@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +23 -2
- package/package.json +1 -1
- package/plugins/antigravity/plugin.json +1 -1
- package/plugins/antigravity/rules/AGENTS.md +5 -3
- package/plugins/opencode/config-io.js +55 -12
- package/plugins/opencode/index.js +611 -0
- package/plugins/opencode/tui.js +278 -6
- package/runner/__pycache__/__init__.cpython-311.pyc +0 -0
- package/runner/__pycache__/auctioneer.cpython-311.pyc +0 -0
- package/runner/__pycache__/auditor_engine.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_store.cpython-311.pyc +0 -0
- package/runner/__pycache__/claim_witness.cpython-311.pyc +0 -0
- package/runner/__pycache__/darkharvest_claims.cpython-311.pyc +0 -0
- package/runner/__pycache__/errors.cpython-311.pyc +0 -0
- package/runner/__pycache__/living_dossiers.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_protocol.cpython-311.pyc +0 -0
- package/runner/__pycache__/mcp_server.cpython-311.pyc +0 -0
- package/runner/__pycache__/path_safety.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb.cpython-311.pyc +0 -0
- package/runner/__pycache__/pcrb_verify.cpython-311.pyc +0 -0
- package/runner/__pycache__/preflight.cpython-311.pyc +0 -0
- package/runner/__pycache__/refinement.cpython-311.pyc +0 -0
- package/runner/__pycache__/research_swarm.cpython-311.pyc +0 -0
- package/runner/__pycache__/retrieval_log.cpython-311.pyc +0 -0
- package/runner/__pycache__/schema_validate.cpython-311.pyc +0 -0
- package/runner/__pycache__/schemas.cpython-311.pyc +0 -0
- package/runner/__pycache__/state_machine.cpython-311.pyc +0 -0
- package/runner/mcp_server.py +81 -10
- package/runner/research_swarm.py +12 -2
- package/runner/schemas.py +18 -0
- package/runner/tests/__pycache__/test_antigravity_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_auction_order.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_backends.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_bet1_spike.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_store.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claim_witness.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_claude_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_config_schema.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_contract_tooling.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_darkharvest_audit.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_domain_packs.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_dossier_contract.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_factory.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_fleet_seam.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_frontier_mirror_standalone.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_grounding.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_living_dossiers.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_manifest_concurrency.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_mcp_server.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_ux.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_opencode_v2_registrars.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_pcrb.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_refinement.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_anomaly.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_search_provider_plugin.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_swarm_config.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_sweep_regressions.cpython-311.pyc +0 -0
- package/runner/tests/__pycache__/test_webcache.cpython-311.pyc +0 -0
- package/runner/tests/test_config_schema.py +106 -0
- package/runner/tests/test_opencode_ux.py +462 -7
- package/runner/tests/test_opencode_v2_registrars.py +1112 -3
- package/runner/tests/test_swarm_config.py +21 -0
- package/schemas/config.schema.json +11 -0
- package/scripts/__pycache__/bet1_advisory_spike.cpython-311.pyc +0 -0
- package/scripts/__pycache__/build_adapters.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check_docs.cpython-311.pyc +0 -0
- package/scripts/__pycache__/divergence_experiment.cpython-311.pyc +0 -0
- package/scripts/__pycache__/gen_schemas.cpython-311.pyc +0 -0
- package/skills/epistemic_search/scripts/__pycache__/search.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/grilling/__pycache__/socratic_tree.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/research_cache/__pycache__/hasher.cpython-311.pyc +0 -0
- package/skills/swarm_config/SKILL.md +26 -0
- package/skills/swarm_config/__pycache__/__init__.cpython-311.pyc +0 -0
- package/skills/swarm_config/__pycache__/configure.cpython-311.pyc +0 -0
- 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:
|
|
392
|
-
|
|
393
|
-
self.
|
|
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()
|