dsh-plugin-rea 2.0.2 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,8 +11,10 @@
11
11
  * Runs with `node --test`. No network, no filesystem writes outside a temp dir.
12
12
  */
13
13
  import assert from "node:assert/strict";
14
- import { spawnSync } from "node:child_process";
15
- import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { spawn, spawnSync } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
17
+ import { createServer } from "node:http";
16
18
  import { tmpdir } from "node:os";
17
19
  import path from "node:path";
18
20
  import test from "node:test";
@@ -319,7 +321,17 @@ function runSetupNodeOnly(setupPath, root, env) {
319
321
  });
320
322
  }
321
323
 
322
- test("--reuse still adopts a compatible node", () => {
324
+ test("--reuse still adopts a compatible node", (t) => {
325
+ // The candidate is the host's own interpreter, so this test only means anything
326
+ // where the host satisfies REA's engines: on a Node 20 runner it fails for a
327
+ // reason that has nothing to do with the reuse check (the check is doing its
328
+ // job), while CI's pinned Node 22 never notices.
329
+ const [major, minor] = process.versions.node.split(".").map(Number);
330
+ const compatible = (major === 22 && minor >= 19) || (major === 24 && minor >= 11) || major >= 26;
331
+ if (!compatible) {
332
+ t.skip(`host Node ${process.versions.node} is outside REA's engines; the candidate would be rejected on purpose`);
333
+ return;
334
+ }
323
335
  const root = mkdtempSync(path.join(tmpdir(), "rea-node-ok-"));
324
336
  try {
325
337
  const run = runSetupNodeOnly(SETUP_SOURCE, root, { REA_BUNDLE_NODE: process.execPath });
@@ -395,7 +407,7 @@ test("an unavailable optional component is reported, not fatal", () => {
395
407
  mkdirSync(stub, { recursive: true });
396
408
  writeFileSync(
397
409
  path.join(stub, "rea.mjs"),
398
- 'if (process.argv.includes("--version")) { console.log("6.4.0"); } else { console.log(JSON.stringify({ checks: [], tools: [] })); }\n',
410
+ 'if (process.argv.includes("--version")) { console.log("6.5.0"); } else { console.log(JSON.stringify({ checks: [], tools: [] })); }\n',
399
411
  );
400
412
 
401
413
  const run = spawnSync(process.execPath, [SETUP_SOURCE, "--offline", "--only", "androidPlatformTools", "--root", root], {
@@ -416,3 +428,743 @@ test("an unavailable optional component is reported, not fatal", () => {
416
428
  rmSync(root, { recursive: true, force: true });
417
429
  }
418
430
  });
431
+
432
+ /* =========================== this round's regressions =========================
433
+ *
434
+ * Each test below pins a defect that was verified in the code before it was
435
+ * changed. They run setup.mjs itself (never a mirror of it) against a throwaway
436
+ * plugin tree, so the whole wiring — manifest → download → place → wire — is the
437
+ * real one. Nothing here reaches the network: a component is either satisfied
438
+ * from a primed cache, or its download is *supposed* to return before opening a
439
+ * socket.
440
+ */
441
+
442
+ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
443
+ const REPO_SHIM = path.join(REPO_ROOT, "bin", "rea-shim.mjs");
444
+
445
+ /** A throwaway plugin tree: setup.mjs, the real manifest, and the shim. */
446
+ function makePluginTree(root, mutateManifest) {
447
+ const plugin = path.join(root, "plugin");
448
+ mkdirSync(path.join(plugin, "scripts"), { recursive: true });
449
+ mkdirSync(path.join(plugin, "manifest"), { recursive: true });
450
+ mkdirSync(path.join(plugin, "bin"), { recursive: true });
451
+ copyFileSync(SETUP_SOURCE, path.join(plugin, "scripts", "setup.mjs"));
452
+ copyFileSync(REPO_SHIM, path.join(plugin, "bin", "rea-shim.mjs"));
453
+ const manifest = JSON.parse(readFileSync(MANIFEST_SOURCE, "utf8"));
454
+ if (mutateManifest) mutateManifest(manifest);
455
+ writeFileSync(path.join(plugin, "manifest", "bundle.manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
456
+ return path.join(plugin, "scripts", "setup.mjs");
457
+ }
458
+
459
+ /** A `file`-kind component, used to replace a real one in a test manifest. */
460
+ function testComponent(url, payload, extra = {}) {
461
+ return {
462
+ description: "test vehicle",
463
+ kind: "file",
464
+ license: "test",
465
+ requires: extra.requires ?? [],
466
+ optional: extra.optional ?? false,
467
+ probe: extra.probe ?? {},
468
+ entries: {
469
+ all: [
470
+ {
471
+ id: "payload",
472
+ version: "1",
473
+ url,
474
+ format: "file",
475
+ size: payload.length,
476
+ sha256: createHash("sha256").update(payload).digest("hex"),
477
+ moveTo: extra.moveTo ?? "payload",
478
+ saveAs: extra.saveAs ?? "payload.bin",
479
+ ...(extra.executable ? { executable: true } : {}),
480
+ },
481
+ ],
482
+ },
483
+ };
484
+ }
485
+
486
+ /** A stub vendored CLI, so the probe/shim stages have something to run. */
487
+ function addReaStub(bundleRoot, version = "6.5.0") {
488
+ const dir = path.join(bundleRoot, "vendor", "node_modules", "rea-agents", "scripts");
489
+ mkdirSync(dir, { recursive: true });
490
+ const identity = JSON.stringify({ cli_package_version: version, catalog: { counts: { mcp_tools: 206 } } });
491
+ writeFileSync(
492
+ path.join(dir, "rea.mjs"),
493
+ 'if (process.argv.includes("--version")) { console.log(' + JSON.stringify(version) + '); }\n' +
494
+ "else { console.log(JSON.stringify({ healthy: true, checks: [], identity: " + identity + " })); }\n",
495
+ );
496
+ }
497
+
498
+ function runToCompletion(command, args, options) {
499
+ return new Promise((resolve) => {
500
+ const child = spawn(command, args, options);
501
+ let stdout = "";
502
+ let stderr = "";
503
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
504
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
505
+ child.on("error", (error) => resolve({ status: -1, stdout, stderr: String(error) }));
506
+ child.on("close", (code) => resolve({ status: code, stdout, stderr }));
507
+ });
508
+ }
509
+
510
+ async function waitFor(predicate, timeoutMs = 8000) {
511
+ const deadline = Date.now() + timeoutMs;
512
+ for (;;) {
513
+ if (predicate()) return true;
514
+ if (Date.now() > deadline) return false;
515
+ await new Promise((resolve) => setTimeout(resolve, 50));
516
+ }
517
+ }
518
+
519
+ /* ------------------------------- --only keeps what it did not touch -------- */
520
+
521
+ test("--only keeps the records of the components it did not touch", () => {
522
+ const root = mkdtempSync(path.join(tmpdir(), "rea-only-keep-"));
523
+ try {
524
+ const bundle = path.join(root, "bundle");
525
+ mkdirSync(bundle, { recursive: true });
526
+ const jdkHome = path.join(root, "jdk-home");
527
+ mkdirSync(jdkHome, { recursive: true });
528
+ // A provisioned machine: the JDK and Node are recorded, and only adb is being
529
+ // topped up. Writing bundle.json from this run's results alone erased the
530
+ // other two, so the shim stopped exporting JAVA_HOME/GHIDRA_INSTALL_DIR.
531
+ writeFileSync(
532
+ path.join(bundle, "bundle.json"),
533
+ JSON.stringify(
534
+ {
535
+ schema: 1,
536
+ paths: { node: process.execPath, jdk: jdkHome, ghidra: null, adb: null },
537
+ versions: { reaAgents: "6.5.0", node: process.versions.node, jdk: "21" },
538
+ components: {
539
+ node: { path: process.execPath, version: process.versions.node },
540
+ jdk: { path: jdkHome, version: "21" },
541
+ },
542
+ },
543
+ null,
544
+ 2,
545
+ ) + "\n",
546
+ );
547
+ addReaStub(bundle);
548
+ const setup = makePluginTree(root, (manifest) => {
549
+ manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", Buffer.from("adb"), { optional: true });
550
+ });
551
+ const run = spawnSync(process.execPath, [setup, "--offline", "--only", "androidPlatformTools", "--root", bundle], {
552
+ encoding: "utf8",
553
+ timeout: 120000,
554
+ });
555
+ assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
556
+ const written = JSON.parse(readFileSync(path.join(bundle, "bundle.json"), "utf8"));
557
+ assert.equal(written.components.jdk.path, jdkHome, "the jdk record must survive an --only run");
558
+ assert.equal(written.paths.jdk, jdkHome, "and so must the path the shim exports as JAVA_HOME");
559
+ assert.equal(written.components.node.path, process.execPath);
560
+ assert.equal(written.paths.node, process.execPath);
561
+ // The component that did run failed (offline), and stays unavailable: the
562
+ // merge must not invent a path for it.
563
+ assert.equal(written.paths.adb, null);
564
+ assert.equal(written.components.androidPlatformTools.unavailable, true);
565
+ } finally {
566
+ rmSync(root, { recursive: true, force: true });
567
+ }
568
+ });
569
+
570
+ test("--only satisfies a requirement from the record instead of skipping the check", () => {
571
+ const root = mkdtempSync(path.join(tmpdir(), "rea-only-requires-"));
572
+ try {
573
+ const bundle = path.join(root, "bundle");
574
+ mkdirSync(bundle, { recursive: true });
575
+ const jdkHome = path.join(root, "jdk-home");
576
+ mkdirSync(jdkHome, { recursive: true });
577
+ const setup = makePluginTree(root, () => {});
578
+ const args = (extra) => [setup, "--reuse", "--dry-run", "--only", "apktool", "--root", bundle, ...extra];
579
+ // JAVA_HOME is also a way "the machine" answers this requirement, so it is
580
+ // cleared: the first case needs the requirement to be genuinely unavailable.
581
+ const env = { ...process.env, JAVA_HOME: "" };
582
+
583
+ // No record at all: the requirement is genuinely unavailable, and that must be
584
+ // reported exactly as a full run reports it.
585
+ const missing = spawnSync(process.execPath, args([]), { encoding: "utf8", timeout: 120000, env });
586
+ assert.match(missing.stdout, /required component "jdk" is unavailable/);
587
+
588
+ // With a recorded JDK the requirement is answered by the record, so apktool
589
+ // plans instead of failing on a vendor/jdk that --only never provisions.
590
+ writeFileSync(
591
+ path.join(bundle, "bundle.json"),
592
+ JSON.stringify({ schema: 1, paths: { jdk: jdkHome }, versions: { jdk: "21" }, components: { jdk: { path: jdkHome, version: "21" } } }, null, 2) + "\n",
593
+ );
594
+ const recorded = spawnSync(process.execPath, args([]), { encoding: "utf8", timeout: 120000, env });
595
+ assert.equal(recorded.status, 0, `${recorded.stdout}\n${recorded.stderr}`);
596
+ assert.doesNotMatch(recorded.stdout, /required component "jdk" is unavailable/);
597
+ assert.match(recorded.stdout, /\[plan\] apktool/);
598
+ // And `--only reaAgents` on a fresh root must still work: the node it needs is
599
+ // the interpreter running setup, which is what provisionReaAgents already uses.
600
+ const fresh = path.join(root, "fresh");
601
+ const reaOnly = spawnSync(process.execPath, [setup, "--dry-run", "--only", "reaAgents", "--root", fresh], { encoding: "utf8", timeout: 120000, env });
602
+ assert.equal(reaOnly.status, 0, `${reaOnly.stdout}\n${reaOnly.stderr}`);
603
+ assert.doesNotMatch(reaOnly.stdout, /required component "node" is unavailable/);
604
+ } finally {
605
+ rmSync(root, { recursive: true, force: true });
606
+ }
607
+ });
608
+
609
+ /* ------------------------------------------- a complete .part is finished work */
610
+
611
+ test("a complete .part is verified and kept instead of re-requested", () => {
612
+ const root = mkdtempSync(path.join(tmpdir(), "rea-part-complete-"));
613
+ try {
614
+ const bundle = path.join(root, "bundle");
615
+ mkdirSync(path.join(bundle, "cache"), { recursive: true });
616
+ const payload = Buffer.from("the whole file, already on disk\n");
617
+ // The URL points at a closed port on purpose: the fixed code must complete
618
+ // without opening a socket. Before the fix it sent `Range: bytes=<size>-`,
619
+ // which a real server answers with 416, and four attempts later it reported a
620
+ // download that was already finished as failed.
621
+ const setup = makePluginTree(root, (manifest) => {
622
+ manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", payload);
623
+ });
624
+ addReaStub(bundle);
625
+ writeFileSync(path.join(bundle, "cache", "payload.bin.part"), payload);
626
+
627
+ const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
628
+ assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
629
+ assert.match(run.stdout, /was already complete; verified and kept/);
630
+ assert.deepEqual(readFileSync(path.join(bundle, "vendor", "payload", "payload.bin")), payload);
631
+ } finally {
632
+ rmSync(root, { recursive: true, force: true });
633
+ }
634
+ });
635
+
636
+ /* ------------------------------------------------- the PowerShell route resumes */
637
+
638
+ test("the PowerShell route resumes from the bytes already on disk", async (t) => {
639
+ if (process.platform !== "win32") {
640
+ // The Node route pins TLS (`httpsRequest`) whatever the URL scheme says, so a
641
+ // plain-HTTP fixture can only exercise the PowerShell route — which is also the
642
+ // Windows default and the one the resume defect was in.
643
+ t.skip("the PowerShell download route only exists on Windows");
644
+ return;
645
+ }
646
+ const payload = Buffer.alloc(1024 * 1024, 11);
647
+ const alreadyHave = 256 * 1024;
648
+ const seen = [];
649
+ const server = createServer((req, res) => {
650
+ req.on("error", () => {});
651
+ res.on("error", () => {});
652
+ const range = req.headers.range ?? null;
653
+ seen.push(range);
654
+ const start = range ? Number(/bytes=(\d+)-/.exec(range)?.[1] ?? 0) : 0;
655
+ if (range && start >= payload.length) {
656
+ res.writeHead(416);
657
+ res.end();
658
+ return;
659
+ }
660
+ const slice = payload.subarray(start);
661
+ const headers = { "content-type": "application/octet-stream", "content-length": String(slice.length) };
662
+ if (start > 0) headers["content-range"] = `bytes ${start}-${payload.length - 1}/${payload.length}`;
663
+ res.writeHead(start > 0 ? 206 : 200, headers);
664
+ res.end(slice);
665
+ });
666
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
667
+ const port = server.address().port;
668
+ const root = mkdtempSync(path.join(tmpdir(), "rea-ps-resume-"));
669
+ try {
670
+ const bundle = path.join(root, "bundle");
671
+ mkdirSync(path.join(bundle, "cache"), { recursive: true });
672
+ const setup = makePluginTree(root, (manifest) => {
673
+ manifest.components.androidPlatformTools = testComponent(`http://127.0.0.1:${port}/payload.bin`, payload);
674
+ });
675
+ addReaStub(bundle);
676
+ writeFileSync(path.join(bundle, "cache", "payload.bin.part"), payload.subarray(0, alreadyHave));
677
+
678
+ const run = await runToCompletion(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], {
679
+ encoding: "utf8",
680
+ timeout: 120000,
681
+ });
682
+ assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
683
+ assert.ok(
684
+ seen.some((header) => header && Number(/bytes=(\d+)-/.exec(header)?.[1]) === alreadyHave),
685
+ `expected a resume request starting at ${alreadyHave}; saw ${JSON.stringify(seen)}`,
686
+ );
687
+ assert.deepEqual(readFileSync(path.join(bundle, "vendor", "payload", "payload.bin")), payload);
688
+ } finally {
689
+ await new Promise((resolve) => server.close(resolve));
690
+ rmSync(root, { recursive: true, force: true });
691
+ }
692
+ });
693
+
694
+ /* ------------------------------------------------- a wrapper must be executable */
695
+
696
+ test("a wrapper marked executable lands with the exec bit on POSIX", (t) => {
697
+ if (process.platform === "win32") {
698
+ t.skip("POSIX exec bits do not exist on Windows; the flag is honoured but meaningless there");
699
+ return;
700
+ }
701
+ const root = mkdtempSync(path.join(tmpdir(), "rea-exec-bit-"));
702
+ try {
703
+ const bundle = path.join(root, "bundle");
704
+ mkdirSync(path.join(bundle, "cache"), { recursive: true });
705
+ const marked = Buffer.from("#!/bin/sh\necho wrapper\n");
706
+ const plain = Buffer.from("not a script\n");
707
+ const setup = makePluginTree(root, (manifest) => {
708
+ const component = testComponent("http://127.0.0.1:9/wrapper.sh", marked, { executable: true, saveAs: "wrapper.sh", moveTo: "tool" });
709
+ component.entries.all.push({
710
+ id: "plain",
711
+ version: "1",
712
+ url: "http://127.0.0.1:9/plain.sh",
713
+ format: "file",
714
+ size: plain.length,
715
+ sha256: createHash("sha256").update(plain).digest("hex"),
716
+ moveTo: "tool",
717
+ saveAs: "plain.sh",
718
+ });
719
+ manifest.components.androidPlatformTools = component;
720
+ });
721
+ addReaStub(bundle);
722
+ // Prime the cache so no socket is opened and the mode is the only variable.
723
+ writeFileSync(path.join(bundle, "cache", "wrapper.sh"), marked);
724
+ writeFileSync(path.join(bundle, "cache", "plain.sh"), plain);
725
+
726
+ const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
727
+ assert.equal(run.status, 0, `${run.stdout}\n${run.stderr}`);
728
+ const wrapperMode = statSync(path.join(bundle, "vendor", "tool", "wrapper.sh")).mode;
729
+ const plainMode = statSync(path.join(bundle, "vendor", "tool", "plain.sh")).mode;
730
+ assert.notEqual(wrapperMode & 0o111, 0, `the marked wrapper must be executable, mode ${wrapperMode.toString(8)}`);
731
+ assert.equal(plainMode & 0o111, 0, "an unmarked file must keep the mode the archive gave it");
732
+ } finally {
733
+ rmSync(root, { recursive: true, force: true });
734
+ }
735
+ });
736
+
737
+ /* ------------------------------------------- a file probe's expect is honoured */
738
+
739
+ test("a file probe honours expect.contains", () => {
740
+ const root = mkdtempSync(path.join(tmpdir(), "rea-file-probe-"));
741
+ try {
742
+ const make = (content) => {
743
+ const bundle = path.join(root, `bundle-${content.length}`);
744
+ mkdirSync(path.join(bundle, "cache"), { recursive: true });
745
+ const payload = Buffer.from(content);
746
+ const component = testComponent("http://127.0.0.1:9/marker.txt", payload, { saveAs: "marker.txt", moveTo: "probe" });
747
+ component.probe = { file: "{vendor}/probe/marker.txt", expect: { contains: "application.version=" } };
748
+ const setup = makePluginTree(path.join(root, `tree-${content.length}`), (manifest) => {
749
+ manifest.components.androidPlatformTools = component;
750
+ });
751
+ addReaStub(bundle);
752
+ writeFileSync(path.join(bundle, "cache", "marker.txt"), payload);
753
+ return spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], { encoding: "utf8", timeout: 120000 });
754
+ };
755
+ const ok = make("application.version=12.1.4\n");
756
+ assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
757
+ // The file exists either way; only its content decides. Before the fix the
758
+ // `expect` on a file probe was dead configuration and this case passed.
759
+ const wrong = make("this is not a Ghidra properties file\n");
760
+ assert.equal(wrong.status, 1, `${wrong.stdout}\n${wrong.stderr}`);
761
+ assert.match(wrong.stdout, /does not contain "application\.version="/);
762
+ } finally {
763
+ rmSync(root, { recursive: true, force: true });
764
+ }
765
+ });
766
+
767
+ /* ------------------------------------- a reused engine must pass its own probe */
768
+
769
+ test("a reused component must pass the probe a provisioned copy would face", () => {
770
+ const root = mkdtempSync(path.join(tmpdir(), "rea-reuse-probe-"));
771
+ try {
772
+ const install = path.join(root, "ghidra");
773
+ mkdirSync(path.join(install, "support"), { recursive: true });
774
+ mkdirSync(path.join(install, "Ghidra"), { recursive: true });
775
+ writeFileSync(path.join(install, "support", process.platform === "win32" ? "analyzeHeadless.bat" : "analyzeHeadless"), "");
776
+ const properties = path.join(install, "Ghidra", "application.properties");
777
+ const setup = makePluginTree(root, (manifest) => {
778
+ // The real manifest needs a JDK for ghidra; this test is about the reuse
779
+ // probe, so the requirement is removed rather than faked.
780
+ manifest.components.ghidra.requires = [];
781
+ });
782
+ const bundle = path.join(root, "bundle");
783
+ const args = ["--reuse", "--dry-run", "--only", "ghidra", "--root", bundle];
784
+ const env = { ...process.env, GHIDRA_INSTALL_DIR: install };
785
+
786
+ writeFileSync(properties, "application.version=12.1.4\n");
787
+ const good = spawnSync(process.execPath, [setup, ...args], { encoding: "utf8", env, timeout: 120000 });
788
+ assert.equal(good.status, 0, `${good.stdout}\n${good.stderr}`);
789
+ assert.match(good.stdout, /reusing existing ghidra from GHIDRA_INSTALL_DIR/);
790
+
791
+ // Adoption used to test only that analyzeHeadless exists, so any directory
792
+ // shaped like a Ghidra install was recorded and the failure surfaced inside REA.
793
+ writeFileSync(properties, "not a Ghidra properties file\n");
794
+ const bad = spawnSync(process.execPath, [setup, ...args], { encoding: "utf8", env, timeout: 120000 });
795
+ assert.doesNotMatch(bad.stdout, /reusing existing ghidra/);
796
+ assert.match(bad.stdout, /not reusing GHIDRA_INSTALL_DIR ghidra/);
797
+ } finally {
798
+ rmSync(root, { recursive: true, force: true });
799
+ }
800
+ });
801
+
802
+ /* ------------------------------------------------------------- the shim's lock */
803
+
804
+ function makeBundleTree(root, { marker, lock }) {
805
+ const bundle = path.join(root, "bundle");
806
+ mkdirSync(path.join(bundle, "bin"), { recursive: true });
807
+ mkdirSync(path.join(bundle, "scripts"), { recursive: true });
808
+ mkdirSync(path.join(bundle, "manifest"), { recursive: true });
809
+ copyFileSync(REPO_SHIM, path.join(bundle, "bin", "rea-shim.mjs"));
810
+ writeFileSync(path.join(bundle, "scripts", "setup.mjs"), 'import { writeFileSync } from "node:fs";\nwriteFileSync(' + JSON.stringify(marker) + ', "ran");\n');
811
+ writeFileSync(path.join(bundle, "manifest", "bundle.manifest.json"), "{}\n");
812
+ if (lock !== undefined) writeFileSync(path.join(bundle, "setup.lock"), lock);
813
+ return bundle;
814
+ }
815
+
816
+ function runShim(bundle, args, extraEnv = {}) {
817
+ // A fresh home keeps the duplicate-registration check from reading this
818
+ // machine's real ~/.dsh/cordis.patch.yml and making the suite depend on it.
819
+ const env = {
820
+ ...process.env,
821
+ DSH_HOME: path.join(path.dirname(bundle), "dsh-home"),
822
+ REA_BUNDLE_ROOT: bundle,
823
+ ...extraEnv,
824
+ };
825
+ delete env.REA_BUNDLE_AUTO_PROVISION;
826
+ mkdirSync(env.DSH_HOME, { recursive: true });
827
+ return spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), ...args], { encoding: "utf8", timeout: 60000, env });
828
+ }
829
+
830
+ test("a malformed bundle.json starts a provisioning run instead of failing forever", async () => {
831
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-malformed-"));
832
+ try {
833
+ const marker = path.join(root, "setup-ran");
834
+ const bundle = makeBundleTree(root, { marker, lock: undefined });
835
+ writeFileSync(path.join(bundle, "bundle.json"), "{ this is not json");
836
+ const run = runShim(bundle, ["mcp"]);
837
+ assert.equal(run.status, 78, run.stderr);
838
+ assert.match(run.stderr, /provisioning started in the background/);
839
+ assert.ok(await waitFor(() => existsSync(marker)), "setup was never started for a malformed bundle.json");
840
+ } finally {
841
+ rmSync(root, { recursive: true, force: true });
842
+ }
843
+ });
844
+
845
+ test("a failed lock is reported as failed, not as provisioning in progress", async () => {
846
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-failed-lock-"));
847
+ try {
848
+ const marker = path.join(root, "setup-ran");
849
+ const lock = JSON.stringify({ state: "failed", pid: 1, at: Date.now(), exit: 1 });
850
+ const bundle = makeBundleTree(root, { marker, lock });
851
+ // No bundle.json: the "missing" path, which used to print the "provisioning
852
+ // started" sentence for six hours while nothing was running at all.
853
+ const run = runShim(bundle, ["mcp"]);
854
+ assert.equal(run.status, 78, run.stderr);
855
+ assert.match(run.stderr, /the last provisioning attempt failed/);
856
+ assert.doesNotMatch(run.stderr, /provisioning started in the background/);
857
+ assert.equal(existsSync(marker), false, "a fresh failed lock must not trigger another run");
858
+ } finally {
859
+ rmSync(root, { recursive: true, force: true });
860
+ }
861
+ });
862
+
863
+ test("a lock whose holder is gone does not block provisioning", async () => {
864
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-dead-lock-"));
865
+ try {
866
+ const marker = path.join(root, "setup-ran");
867
+ const dead = spawnSync(process.execPath, ["-e", ""]);
868
+ assert.ok(Number.isInteger(dead.pid), "could not obtain a pid to outlive");
869
+ const lock = JSON.stringify({ state: "running", pid: dead.pid, at: Date.now() });
870
+ const bundle = makeBundleTree(root, { marker, lock });
871
+ const run = runShim(bundle, ["mcp"]);
872
+ assert.equal(run.status, 78, run.stderr);
873
+ assert.match(run.stderr, /provisioning started in the background/);
874
+ assert.ok(await waitFor(() => existsSync(marker)), "a dead lock holder must not suppress the run");
875
+ } finally {
876
+ rmSync(root, { recursive: true, force: true });
877
+ }
878
+ });
879
+
880
+ test("an expired lock is cleared instead of wedging provisioning forever", async () => {
881
+ const stale = Date.now() - 7 * 60 * 60 * 1000;
882
+ const dead = spawnSync(process.execPath, ["-e", ""]).pid;
883
+ const cases = {
884
+ "running lock with a dead holder": JSON.stringify({ state: "running", pid: dead, at: stale }),
885
+ "failed lock past the freshness window": JSON.stringify({ state: "failed", pid: dead, at: stale, exit: 1 }),
886
+ "legacy numeric lock": String(stale),
887
+ };
888
+ for (const [label, lock] of Object.entries(cases)) {
889
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-stale-"));
890
+ try {
891
+ const marker = path.join(root, "setup-ran");
892
+ const bundle = makeBundleTree(root, { marker, lock });
893
+ const run = runShim(bundle, ["mcp"]);
894
+ assert.equal(run.status, 78, `${label}: ${run.stderr}`);
895
+ // Reproduced before the fix: all three cases were silent AND blocked, because
896
+ // the stale lock was never removed and the exclusive create then failed with
897
+ // EEXIST forever. A lock older than the freshness window is not evidence of a
898
+ // run, so it must be dropped and the attempt started again.
899
+ assert.match(run.stderr, /provisioning started in the background/, label);
900
+ assert.ok(await waitFor(() => existsSync(marker)), `${label}: setup was never started`);
901
+ } finally {
902
+ rmSync(root, { recursive: true, force: true });
903
+ }
904
+ }
905
+ });
906
+
907
+ test("a read-only bundle root reports unwritable instead of crashing on the stale lock", { skip: process.platform === "win32" ? "POSIX directory permissions only" : false }, () => {
908
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-readonly-"));
909
+ try {
910
+ const stale = Date.now() - 7 * 60 * 60 * 1000;
911
+ const lock = JSON.stringify({ state: "running", pid: 999999, at: stale });
912
+ const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock });
913
+ chmodSync(bundle, 0o555);
914
+ try {
915
+ const run = runShim(bundle, ["mcp"], { DSH_HOME: path.join(root, "dsh-home") });
916
+ assert.equal(run.status, 78, run.stderr);
917
+ // Before the fix this was an uncaught EACCES stack trace and exit 1: the
918
+ // stale lock could not be removed, and nothing caught the throw.
919
+ assert.match(run.stderr, /provisioning could not be started \(unwritable\)/);
920
+ assert.doesNotMatch(run.stderr, /EACCES/);
921
+ } finally {
922
+ chmodSync(bundle, 0o755);
923
+ }
924
+ } finally {
925
+ rmSync(root, { recursive: true, force: true });
926
+ }
927
+ });
928
+
929
+ test("a second REA registration is reported instead of silently duplicated", () => {
930
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-duplicate-"));
931
+ try {
932
+ const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock: undefined });
933
+ const home = path.join(root, "dsh-home");
934
+ mkdirSync(home, { recursive: true });
935
+ writeFileSync(
936
+ path.join(home, "cordis.patch.yml"),
937
+ ["- insert:", " - id: rea-mcp", " name: '@deepseek-ai/dsh-mcp-client'", " config:", " serverName: rea", " transport: stdio", ""].join("\n"),
938
+ );
939
+ const run = runShim(bundle, ["mcp"], { DSH_HOME: home });
940
+ assert.equal(run.status, 78, run.stderr);
941
+ assert.match(run.stderr, /duplicate REA registration/);
942
+ assert.match(run.stderr, /id "rea-mcp"/);
943
+
944
+ const status = spawnSync(process.execPath, [path.join(bundle, "bin", "rea-shim.mjs"), "--shim-status"], {
945
+ encoding: "utf8",
946
+ env: { ...process.env, DSH_HOME: home, REA_BUNDLE_ROOT: bundle },
947
+ });
948
+ assert.equal(JSON.parse(status.stdout).duplicateRegistration.id, "rea-mcp");
949
+ } finally {
950
+ rmSync(root, { recursive: true, force: true });
951
+ }
952
+ });
953
+
954
+ test("this bundle's own row is not reported as a duplicate", () => {
955
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-own-row-"));
956
+ try {
957
+ const bundle = makeBundleTree(root, { marker: path.join(root, "setup-ran"), lock: undefined });
958
+ const home = path.join(root, "dsh-home");
959
+ mkdirSync(home, { recursive: true });
960
+ writeFileSync(
961
+ path.join(home, "cordis.patch.yml"),
962
+ ["- insert:", " - id: mcp-rea", " name: '@deepseek-ai/dsh-mcp-client'", " config:", " serverName: rea", ""].join("\n"),
963
+ );
964
+ const run = runShim(bundle, ["mcp"], { DSH_HOME: home });
965
+ assert.equal(run.status, 78, run.stderr);
966
+ assert.doesNotMatch(run.stderr, /duplicate REA registration/);
967
+ } finally {
968
+ rmSync(root, { recursive: true, force: true });
969
+ }
970
+ });
971
+
972
+ /* ------------------------------- --offline is a promise, and npm is the one step
973
+ * that cannot keep it: it is an install, not a download. -------------------- */
974
+
975
+ test("--offline refuses the npm step instead of going online anyway", () => {
976
+ const root = mkdtempSync(path.join(tmpdir(), "rea-offline-rea-"));
977
+ try {
978
+ const setup = makePluginTree(root, () => {});
979
+ const bundle = path.join(root, "bundle");
980
+ const run = spawnSync(process.execPath, [setup, "--offline", "--only", "reaAgents", "--root", bundle], {
981
+ encoding: "utf8",
982
+ timeout: 120000,
983
+ });
984
+ assert.equal(run.status, 1, `${run.stdout}\n${run.stderr}`);
985
+ assert.match(run.stdout, /--offline and the vendored REA/);
986
+
987
+ // The other direction: a usable vendored copy is still accepted offline, so the
988
+ // flag does not turn into "reaAgents can never be provisioned".
989
+ const second = path.join(root, "bundle2");
990
+ mkdirSync(second, { recursive: true });
991
+ addReaStub(second);
992
+ const ok = spawnSync(process.execPath, [setup, "--offline", "--only", "reaAgents", "--root", second], {
993
+ encoding: "utf8",
994
+ timeout: 120000,
995
+ });
996
+ assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
997
+ assert.match(ok.stdout, /already vendored/);
998
+ } finally {
999
+ rmSync(root, { recursive: true, force: true });
1000
+ }
1001
+ });
1002
+
1003
+ /* ------------------------------- numeric overrides must not become NaN ------ */
1004
+
1005
+ test("a non-numeric numeric override falls back to the default", () => {
1006
+ const root = mkdtempSync(path.join(tmpdir(), "rea-env-"));
1007
+ try {
1008
+ const setup = makePluginTree(root, () => {});
1009
+ const run = (env) =>
1010
+ spawnSync(process.execPath, [setup, "--dry-run", "--only", "node", "--root", path.join(root, "bundle")], {
1011
+ encoding: "utf8",
1012
+ timeout: 120000,
1013
+ env: { ...process.env, ...env },
1014
+ });
1015
+ // `Number("abc")` is NaN, and NaN reached the socket layer as 0-ish behaviour;
1016
+ // `REA_BUNDLE_FETCH_RETRIES=abc` once produced "gave up after NaN attempt(s)".
1017
+ const bogus = run({ REA_BUNDLE_FETCH_RETRIES: "abc", REA_BUNDLE_IDLE_TIMEOUT_MS: "abc", REA_BUNDLE_CONNECT_TIMEOUT_MS: "abc" });
1018
+ assert.equal(bogus.status, 0, `${bogus.stdout}\n${bogus.stderr}`);
1019
+ assert.match(bogus.stdout, /retries=4\b/);
1020
+ assert.match(bogus.stdout, /idle=120000ms/);
1021
+ assert.doesNotMatch(bogus.stdout, /NaN/);
1022
+ const explicit = run({ REA_BUNDLE_FETCH_RETRIES: "7" });
1023
+ assert.match(explicit.stdout, /retries=7\b/);
1024
+ } finally {
1025
+ rmSync(root, { recursive: true, force: true });
1026
+ }
1027
+ });
1028
+
1029
+ /* ------------------------------- POSIX extraction fallbacks ----------------- */
1030
+
1031
+ /** An `archive`-kind component, for the extraction paths. */
1032
+ function testArchive(url, payload, extra = {}) {
1033
+ return {
1034
+ description: "test archive",
1035
+ kind: "archive",
1036
+ license: "test",
1037
+ requires: [],
1038
+ optional: false,
1039
+ unpack: { stripRootComponents: extra.strip ?? 0, moveTo: extra.moveTo ?? "arch" },
1040
+ probe: extra.probe ?? {},
1041
+ sources: {
1042
+ all: {
1043
+ version: "1",
1044
+ url,
1045
+ format: extra.format ?? "zip",
1046
+ size: payload.length,
1047
+ sha256: createHash("sha256").update(payload).digest("hex"),
1048
+ },
1049
+ },
1050
+ };
1051
+ }
1052
+
1053
+ test("a zip still extracts when unzip is absent (POSIX fallbacks)", (t) => {
1054
+ if (process.platform === "win32") {
1055
+ t.skip("Windows extracts through tar/Expand-Archive; the POSIX fallbacks do not exist there");
1056
+ return;
1057
+ }
1058
+ const root = mkdtempSync(path.join(tmpdir(), "rea-extract-"));
1059
+ try {
1060
+ // A real zip, built with the very tool the fallback will use.
1061
+ const zipPath = path.join(root, "fixture.zip");
1062
+ const built = spawnSync("python3", [
1063
+ "-c",
1064
+ "import sys,zipfile;z=zipfile.ZipFile(sys.argv[1],'w');z.writestr('inside.txt','hello from inside\\n');z.close()",
1065
+ zipPath,
1066
+ ]);
1067
+ if (built.status !== 0) {
1068
+ t.skip("python3 is unavailable, so neither the fixture nor the fallback can run");
1069
+ return;
1070
+ }
1071
+ const payload = readFileSync(zipPath);
1072
+ const python = spawnSync("sh", ["-c", "command -v python3"], { encoding: "utf8" }).stdout.trim();
1073
+ assert.ok(python, "python3 was just used to build the fixture");
1074
+
1075
+ // A PATH that has python3 and nothing else: unzip cannot be found, which is
1076
+ // exactly the bare-server case. Ghidra ships only as a zip, so this used to be
1077
+ // a certain failure rather than an occasional one.
1078
+ const binDir = path.join(root, "bin");
1079
+ mkdirSync(binDir, { recursive: true });
1080
+ const onlyPython = path.join(binDir, "python3");
1081
+ symlinkSync(python, onlyPython);
1082
+ const setup = makePluginTree(root, (manifest) => {
1083
+ manifest.components.androidPlatformTools = testArchive("http://127.0.0.1:9/fixture.zip", payload);
1084
+ });
1085
+ const cases = [
1086
+ { label: "python3 fallback", path: binDir, expected: 0 },
1087
+ { label: "no extractor at all", path: path.join(root, "empty-bin"), expected: 1 },
1088
+ ];
1089
+ for (const testCase of cases) {
1090
+ mkdirSync(testCase.path, { recursive: true });
1091
+ const bundle = path.join(root, `bundle-${testCase.expected}`);
1092
+ mkdirSync(path.join(bundle, "cache"), { recursive: true });
1093
+ writeFileSync(path.join(bundle, "cache", "fixture.zip"), payload);
1094
+ addReaStub(bundle);
1095
+ const run = spawnSync(process.execPath, [setup, "--only", "androidPlatformTools", "--root", bundle], {
1096
+ encoding: "utf8",
1097
+ timeout: 120000,
1098
+ env: { ...process.env, PATH: testCase.path },
1099
+ });
1100
+ assert.equal(run.status, testCase.expected, `${testCase.label}: ${run.stdout}\n${run.stderr}`);
1101
+ if (testCase.expected === 0) {
1102
+ assert.equal(readFileSync(path.join(bundle, "vendor", "arch", "inside.txt"), "utf8"), "hello from inside\n");
1103
+ } else {
1104
+ assert.match(run.stdout, /no zip extractor available/);
1105
+ assert.match(run.stdout, /install unzip or python3/);
1106
+ }
1107
+ }
1108
+ } finally {
1109
+ rmSync(root, { recursive: true, force: true });
1110
+ }
1111
+ });
1112
+
1113
+ test("the fallback vendored-REA path matches where setup installs it", () => {
1114
+ const root = mkdtempSync(path.join(tmpdir(), "rea-shim-fallback-"));
1115
+ try {
1116
+ const bundle = path.join(root, "bundle");
1117
+ // An older bundle.json has paths.node but no paths.reaEntry. The fallback used
1118
+ // to omit `node_modules`, so a working install was reported as gone.
1119
+ mkdirSync(bundle, { recursive: true });
1120
+ addReaStub(bundle);
1121
+ writeFileSync(path.join(bundle, "bundle.json"), JSON.stringify({ schema: 1, paths: { node: process.execPath } }, null, 2) + "\n");
1122
+ const run = spawnSync(process.execPath, [path.join(REPO_ROOT, "bin", "rea-shim.mjs"), "--shim-status"], {
1123
+ encoding: "utf8",
1124
+ timeout: 60000,
1125
+ env: { ...process.env, REA_BUNDLE_ROOT: bundle, REA_BUNDLE_AUTO_PROVISION: "0" },
1126
+ });
1127
+ const report = JSON.parse(run.stdout);
1128
+ assert.equal(report.rea.ok, true, JSON.stringify(report.rea));
1129
+ assert.match(report.rea.reaEntry, /node_modules/);
1130
+ } finally {
1131
+ rmSync(root, { recursive: true, force: true });
1132
+ }
1133
+ });
1134
+
1135
+ test("--json releases the lock on success and marks it failed on failure", () => {
1136
+ const root = mkdtempSync(path.join(tmpdir(), "rea-lock-json-"));
1137
+ try {
1138
+ const bundle = path.join(root, "bundle");
1139
+ mkdirSync(bundle, { recursive: true });
1140
+ addReaStub(bundle);
1141
+ const component = (optional) =>
1142
+ makePluginTree(path.join(root, optional ? "ok" : "bad"), (manifest) => {
1143
+ manifest.components.androidPlatformTools = testComponent("http://127.0.0.1:9/payload.bin", Buffer.from("adb"), { optional });
1144
+ });
1145
+
1146
+ // The lock is what tells the shim a run is in flight. A successful --json run
1147
+ // used to leave it behind (the removal sat in the non---json branch), so a
1148
+ // fully provisioned machine advertised "provisioning started" for six hours.
1149
+ writeFileSync(path.join(bundle, "setup.lock"), JSON.stringify({ state: "running", pid: process.pid, at: Date.now() }));
1150
+ const ok = spawnSync(process.execPath, [component(true), "--json", "--offline", "--only", "androidPlatformTools", "--root", bundle], {
1151
+ encoding: "utf8",
1152
+ timeout: 120000,
1153
+ });
1154
+ assert.equal(ok.status, 0, `${ok.stdout}\n${ok.stderr}`);
1155
+ assert.equal(existsSync(path.join(bundle, "setup.lock")), false, "a successful --json run must not leave the lock behind");
1156
+
1157
+ // A failing run keeps the lock on purpose, but records why, so the shim can
1158
+ // report a failure instead of pretending a run is still going.
1159
+ const bad = spawnSync(process.execPath, [component(false), "--json", "--offline", "--only", "androidPlatformTools", "--root", bundle], {
1160
+ encoding: "utf8",
1161
+ timeout: 120000,
1162
+ });
1163
+ assert.equal(bad.status, 1, `${bad.stdout}\n${bad.stderr}`);
1164
+ const lock = JSON.parse(readFileSync(path.join(bundle, "setup.lock"), "utf8"));
1165
+ assert.equal(lock.state, "failed");
1166
+ assert.equal(lock.exit, 1);
1167
+ } finally {
1168
+ rmSync(root, { recursive: true, force: true });
1169
+ }
1170
+ });