@heretek-ai/epistemic-swarm 0.7.19 → 0.7.21

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.
Files changed (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +23 -2
  3. package/package.json +1 -1
  4. package/plugins/antigravity/plugin.json +1 -1
  5. package/plugins/antigravity/rules/AGENTS.md +5 -3
  6. package/plugins/opencode/config-io.js +55 -12
  7. package/plugins/opencode/index.js +611 -0
  8. package/plugins/opencode/tui.js +278 -6
  9. package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
  10. package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
  11. package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
  12. package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
  13. package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
  14. package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
  15. package/runner/__pycache__/errors.cpython-311.pyc +0 -0
  16. package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
  17. package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
  18. package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
  19. package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
  20. package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
  21. package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
  22. package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
  23. package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
  24. package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
  25. package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
  26. package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
  27. package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
  28. package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
  29. package/runner/mcp_server.py +81 -10
  30. package/runner/research_swarm.py +12 -2
  31. package/runner/schemas.py +18 -0
  32. package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
  33. package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
  34. package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
  35. package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
  36. package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
  37. package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
  38. package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
  39. package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
  40. package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
  41. package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
  42. package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
  43. package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
  44. package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
  45. package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
  46. package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
  47. package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
  48. package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
  49. package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
  50. package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
  51. package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
  52. package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
  53. package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
  54. package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
  55. package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
  56. package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
  57. package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
  58. package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
  59. package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
  60. package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
  61. package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
  62. package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
  63. package/runner/tests/test_config_schema.py +106 -0
  64. package/runner/tests/test_opencode_ux.py +462 -7
  65. package/runner/tests/test_opencode_v2_registrars.py +1112 -3
  66. package/runner/tests/test_swarm_config.py +21 -0
  67. package/schemas/config.schema.json +11 -0
  68. package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
  69. package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
  70. package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
  71. package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
  72. package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
  73. package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
  74. package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
  75. package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
  76. package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
  77. package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
  78. package/skills/swarm_config/SKILL.md +26 -0
  79. package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
  80. package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
  81. package/skills/swarm_config/configure.py +40 -6
@@ -388,9 +388,11 @@ class TestConfigToolCatalogSurface(unittest.TestCase):
388
388
  self.assertEqual(problems, [])
389
389
  advertised_keys = set(advertised["properties"]) - self.CONTROL_ARGS
390
390
  self.assertEqual(advertised_keys, set(artifact["properties"]))
391
- # Phase 03 owns mcp_servers: it must stay out of the schema AND the ad.
392
- self.assertNotIn("mcp_servers", advertised["properties"])
393
- self.assertNotIn("mcp_servers", artifact["properties"])
391
+ # Phase 03 owns mcp_servers: the persisted toggle map is in the schema
392
+ # AND the ad (applied via ctx.mcp.transform; catalog file untouched).
393
+ self.assertIn("mcp_servers", advertised["properties"])
394
+ self.assertIn("mcp_servers", artifact["properties"])
395
+ self.assertEqual(advertised["properties"]["mcp_servers"]["type"], "object")
394
396
  # R4: both expected-hash spellings are advertised and documented.
395
397
  self.assertIn("expected_hash", advertised["properties"])
396
398
  self.assertIn("expectedHash", advertised["properties"])
@@ -512,5 +514,1112 @@ class TestWizardSlashRegistration(unittest.TestCase):
512
514
  self.assertEqual(data["withPanel"]["panels"], 1)
513
515
 
514
516
 
517
+ # ---------------------------------------------------------------------------
518
+ # Phase 03 (opencode-settings-menu): `iumbtems.settings` RPC domain, storage
519
+ # mirror, MCP toggles, temperature hook + the bounded S3 spike harness.
520
+ #
521
+ # The mock host below models the v2 API shapes verified against
522
+ # @opencode/plugin@2.0.18 + https://opencode.ai/v2/docs/build/plugins/rpc:
523
+ # `ctx.rpc.register(Def, handlers)` returning `{dispose, events.emit}`,
524
+ # `ctx.storage` get/set/remove/scan, `ctx.mcp.transform` with an
525
+ # editor over `[name, config]` tuples, and `session.hook(name, fn)`.
526
+ # ---------------------------------------------------------------------------
527
+
528
+ PHASE3_HOST = """
529
+ import plugin, {
530
+ SettingsRpc, SETTINGS_RPC_ID, SETTINGS_STORAGE_KEY,
531
+ createSettingsHandlers, registerSettingsRpc,
532
+ resolveMcpToggles, readPersistedMcpToggles, loadControllableMcpServers,
533
+ registerMcpToggles,
534
+ temperatureForRole, applyTemperatureToContext, registerTemperatureHook,
535
+ } from "./plugins/opencode/index.js";
536
+
537
+ function makePhase3Host(root, opts = {}) {
538
+ const emits = [];
539
+ const handlers = {};
540
+ let def = null;
541
+ const storageMap = new Map();
542
+ const mcpServers = new Map(Object.entries(opts.mcpSeed || {
543
+ iumbtems: {}, searxng: {}, "brave-search": {}, firecrawl: {}, "custom-evil": {},
544
+ }));
545
+ const hookCalls = [];
546
+ const host = {
547
+ options: {},
548
+ location: { directory: root },
549
+ rpc: {
550
+ register: async (d, h) => {
551
+ def = d;
552
+ Object.assign(handlers, h);
553
+ return {
554
+ dispose: async () => {},
555
+ events: { emit: async (name, data) => { emits.push({ name, data }); } },
556
+ };
557
+ },
558
+ },
559
+ storage: {
560
+ get: async (k) => storageMap.get(k),
561
+ set: async (k, v) => { storageMap.set(k, v); },
562
+ remove: async (k) => { storageMap.delete(k); },
563
+ scan: async ({ prefix }) => ({
564
+ entries: [...storageMap.entries()]
565
+ .filter(([k]) => String(k).startsWith(prefix))
566
+ .map(([key, value]) => ({ key, value })),
567
+ }),
568
+ },
569
+ mcp: {
570
+ transform: async (fn) => {
571
+ const editor = {
572
+ list: () => [...mcpServers.entries()],
573
+ get: (n) => mcpServers.get(n),
574
+ set: (n, c) => { mcpServers.set(n, c); },
575
+ update: (n, fn2) => { const c = mcpServers.get(n); if (c) fn2(c); },
576
+ remove: (n) => { mcpServers.delete(n); },
577
+ };
578
+ fn(editor);
579
+ return { dispose: async () => {} };
580
+ },
581
+ reload: async () => { host._reloads = (host._reloads || 0) + 1; },
582
+ },
583
+ session: {
584
+ prompt: async () => ({}),
585
+ hook: async (name, fn) => { hookCalls.push({ name, fn }); return { dispose: async () => {} }; },
586
+ },
587
+ command: {
588
+ list: async () => ({ data: [] }),
589
+ transform: async () => ({ dispose: () => {} }),
590
+ reload: async () => {},
591
+ },
592
+ tool: { transform: async () => ({ dispose: () => {} }), reload: async () => {} },
593
+ agent: {
594
+ list: async () => ({ data: [] }),
595
+ transform: async () => ({ dispose: () => {} }),
596
+ reload: async () => {},
597
+ },
598
+ skill: {
599
+ list: async () => ({ data: [] }),
600
+ transform: async () => ({ dispose: () => {} }),
601
+ reload: async () => {},
602
+ },
603
+ };
604
+ return { host, emits, handlers, storageMap, mcpServers, hookCalls, getDef: () => def };
605
+ }
606
+
607
+ // In-memory stub of the canonical Python surface (`callMcp`) with the
608
+ // phase-01/02 hash-guard semantics: full/prefix match proceeds, mismatch is
609
+ // `stale` with a fresh snapshot, conflicting spellings are an error.
610
+ function makeStubMcp(state) {
611
+ const calls = [];
612
+ const impl = async (tool, args = {}) => {
613
+ calls.push({ tool, args });
614
+ if (tool !== "iumbtems_config") {
615
+ return { content: JSON.stringify({ status: "error" }), status: "error" };
616
+ }
617
+ const keys = Object.keys(args || {});
618
+ const readOnly = keys.every((k) =>
619
+ ["base_dir", "show", "expected_hash", "expectedHash"].includes(k));
620
+ if (readOnly) {
621
+ return {
622
+ content: JSON.stringify({
623
+ status: "current", config: { ...state.config }, migrated: false,
624
+ }),
625
+ status: "success",
626
+ };
627
+ }
628
+ const snake = args.expected_hash;
629
+ const camel = args.expectedHash;
630
+ if (snake !== undefined && camel !== undefined && String(snake) !== String(camel)) {
631
+ return {
632
+ content: JSON.stringify({
633
+ status: "error", code: "CONFLICTING_EXPECTED_HASH", written: false,
634
+ }),
635
+ status: "success",
636
+ };
637
+ }
638
+ const expected = snake !== undefined ? snake : camel;
639
+ if (expected !== undefined && expected !== null &&
640
+ !(state.hash || "").startsWith(String(expected))) {
641
+ return {
642
+ content: JSON.stringify({
643
+ status: "stale", written: false,
644
+ expected_hash: String(expected),
645
+ current_hash: state.hash,
646
+ config: { ...state.config },
647
+ }),
648
+ status: "success",
649
+ };
650
+ }
651
+ const updates = { ...(args || {}) };
652
+ for (const k of ["base_dir", "show", "expected_hash", "expectedHash"]) delete updates[k];
653
+ Object.assign(state.config, updates);
654
+ state.writes = (state.writes || 0) + 1;
655
+ state.hash = `hash${state.writes}`;
656
+ return {
657
+ content: JSON.stringify({
658
+ status: "updated", config: { ...state.config }, hash: state.hash,
659
+ }),
660
+ status: "success",
661
+ };
662
+ };
663
+ impl.calls = calls;
664
+ return impl;
665
+ }
666
+
667
+ // Real-host `context.error(type, message, data)` shape: throw a typed error.
668
+ function rpcCtx() {
669
+ return {
670
+ signal: null,
671
+ error: (type, message, data) => {
672
+ const e = new Error(message);
673
+ e.type = type;
674
+ e.data = data;
675
+ throw e;
676
+ },
677
+ };
678
+ }
679
+ """
680
+
681
+
682
+ class TestSettingsRpcDomain(unittest.TestCase):
683
+ """C1: the `iumbtems.settings` domain registers; get/set/validate run."""
684
+
685
+ def test_definition_shape_matches_v2_rpc_pattern(self):
686
+ res = run_node(
687
+ PHASE3_HOST
688
+ + """
689
+ const methods = Object.keys(SettingsRpc.methods).sort();
690
+ const errorNames = Object.keys(SettingsRpc.methods.set.errors || {});
691
+ console.log(JSON.stringify({
692
+ id: SettingsRpc.id,
693
+ rpcId: SETTINGS_RPC_ID,
694
+ methods,
695
+ getIn: SettingsRpc.methods.get.input,
696
+ getOut: SettingsRpc.methods.get.output,
697
+ setOutRequired: SettingsRpc.methods.set.output.required,
698
+ events: Object.keys(SettingsRpc.events),
699
+ changedRequired: SettingsRpc.events.changed.schema.required,
700
+ changedType: SettingsRpc.events.changed.schema.type,
701
+ errorNames,
702
+ }));
703
+ """
704
+ )
705
+ self.assertEqual(res.returncode, 0, res.stderr)
706
+ d = last_json_object(res.stdout)
707
+ self.assertEqual(d["id"], "iumbtems.settings")
708
+ self.assertEqual(d["rpcId"], "iumbtems.settings")
709
+ self.assertEqual(d["methods"], ["get", "set", "validate"])
710
+ # JSON-Schema I/O per the v2 RPC pattern; event data is an object.
711
+ self.assertEqual(d["getIn"]["type"], "object")
712
+ # R1: `get` carries the on-disk `raw` alongside the effective `config`.
713
+ self.assertEqual(d["getOut"]["type"], "object")
714
+ self.assertIn("raw", d["getOut"]["properties"])
715
+ self.assertIn("config", d["getOut"]["required"])
716
+ self.assertIn("config", d["setOutRequired"])
717
+ self.assertIn("hash", d["setOutRequired"])
718
+ self.assertEqual(d["events"], ["changed"])
719
+ self.assertIn("hash", d["changedRequired"])
720
+ self.assertEqual(d["changedType"], "object")
721
+ # `rpc.*` error names are reserved by OpenCode and must not be used.
722
+ self.assertFalse([n for n in d["errorNames"] if n.startswith("rpc.")])
723
+ self.assertIn("stale", d["errorNames"])
724
+
725
+ def test_setup_registers_rpc_domain(self):
726
+ res = run_node(
727
+ PHASE3_HOST
728
+ + """
729
+ const box = makePhase3Host("/tmp/oc-rpc-setup");
730
+ await plugin.setup(box.host);
731
+ const def = box.getDef();
732
+ console.log(JSON.stringify({
733
+ defId: def && def.id,
734
+ handlerNames: Object.keys(box.handlers).sort(),
735
+ }));
736
+ """
737
+ )
738
+ self.assertEqual(res.returncode, 0, res.stderr)
739
+ d = last_json_object(res.stdout)
740
+ self.assertEqual(d["defId"], "iumbtems.settings")
741
+ self.assertEqual(d["handlerNames"], ["get", "set", "validate"])
742
+
743
+ def test_setup_without_rpc_domain_still_works(self):
744
+ res = run_node(
745
+ PHASE3_HOST
746
+ + """
747
+ const box = makePhase3Host("/tmp/oc-rpc-bare");
748
+ delete box.host.rpc;
749
+ delete box.host.storage;
750
+ delete box.host.mcp;
751
+ delete box.host.session.hook;
752
+ const cleanup = await plugin.setup(box.host);
753
+ console.log(JSON.stringify({ cleanupFn: typeof cleanup }));
754
+ """
755
+ )
756
+ self.assertEqual(res.returncode, 0, res.stderr)
757
+ self.assertEqual(last_json_object(res.stdout)["cleanupFn"], "function")
758
+
759
+ def test_get_set_validate_round_trip(self):
760
+ res = run_node(
761
+ PHASE3_HOST
762
+ + """
763
+ const box = makePhase3Host("/tmp/oc-rpc-roundtrip");
764
+ const state = { config: { mode: "research", max_iterations: 2 }, hash: "abc123def4567890", writes: 0 };
765
+ const callMcp = makeStubMcp(state);
766
+ const emitted = [];
767
+ const handlers = createSettingsHandlers({
768
+ root: "/tmp/oc-rpc-roundtrip",
769
+ callMcpImpl: callMcp,
770
+ storage: box.host.storage,
771
+ emitChanged: async (data) => { emitted.push(data); },
772
+ reloadMcp: async () => { await box.host.mcp.reload(); },
773
+ });
774
+ const ctx = rpcCtx();
775
+ const got = await handlers.get({}, ctx);
776
+ const validated = await handlers.validate({ updates: { max_iterations: 9 } });
777
+ const validatedOk = await handlers.validate({ updates: { mode: "audit" } });
778
+ const written = await handlers.set({ updates: { mode: "audit" } }, ctx);
779
+ const mirror = await box.host.storage.get(SETTINGS_STORAGE_KEY);
780
+ console.log(JSON.stringify({
781
+ gotMode: got.config.mode,
782
+ gotHash: got.hash,
783
+ invalid: validated.valid,
784
+ invalidProblems: validated.problems.length,
785
+ validOk: validatedOk.valid,
786
+ writtenMode: written.config.mode,
787
+ writtenHash: written.hash,
788
+ emits: emitted,
789
+ reloads: box.host._reloads || 0,
790
+ mirrorMode: mirror && mirror.config && mirror.config.mode,
791
+ mirrorHash: mirror && mirror.hash,
792
+ mirrorHasUpdated: Boolean(mirror && mirror.updated),
793
+ }));
794
+ """
795
+ )
796
+ self.assertEqual(res.returncode, 0, res.stderr)
797
+ d = last_json_object(res.stdout)
798
+ self.assertEqual(d["gotMode"], "research")
799
+ # No config file on disk in the stubbed workspace: hash is null.
800
+ self.assertIsNone(d["gotHash"])
801
+ self.assertFalse(d["invalid"])
802
+ self.assertGreater(d["invalidProblems"], 0)
803
+ self.assertTrue(d["validOk"])
804
+ self.assertEqual(d["writtenMode"], "audit")
805
+ # `changed` emitted exactly once per successful write.
806
+ self.assertEqual(len(d["emits"]), 1)
807
+ self.assertEqual(d["emits"][0]["hash"], d["writtenHash"])
808
+ self.assertIn("mode", d["emits"][0]["keys"])
809
+ self.assertEqual(d["reloads"], 1)
810
+ # Storage mirror holds the last effective config + hash.
811
+ self.assertEqual(d["mirrorMode"], "audit")
812
+ self.assertEqual(d["mirrorHash"], d["writtenHash"])
813
+ self.assertTrue(d["mirrorHasUpdated"])
814
+
815
+ def test_rpc_writes_through_canonical_python_surface(self):
816
+ # No stub: the default callMcp spawns runner/mcp_server.py for real,
817
+ # proving the single-writer discipline holds end to end.
818
+ import tempfile
819
+
820
+ with tempfile.TemporaryDirectory() as tmp:
821
+ res = run_node(
822
+ PHASE3_HOST
823
+ + f"""
824
+ const handlers = createSettingsHandlers({{ root: {tmp!r} }});
825
+ const ctx = rpcCtx();
826
+ const before = await handlers.get(ctx);
827
+ const written = await handlers.set({{ updates: {{ mode: "audit" }} }}, ctx);
828
+ const after = await handlers.get(ctx);
829
+ console.log(JSON.stringify({{
830
+ beforeMode: before.config.mode,
831
+ writtenMode: written.config.mode,
832
+ hashLen: String(written.hash || "").length,
833
+ afterMode: after.config.mode,
834
+ }}));
835
+ """
836
+ )
837
+ self.assertEqual(res.returncode, 0, res.stderr)
838
+ d = last_json_object(res.stdout)
839
+ self.assertEqual(d["beforeMode"], "research")
840
+ self.assertEqual(d["writtenMode"], "audit")
841
+ self.assertEqual(d["hashLen"], 64)
842
+ self.assertEqual(d["afterMode"], "audit")
843
+
844
+
845
+ class TestSettingsRpcGuards(unittest.TestCase):
846
+ """C3: stale/conflicting/malformed guards reject with no write."""
847
+
848
+ def test_stale_expected_hash_returns_structured_error(self):
849
+ res = run_node(
850
+ PHASE3_HOST
851
+ + """
852
+ const box = makePhase3Host("/tmp/oc-rpc-stale");
853
+ const state = { config: { mode: "audit" }, hash: "abcdef1234567890", writes: 0 };
854
+ const callMcp = makeStubMcp(state);
855
+ const emitted = [];
856
+ const handlers = createSettingsHandlers({
857
+ root: "/tmp/oc-rpc-stale",
858
+ callMcpImpl: callMcp,
859
+ storage: box.host.storage,
860
+ emitChanged: async (data) => { emitted.push(data); },
861
+ });
862
+ const ctx = rpcCtx();
863
+ let failure = null;
864
+ try {
865
+ await handlers.set({ updates: { mode: "research" }, expected_hash: "deadbeef00" }, ctx);
866
+ } catch (e) { failure = { type: e.type, data: e.data }; }
867
+ const mirror = await box.host.storage.get(SETTINGS_STORAGE_KEY);
868
+ console.log(JSON.stringify({
869
+ failureType: failure && failure.type,
870
+ currentHash: failure && failure.data && failure.data.current_hash,
871
+ freshMode: failure && failure.data && failure.data.config && failure.data.config.mode,
872
+ writtenFlag: failure && failure.data && failure.data.written,
873
+ writes: state.writes,
874
+ emits: emitted.length,
875
+ mirror: mirror === undefined ? null : mirror,
876
+ }));
877
+ """
878
+ )
879
+ self.assertEqual(res.returncode, 0, res.stderr)
880
+ d = last_json_object(res.stdout)
881
+ self.assertEqual(d["failureType"], "stale")
882
+ self.assertEqual(d["currentHash"], "abcdef1234567890")
883
+ self.assertEqual(d["freshMode"], "audit")
884
+ self.assertFalse(d["writtenFlag"])
885
+ self.assertEqual(d["writes"], 0)
886
+ self.assertEqual(d["emits"], 0)
887
+ self.assertIsNone(d["mirror"])
888
+
889
+ def test_conflicting_spellings_rejected_without_call(self):
890
+ res = run_node(
891
+ PHASE3_HOST
892
+ + """
893
+ const box = makePhase3Host("/tmp/oc-rpc-conflict");
894
+ const state = { config: { mode: "audit" }, hash: "abcdef1234567890", writes: 0 };
895
+ const callMcp = makeStubMcp(state);
896
+ const handlers = createSettingsHandlers({
897
+ root: "/tmp/oc-rpc-conflict", callMcpImpl: callMcp,
898
+ });
899
+ const ctx = rpcCtx();
900
+ let failure = null;
901
+ try {
902
+ await handlers.set(
903
+ { updates: { mode: "research" }, expected_hash: "abcdef1234567890", expectedHash: "00000000" },
904
+ ctx
905
+ );
906
+ } catch (e) { failure = { type: e.type, message: e.message }; }
907
+ console.log(JSON.stringify({
908
+ failureType: failure && failure.type,
909
+ calls: callMcp.calls.length,
910
+ writes: state.writes,
911
+ }));
912
+ """
913
+ )
914
+ self.assertEqual(res.returncode, 0, res.stderr)
915
+ d = last_json_object(res.stdout)
916
+ self.assertEqual(d["failureType"], "conflict")
917
+ self.assertEqual(d["calls"], 0)
918
+ self.assertEqual(d["writes"], 0)
919
+
920
+ def test_malformed_guard_rejected_without_call(self):
921
+ res = run_node(
922
+ PHASE3_HOST
923
+ + """
924
+ const box = makePhase3Host("/tmp/oc-rpc-malformed");
925
+ const state = { config: {}, hash: "abcdef1234567890", writes: 0 };
926
+ const callMcp = makeStubMcp(state);
927
+ const handlers = createSettingsHandlers({
928
+ root: "/tmp/oc-rpc-malformed", callMcpImpl: callMcp,
929
+ });
930
+ const ctx = rpcCtx();
931
+ const types = [];
932
+ for (const guard of ["abcd", "xyz-not-hex", "12"]) {
933
+ try {
934
+ await handlers.set({ updates: { mode: "research" }, expectedHash: guard }, ctx);
935
+ types.push("accepted:" + guard);
936
+ } catch (e) { types.push(e.type); }
937
+ }
938
+ // Equal spellings carrying the live hash proceed.
939
+ const ok = await handlers.set(
940
+ { updates: { mode: "research" }, expected_hash: "abcdef1234567890", expectedHash: "abcdef1234567890" },
941
+ ctx
942
+ );
943
+ console.log(JSON.stringify({ types, okMode: ok.config.mode, calls: callMcp.calls.length }));
944
+ """
945
+ )
946
+ self.assertEqual(res.returncode, 0, res.stderr)
947
+ d = last_json_object(res.stdout)
948
+ self.assertEqual(d["types"], ["invalid", "invalid", "invalid"])
949
+ self.assertEqual(d["okMode"], "research")
950
+ # Only the accepted write reached the canonical surface.
951
+ self.assertEqual(d["calls"], 1)
952
+
953
+
954
+ class TestMcpToggleReconcile(unittest.TestCase):
955
+ """C5: `ctx.mcp.transform` applies `disabled`; the catalog is untouched."""
956
+
957
+ def test_controllable_set_matches_catalog(self):
958
+ res = run_node(
959
+ PHASE3_HOST
960
+ + """
961
+ import fs from "node:fs";
962
+ import path from "node:path";
963
+ const catalog = JSON.parse(
964
+ fs.readFileSync("config/mcp-research-servers.json", "utf-8")
965
+ );
966
+ console.log(JSON.stringify({
967
+ controllable: loadControllableMcpServers().sort(),
968
+ catalogKeys: Object.keys(catalog.mcpServers || {}).sort(),
969
+ }));
970
+ """
971
+ )
972
+ self.assertEqual(res.returncode, 0, res.stderr)
973
+ d = last_json_object(res.stdout)
974
+ self.assertEqual(d["controllable"], d["catalogKeys"])
975
+ self.assertIn("iumbtems", d["controllable"])
976
+
977
+ def test_resolve_toggles_triple_gate(self):
978
+ res = run_node(
979
+ PHASE3_HOST
980
+ + """
981
+ const toggles = resolveMcpToggles(
982
+ { searxng: false, iumbtems: true, "custom-evil": false, nope: false, bad: "x" },
983
+ ["iumbtems", "searxng", "custom-evil"],
984
+ ["iumbtems", "searxng", "brave-search", "firecrawl"]
985
+ );
986
+ console.log(JSON.stringify({ toggles }));
987
+ """
988
+ )
989
+ self.assertEqual(res.returncode, 0, res.stderr)
990
+ toggles = last_json_object(res.stdout)["toggles"]
991
+ # searxng disables; iumbtems:true reconciles to disabled:false;
992
+ # custom-evil is not controllable, nope is not in the registry,
993
+ # bad is not a boolean.
994
+ self.assertEqual(
995
+ toggles,
996
+ [
997
+ {"name": "searxng", "disabled": True},
998
+ {"name": "iumbtems", "disabled": False},
999
+ ],
1000
+ )
1001
+
1002
+ def test_transform_applies_disabled_from_persisted_map(self):
1003
+ import tempfile
1004
+
1005
+ with tempfile.TemporaryDirectory() as tmp:
1006
+ research = Path(tmp) / ".research"
1007
+ research.mkdir()
1008
+ research.joinpath("config.json").write_text(
1009
+ json.dumps({"mcp_servers": {"searxng": False}}),
1010
+ encoding="utf-8",
1011
+ )
1012
+ res = run_node(
1013
+ PHASE3_HOST
1014
+ + f"""
1015
+ const box = makePhase3Host({tmp!r});
1016
+ const reg = await registerMcpToggles(box.host, {{ root: {tmp!r} }});
1017
+ const servers = Object.fromEntries(box.mcpServers);
1018
+ console.log(JSON.stringify({{
1019
+ registered: Boolean(reg),
1020
+ searxngDisabled: servers.searxng && servers.searxng.disabled,
1021
+ iumbtemsKeys: Object.keys(servers.iumbtems || {{}}),
1022
+ evilKeys: Object.keys(servers["custom-evil"] || {{}}),
1023
+ }}));
1024
+ """
1025
+ )
1026
+ self.assertEqual(res.returncode, 0, res.stderr)
1027
+ d = last_json_object(res.stdout)
1028
+ self.assertTrue(d["registered"])
1029
+ self.assertTrue(d["searxngDisabled"])
1030
+ self.assertEqual(d["iumbtemsKeys"], [])
1031
+ self.assertEqual(d["evilKeys"], [])
1032
+
1033
+ def test_catalog_file_byte_identical(self):
1034
+ catalog = PROJECT_ROOT / "config" / "mcp-research-servers.json"
1035
+ before = catalog.read_bytes()
1036
+ res = run_node(
1037
+ PHASE3_HOST
1038
+ + """
1039
+ const box = makePhase3Host("/tmp/oc-mcp-catalog");
1040
+ await plugin.setup(box.host);
1041
+ console.log(JSON.stringify({ ok: true }));
1042
+ """
1043
+ )
1044
+ self.assertEqual(res.returncode, 0, res.stderr)
1045
+ self.assertEqual(catalog.read_bytes(), before)
1046
+
1047
+ def test_mcp_domain_absent_is_graceful(self):
1048
+ res = run_node(
1049
+ PHASE3_HOST
1050
+ + """
1051
+ const box = makePhase3Host("/tmp/oc-mcp-bare");
1052
+ delete box.host.mcp;
1053
+ const reg = await registerMcpToggles(box.host, { root: "/tmp/oc-mcp-bare" });
1054
+ console.log(JSON.stringify({ reg }));
1055
+ """
1056
+ )
1057
+ self.assertEqual(res.returncode, 0, res.stderr)
1058
+ self.assertIsNone(last_json_object(res.stdout)["reg"])
1059
+
1060
+
1061
+ class TestTemperatureHook(unittest.TestCase):
1062
+ """C6 (wired half): the context hook keys off `event.agent`."""
1063
+
1064
+ HOOK_FIRE = """
1065
+ const box = makePhase3Host("/tmp/oc-temp");
1066
+ const reg = await registerTemperatureHook(box.host, { env: {
1067
+ IUMBTEMS_TEMPERATURE_ALPHA: "0.2",
1068
+ IUMBTEMS_TEMPERATURE_BETA: "not-a-number",
1069
+ }});
1070
+ const hook = box.hookCalls.find((h) => h.name === "context");
1071
+ """
1072
+
1073
+ def test_hook_registered_and_keyed_off_event_agent(self):
1074
+ res = run_node(
1075
+ PHASE3_HOST
1076
+ + self.HOOK_FIRE
1077
+ + """
1078
+ // The role comes from `event.agent` (type-guaranteed SessionContext),
1079
+ // never from the IUMBTEMS_AGENT_ROLE env var: a hostile env naming
1080
+ // beta must not move an alpha request.
1081
+ process.env.IUMBTEMS_AGENT_ROLE = "beta";
1082
+ const alpha = { agent: "alpha", options: {} };
1083
+ const beta = { agent: "beta", options: {} };
1084
+ const noAgent = { options: {} };
1085
+ hook.fn(alpha);
1086
+ hook.fn(beta);
1087
+ hook.fn(noAgent);
1088
+ console.log(JSON.stringify({
1089
+ registered: Boolean(reg),
1090
+ hookName: hook && hook.name,
1091
+ alphaTemp: alpha.options.temperature,
1092
+ betaHasTemp: Object.prototype.hasOwnProperty.call(beta.options, "temperature"),
1093
+ noAgentHasTemp: Object.prototype.hasOwnProperty.call(noAgent.options, "temperature"),
1094
+ }));
1095
+ """
1096
+ )
1097
+ self.assertEqual(res.returncode, 0, res.stderr)
1098
+ d = last_json_object(res.stdout)
1099
+ self.assertTrue(d["registered"])
1100
+ self.assertEqual(d["hookName"], "context")
1101
+ self.assertEqual(d["alphaTemp"], 0.2)
1102
+ self.assertFalse(d["betaHasTemp"])
1103
+ self.assertFalse(d["noAgentHasTemp"])
1104
+
1105
+ def test_temperature_bounds(self):
1106
+ res = run_node(
1107
+ PHASE3_HOST
1108
+ + """
1109
+ const env = {
1110
+ IUMBTEMS_TEMPERATURE_ALPHA: "0.2",
1111
+ IUMBTEMS_TEMPERATURE_BETA: "high",
1112
+ IUMBTEMS_TEMPERATURE_GAMMA: "5",
1113
+ IUMBTEMS_TEMPERATURE_DELTA: "",
1114
+ };
1115
+ const generic = { IUMBTEMS_TEMPERATURE: "0.7" };
1116
+ console.log(JSON.stringify({
1117
+ alpha: temperatureForRole("alpha", env) ?? null,
1118
+ beta: temperatureForRole("beta", env) ?? null,
1119
+ gamma: temperatureForRole("gamma", env) ?? null,
1120
+ delta: temperatureForRole("delta", env) ?? null,
1121
+ missing: temperatureForRole("zeta", env) ?? null,
1122
+ empty: temperatureForRole("", env) ?? null,
1123
+ genericAlpha: temperatureForRole("alpha", generic) ?? null,
1124
+ applied: applyTemperatureToContext({ agent: "alpha", options: {} }, env),
1125
+ skipped: applyTemperatureToContext({ options: {} }, env),
1126
+ }));
1127
+ """
1128
+ )
1129
+ self.assertEqual(res.returncode, 0, res.stderr)
1130
+ d = last_json_object(res.stdout)
1131
+ self.assertEqual(d["alpha"], 0.2)
1132
+ self.assertIsNone(d["beta"])
1133
+ self.assertIsNone(d["gamma"])
1134
+ self.assertIsNone(d["delta"])
1135
+ self.assertIsNone(d["missing"])
1136
+ self.assertIsNone(d["empty"])
1137
+ self.assertEqual(d["genericAlpha"], 0.7)
1138
+ self.assertTrue(d["applied"])
1139
+ self.assertFalse(d["skipped"])
1140
+
1141
+ def test_hook_domain_absent_is_graceful(self):
1142
+ res = run_node(
1143
+ PHASE3_HOST
1144
+ + """
1145
+ const reg = await registerTemperatureHook({}, {});
1146
+ console.log(JSON.stringify({ reg }));
1147
+ """
1148
+ )
1149
+ self.assertEqual(res.returncode, 0, res.stderr)
1150
+ self.assertIsNone(last_json_object(res.stdout)["reg"])
1151
+
1152
+
1153
+ class TestS3SpikeHarness(unittest.TestCase):
1154
+ """C6 (spike): `IUMBTEMS_AGENT_ROLE` reaches the spawned child env.
1155
+
1156
+ Bounded by the phase brief: local stub harness only — no model spend, no
1157
+ network. The dated outcome is recorded in the phase receipt: the runner
1158
+ env leg and the in-host hook leg are verified below; whether a live
1159
+ `opencode run` child loads the project plugin and honors the hook is
1160
+ NEGATIVE_KNOWLEDGE (unobservable without spending a model call).
1161
+ """
1162
+
1163
+ def test_role_exported_to_child_env(self):
1164
+ import os
1165
+ from unittest import mock
1166
+
1167
+ from runner.research_swarm import _config_child_env
1168
+
1169
+ with mock.patch.dict(os.environ, {}, clear=True):
1170
+ env = _config_child_env(
1171
+ Path("/tmp/proj/.research"), "/tmp/proj", {}, role="alpha"
1172
+ )
1173
+ self.assertEqual(env["IUMBTEMS_AGENT_ROLE"], "alpha")
1174
+ beta = _config_child_env(
1175
+ Path("/tmp/proj/.research"), "/tmp/proj", {}, role="beta"
1176
+ )
1177
+ self.assertEqual(beta["IUMBTEMS_AGENT_ROLE"], "beta")
1178
+ # Absent/empty role exports nothing (byte-identical default path).
1179
+ for blank in (None, ""):
1180
+ plain = _config_child_env(
1181
+ Path("/tmp/proj/.research"), "/tmp/proj", {}, role=blank
1182
+ )
1183
+ self.assertNotIn("IUMBTEMS_AGENT_ROLE", plain)
1184
+ # The pre-existing channels are unchanged by the new parameter.
1185
+ self.assertEqual(env["PWD"], "/tmp/proj")
1186
+ self.assertEqual(env["IUMBTEMS_RESEARCH_DIR"], "/tmp/proj/.research")
1187
+
1188
+ def test_spawn_wiring_carries_role(self):
1189
+ import json as _json
1190
+ import os
1191
+ import tempfile
1192
+ from unittest import mock
1193
+
1194
+ from runner.research_swarm import SwarmRunner
1195
+
1196
+ with tempfile.TemporaryDirectory() as tmp:
1197
+ base = Path(tmp) / ".research"
1198
+ base.mkdir(parents=True, exist_ok=True)
1199
+ (base / "config.json").write_text(
1200
+ _json.dumps({"searxng_url": "http://searx.local:8888"}),
1201
+ encoding="utf-8",
1202
+ )
1203
+ runner = SwarmRunner(base_dir=base, mock_mode=False, mode="research")
1204
+ fake = mock.Mock()
1205
+ fake.stdout = "ok"
1206
+ with mock.patch.dict(os.environ, {}, clear=True):
1207
+ with mock.patch("subprocess.run", return_value=fake) as run_mock:
1208
+ runner.run_claude_process("objective text", role="beta")
1209
+ kwargs = run_mock.call_args.kwargs
1210
+ self.assertEqual(kwargs["env"]["IUMBTEMS_AGENT_ROLE"], "beta")
1211
+ # Default callers (no role) keep the historical shape.
1212
+ with mock.patch.dict(os.environ, {}, clear=True):
1213
+ with mock.patch("subprocess.run", return_value=fake) as run_mock:
1214
+ runner.run_claude_process("objective text")
1215
+ kwargs = run_mock.call_args.kwargs
1216
+ self.assertEqual(kwargs["env"]["IUMBTEMS_AGENT_ROLE"], "alpha")
1217
+
1218
+ def test_stub_child_observes_role_in_its_env(self):
1219
+ # OS-level leg: a stub child spawned with the produced env observes
1220
+ # IUMBTEMS_AGENT_ROLE. No model, no network — pure env plumbing.
1221
+ import os
1222
+ import subprocess
1223
+ import sys
1224
+ import tempfile
1225
+ from unittest import mock
1226
+
1227
+ from runner.research_swarm import _config_child_env
1228
+
1229
+ with tempfile.TemporaryDirectory() as tmp:
1230
+ probe = Path(tmp) / "role_probe.py"
1231
+ probe.write_text(
1232
+ "import os, sys; sys.stdout.write(os.environ.get("
1233
+ "'IUMBTEMS_AGENT_ROLE', '<absent>'))",
1234
+ encoding="utf-8",
1235
+ )
1236
+ with mock.patch.dict(os.environ, {}, clear=True):
1237
+ env = _config_child_env(Path(tmp) / ".research", tmp, {}, role="beta")
1238
+ out = subprocess.run(
1239
+ [sys.executable, str(probe)],
1240
+ capture_output=True,
1241
+ text=True,
1242
+ env=env,
1243
+ )
1244
+ self.assertEqual(out.returncode, 0, out.stderr)
1245
+ self.assertEqual(out.stdout, "beta")
1246
+
1247
+
1248
+ class TestFallbackMirrorConvergence(unittest.TestCase):
1249
+ """Phase-03 R1: fallback-write -> RPC-get-fresh + mirror-converged.
1250
+
1251
+ The degraded path honestly stays degraded, but the mirror must not lie
1252
+ permanently: every server-mediated read refreshes it to disk truth
1253
+ (refresh-on-read; no new protocol). The stub below is file-backed so the
1254
+ single-read snapshot (phase-03 R5) exercises real disk bytes end to end.
1255
+ """
1256
+
1257
+ FILE_BACKED = """
1258
+ const __fs = await import("node:fs");
1259
+ const __crypto = await import("node:crypto");
1260
+ const __path = await import("node:path");
1261
+ const fs = __fs.default || __fs;
1262
+ const fspath = __path.default || __path;
1263
+ const shaFile = (f) => __crypto.createHash("sha256").update(fs.readFileSync(f)).digest("hex");
1264
+ // File-backed stub of the canonical surface: show reads the real file,
1265
+ // set guards + writes the real file. The handler's single-read snapshot
1266
+ // therefore sees the same bytes a production fallback write leaves.
1267
+ function makeFileBackedMcp(cfgFile) {
1268
+ const calls = [];
1269
+ const impl = async (tool, args = {}) => {
1270
+ calls.push(args);
1271
+ const read = () => JSON.parse(fs.readFileSync(cfgFile, "utf-8"));
1272
+ const keys = Object.keys(args || {});
1273
+ const readOnly = keys.every((k) =>
1274
+ ["base_dir", "show", "expected_hash", "expectedHash"].includes(k));
1275
+ if (readOnly) {
1276
+ return {
1277
+ content: JSON.stringify({ status: "current", config: read(), migrated: false }),
1278
+ status: "success",
1279
+ };
1280
+ }
1281
+ const exp = args.expected_hash !== undefined ? args.expected_hash : args.expectedHash;
1282
+ const cur = shaFile(cfgFile);
1283
+ if (exp !== undefined && exp !== null && !cur.startsWith(String(exp))) {
1284
+ return {
1285
+ content: JSON.stringify({
1286
+ status: "stale", written: false,
1287
+ expected_hash: String(exp), current_hash: cur, config: read(),
1288
+ }),
1289
+ status: "success",
1290
+ };
1291
+ }
1292
+ const updates = { ...(args || {}) };
1293
+ for (const k of ["base_dir", "show", "expected_hash", "expectedHash"]) delete updates[k];
1294
+ const next = { ...read(), ...updates };
1295
+ fs.writeFileSync(cfgFile, JSON.stringify(next, null, 2));
1296
+ return {
1297
+ content: JSON.stringify({ status: "updated", config: next, hash: shaFile(cfgFile) }),
1298
+ status: "success",
1299
+ };
1300
+ };
1301
+ impl.calls = calls;
1302
+ return impl;
1303
+ }
1304
+ """
1305
+
1306
+ def test_fallback_write_then_get_refreshes_mirror(self):
1307
+ import tempfile
1308
+
1309
+ with tempfile.TemporaryDirectory() as tmp:
1310
+ research = Path(tmp) / ".research"
1311
+ research.mkdir()
1312
+ cfg = research / "config.json"
1313
+ cfg.write_text(json.dumps({"mode": "research"}), encoding="utf-8")
1314
+ res = run_node(
1315
+ PHASE3_HOST
1316
+ + self.FILE_BACKED
1317
+ + f"""
1318
+ const cfgFile = {str(cfg)!r};
1319
+ const box = makePhase3Host({tmp!r});
1320
+ const callMcp = makeFileBackedMcp(cfgFile);
1321
+ const emitted = [];
1322
+ const handlers = createSettingsHandlers({{
1323
+ root: {tmp!r},
1324
+ callMcpImpl: callMcp,
1325
+ storage: box.host.storage,
1326
+ emitChanged: async (data) => {{ emitted.push(data); }},
1327
+ }});
1328
+ const ctx = rpcCtx();
1329
+ // 1. RPC-leg write: mirror holds the written snapshot.
1330
+ await handlers.set({{ updates: {{ mode: "audit" }} }}, ctx);
1331
+ const afterSet = await box.host.storage.get(SETTINGS_STORAGE_KEY);
1332
+ // 2. Fallback-leg write: direct-fs, bypassing RPC and the mirror
1333
+ // (exactly what the degraded TUI path does via config-io.js).
1334
+ fs.writeFileSync(cfgFile, JSON.stringify({{ mode: "scout" }}, null, 2));
1335
+ const staleMirror = await box.host.storage.get(SETTINGS_STORAGE_KEY);
1336
+ // 3. Next server-mediated read: disk truth + converged mirror.
1337
+ const got = await handlers.get({{}}, ctx);
1338
+ const converged = await box.host.storage.get(SETTINGS_STORAGE_KEY);
1339
+ console.log(JSON.stringify({{
1340
+ mirrorAfterSet: afterSet && afterSet.config.mode,
1341
+ mirrorWasStale: staleMirror && staleMirror.config.mode,
1342
+ gotMode: got.config.mode,
1343
+ gotHash: got.hash,
1344
+ diskHash: shaFile(cfgFile),
1345
+ mirrorMode: converged && converged.config.mode,
1346
+ mirrorHash: converged && converged.hash,
1347
+ extraEmits: emitted.length,
1348
+ }}));
1349
+ """
1350
+ )
1351
+ self.assertEqual(res.returncode, 0, res.stderr)
1352
+ d = last_json_object(res.stdout)
1353
+ self.assertEqual(d["mirrorAfterSet"], "audit")
1354
+ # The test is not vacuous: the mirror genuinely lagged disk.
1355
+ self.assertEqual(d["mirrorWasStale"], "audit")
1356
+ # The read returns disk truth and the mirror converges to it.
1357
+ self.assertEqual(d["gotMode"], "scout")
1358
+ self.assertEqual(d["gotHash"], d["diskHash"])
1359
+ self.assertEqual(d["mirrorMode"], "scout")
1360
+ self.assertEqual(d["mirrorHash"], d["diskHash"])
1361
+ # Convergence emits nothing: `changed` fires on writes, not reads.
1362
+ self.assertEqual(d["extraEmits"], 1)
1363
+
1364
+
1365
+ class TestSingleReadSnapshot(unittest.TestCase):
1366
+ """Phase-03 R5: no era mixing — config, hash, and mirror are one snapshot.
1367
+
1368
+ Torn-race repro (qa-b section E shape): the Python payload is built from
1369
+ era-A bytes while the disk is mutated to era-B mid-call. The handler must
1370
+ return a coherent pair from a single disk read — an era-A config with an
1371
+ era-B hash must be impossible.
1372
+ """
1373
+
1374
+ def test_torn_race_cannot_mix_eras(self):
1375
+ import tempfile
1376
+
1377
+ with tempfile.TemporaryDirectory() as tmp:
1378
+ research = Path(tmp) / ".research"
1379
+ research.mkdir()
1380
+ cfg = research / "config.json"
1381
+ era_a = {"mode": "scout"}
1382
+ era_b = {"mode": "audit"}
1383
+ cfg.write_text(json.dumps(era_a), encoding="utf-8")
1384
+ res = run_node(
1385
+ PHASE3_HOST
1386
+ + f"""
1387
+ import {{ createHash }} from "node:crypto";
1388
+ import fs from "node:fs";
1389
+ const cfgFile = {str(cfg)!r};
1390
+ const shaBytes = (b) => createHash("sha256").update(b).digest("hex");
1391
+ const bytesA = fs.readFileSync(cfgFile);
1392
+ const box = makePhase3Host({tmp!r});
1393
+ // Payload built from era-A bytes; disk mutated to era-B while
1394
+ // the "Python surface" is in flight.
1395
+ const callMcp = async () => {{
1396
+ await new Promise((r) => setTimeout(r, 15));
1397
+ fs.writeFileSync(cfgFile, JSON.stringify({json.dumps(era_b)}, null, 2));
1398
+ return {{
1399
+ content: JSON.stringify({{
1400
+ status: "current",
1401
+ config: JSON.parse(String(bytesA)),
1402
+ migrated: false,
1403
+ }}),
1404
+ status: "success",
1405
+ }};
1406
+ }};
1407
+ const handlers = createSettingsHandlers({{
1408
+ root: {tmp!r},
1409
+ callMcpImpl: callMcp,
1410
+ storage: box.host.storage,
1411
+ }});
1412
+ const got = await handlers.get({{}}, rpcCtx());
1413
+ const mirror = await box.host.storage.get(SETTINGS_STORAGE_KEY);
1414
+ const bytesB = fs.readFileSync(cfgFile);
1415
+ console.log(JSON.stringify({{
1416
+ gotMode: got.config.mode,
1417
+ gotHash: got.hash,
1418
+ shaA: shaBytes(bytesA),
1419
+ shaB: shaBytes(bytesB),
1420
+ mirrorMode: mirror && mirror.config.mode,
1421
+ mirrorHash: mirror && mirror.hash,
1422
+ }}));
1423
+ """
1424
+ )
1425
+ self.assertEqual(res.returncode, 0, res.stderr)
1426
+ d = last_json_object(res.stdout)
1427
+ # Coherence: the pair matches exactly one era — never scout+shaB.
1428
+ coherent = (d["gotMode"], d["gotHash"]) in (
1429
+ ("scout", d["shaA"]),
1430
+ ("audit", d["shaB"]),
1431
+ )
1432
+ self.assertTrue(
1433
+ coherent,
1434
+ f"era mixing: mode={d['gotMode']} hash matches neither era",
1435
+ )
1436
+ self.assertFalse(
1437
+ d["gotMode"] == "scout" and d["gotHash"] == d["shaB"],
1438
+ "era-A config with era-B hash",
1439
+ )
1440
+ # The mirror converges to the same coherent pair.
1441
+ self.assertEqual(d["mirrorMode"], d["gotMode"])
1442
+ self.assertEqual(d["mirrorHash"], d["gotHash"])
1443
+
1444
+ def test_get_payload_carries_raw_and_effective_from_one_read(self):
1445
+ """R1: `get` carries the on-disk `raw` alongside the merged effective
1446
+ `config`, and the hash covers the same bytes (single-read preserved)."""
1447
+ import hashlib
1448
+ import tempfile
1449
+
1450
+ with tempfile.TemporaryDirectory() as tmp:
1451
+ research = Path(tmp) / ".research"
1452
+ research.mkdir()
1453
+ cfg = research / "config.json"
1454
+ on_disk = {"mode": "scout"}
1455
+ cfg.write_text(json.dumps(on_disk), encoding="utf-8")
1456
+ res = run_node(
1457
+ PHASE3_HOST
1458
+ + f"""
1459
+ const box = makePhase3Host({tmp!r});
1460
+ const stub = async () => ({{
1461
+ content: JSON.stringify({{ status: "current", config: {{}}, migrated: false }}),
1462
+ status: "success",
1463
+ }});
1464
+ const handlers = createSettingsHandlers({{
1465
+ root: {tmp!r},
1466
+ callMcpImpl: stub,
1467
+ storage: box.host.storage,
1468
+ }});
1469
+ const got = await handlers.get({{}}, rpcCtx());
1470
+ console.log(JSON.stringify({{
1471
+ raw: got.raw,
1472
+ effectiveMode: got.config.mode,
1473
+ effectiveHasDefaults: ("search_engine" in got.config) && ("max_iterations" in got.config),
1474
+ rawHasSearchEngine: Boolean(got.raw && ("search_engine" in got.raw)),
1475
+ hash: got.hash,
1476
+ }}));
1477
+ """
1478
+ )
1479
+ self.assertEqual(res.returncode, 0, res.stderr)
1480
+ d = last_json_object(res.stdout)
1481
+ # `raw` is exactly the on-disk file config (not the merged result).
1482
+ self.assertEqual(d["raw"], on_disk)
1483
+ self.assertFalse(d["rawHasSearchEngine"])
1484
+ # `config` is the merged effective config.
1485
+ self.assertEqual(d["effectiveMode"], "scout")
1486
+ self.assertTrue(d["effectiveHasDefaults"])
1487
+ # Single-read: the hash is of the same bytes `raw` was parsed from.
1488
+ self.assertEqual(d["hash"], hashlib.sha256(cfg.read_bytes()).hexdigest())
1489
+
1490
+
1491
+ class TestRpcGetSanitizedError(unittest.TestCase):
1492
+ """Phase-03 R4b: `get` spawn failures are structured, fail-closed errors."""
1493
+
1494
+ def test_spawn_enoent_never_leaks_internals(self):
1495
+ res = run_node(
1496
+ PHASE3_HOST
1497
+ + """
1498
+ const box = makePhase3Host("/tmp/oc-r4b-enoent");
1499
+ const throwing = async () => {
1500
+ const err = new Error("spawn python3 ENOENT");
1501
+ err.code = "ENOENT";
1502
+ throw err;
1503
+ };
1504
+ const garbage = async () => ({ content: "garbage{{{not json", status: "error" });
1505
+ const results = [];
1506
+ for (const impl of [throwing, garbage]) {
1507
+ const handlers = createSettingsHandlers({
1508
+ root: "/tmp/oc-r4b-enoent",
1509
+ callMcpImpl: impl,
1510
+ storage: box.host.storage,
1511
+ });
1512
+ try {
1513
+ await handlers.get({}, rpcCtx());
1514
+ results.push({ threw: false });
1515
+ } catch (e) {
1516
+ results.push({
1517
+ threw: true,
1518
+ code: e.code || null,
1519
+ leaksEnoent: String(e.message).includes("ENOENT"),
1520
+ leaksRaw: String(e.message).includes("garbage"),
1521
+ });
1522
+ }
1523
+ }
1524
+ const mirror = await box.host.storage.get(SETTINGS_STORAGE_KEY);
1525
+ console.log(JSON.stringify({
1526
+ results,
1527
+ mirror: mirror === undefined ? null : mirror,
1528
+ }));
1529
+ """
1530
+ )
1531
+ self.assertEqual(res.returncode, 0, res.stderr)
1532
+ d = last_json_object(res.stdout)
1533
+ for r in d["results"]:
1534
+ self.assertTrue(r["threw"])
1535
+ self.assertEqual(r["code"], "SETTINGS_RPC_UNAVAILABLE")
1536
+ self.assertFalse(r["leaksEnoent"])
1537
+ self.assertFalse(r["leaksRaw"])
1538
+ # Fail-closed: nothing was mirrored on a failed read.
1539
+ self.assertIsNone(d["mirror"])
1540
+
1541
+
1542
+ class TestSetLegSanitizedErrors(unittest.TestCase):
1543
+ """Phase-03 R7/R9: `set` failure payloads carry no tracebacks/paths.
1544
+
1545
+ R9 de-circularization: the fixtures and the expected sanitized strings are
1546
+ INDEPENDENT of the sanitizer's implementation — there is no shared regex
1547
+ here. The fixtures are real interpreter stderr (any POSIX root),
1548
+ Windows-drive and UNC paths, a `File "…", line N` frame, and a clean schema
1549
+ message; the mixed list proves PER-ITEM sanitization (the pre-R9
1550
+ all-or-nothing `list.every` would collapse the whole list to one reason).
1551
+ """
1552
+
1553
+ FIXED = (
1554
+ "Config write rejected: canonical config surface returned an "
1555
+ "unreadable failure."
1556
+ )
1557
+ CLEAN = "mode: value 'nope' not in enum ['research', 'audit']"
1558
+
1559
+ def test_set_failure_payload_sweep(self):
1560
+ res = run_node(
1561
+ PHASE3_HOST
1562
+ + r"""
1563
+ const box = makePhase3Host("/tmp/oc-r9-sweep");
1564
+ const fixtures = {
1565
+ posixStderr: "python3: can't open file '/workspace/nope/runner/mcp_server.py': [Errno 2] No such file or directory",
1566
+ windowsDrive: "C:\\Users\\x\\AppData\\Local\\Temp\\iumbtems\\config.json",
1567
+ uncShare: "\\\\fileserver\\share\\iumbtems\\config.json",
1568
+ fileLine: ' File "/srv/app/runner/mcp_server.py", line 42, in _handle_config',
1569
+ cleanEnum: "mode: value 'nope' not in enum ['research', 'audit']",
1570
+ };
1571
+ const call = async (impl) => {
1572
+ const handlers = createSettingsHandlers({
1573
+ root: "/tmp/oc-r9-sweep",
1574
+ callMcpImpl: impl,
1575
+ storage: box.host.storage,
1576
+ });
1577
+ try {
1578
+ await handlers.set({ updates: { mode: "scout" } }, rpcCtx());
1579
+ return { threw: false };
1580
+ } catch (e) {
1581
+ const data = e.data || e.rpcData || {};
1582
+ return { threw: true, type: e.type || e.rpcType || null, written: data.written, errors: data.errors || null };
1583
+ }
1584
+ };
1585
+ const listImpl = (items) => async () => ({
1586
+ content: JSON.stringify({ status: "error", code: "CONFIG_VALIDATION_FAILED", written: false, errors: items }),
1587
+ status: "success",
1588
+ });
1589
+ const rawImpl = (text) => async () => ({ content: text, status: "error" });
1590
+ const out = {};
1591
+ out.posixStderr = await call(listImpl([fixtures.posixStderr]));
1592
+ out.windowsDrive = await call(listImpl([fixtures.windowsDrive]));
1593
+ out.uncShare = await call(listImpl([fixtures.uncShare]));
1594
+ out.fileLine = await call(listImpl([fixtures.fileLine]));
1595
+ out.cleanEnum = await call(listImpl([fixtures.cleanEnum]));
1596
+ out.mixed = await call(listImpl([fixtures.cleanEnum, fixtures.posixStderr]));
1597
+ out.rawStderr = await call(rawImpl(fixtures.posixStderr));
1598
+ console.log(JSON.stringify(out));
1599
+ """
1600
+ )
1601
+ self.assertEqual(res.returncode, 0, res.stderr)
1602
+ d = last_json_object(res.stdout)
1603
+ # Every leaking fixture (JSON error list or raw non-JSON stderr) is
1604
+ # replaced by the fixed reason, and nothing is written.
1605
+ for name in (
1606
+ "posixStderr",
1607
+ "windowsDrive",
1608
+ "uncShare",
1609
+ "fileLine",
1610
+ "rawStderr",
1611
+ ):
1612
+ self.assertTrue(d[name]["threw"], name)
1613
+ self.assertEqual(d[name]["type"], "invalid", name)
1614
+ self.assertFalse(d[name]["written"], name)
1615
+ self.assertEqual(d[name]["errors"], [self.FIXED], name)
1616
+ # A clean schema message passes through untouched (no over-sanitization).
1617
+ self.assertTrue(d["cleanEnum"]["threw"])
1618
+ self.assertEqual(d["cleanEnum"]["errors"], [self.CLEAN])
1619
+ # PER-ITEM: the clean entry survives while only the leaking entry is
1620
+ # replaced (the pre-R9 list.every would have returned just [FIXED]).
1621
+ self.assertEqual(d["mixed"]["errors"], [self.CLEAN, self.FIXED])
1622
+
1623
+
515
1624
  if __name__ == "__main__":
516
1625
  unittest.main()