diff --git a/core/framework/server/routes_execution.py b/core/framework/server/routes_execution.py index 89b09b37..4be7ad96 100644 --- a/core/framework/server/routes_execution.py +++ b/core/framework/server/routes_execution.py @@ -1577,6 +1577,39 @@ async def fork_session_into_colony( } metadata_path.write_text(json.dumps(metadata, indent=2, ensure_ascii=False), encoding="utf-8") + # ── 4a. Inherit the queen's tool allowlist into the colony ─── + # A colony forked from a curated queen should start with the same + # tool surface (otherwise the colony silently falls back to its own + # "allow every MCP tool" default, undoing the parent's curation). + # We copy the queen's LIVE effective allowlist so the snapshot + # reflects whatever was in force the moment the user clicked "Create + # Colony". Users can further narrow the colony via the Tool Library. + # Skip the write when the queen is on allow-all (None) so the colony + # keeps the same semantics without creating an inert sidecar. + try: + queen_enabled = getattr( + getattr(session, "phase_state", None), + "enabled_mcp_tools", + None, + ) + if isinstance(queen_enabled, list): + from framework.host.colony_tools_config import update_colony_tools_config + + update_colony_tools_config(colony_name, list(queen_enabled)) + logger.info( + "Inherited queen allowlist into colony '%s' (%d tools)", + colony_name, + len(queen_enabled), + ) + except Exception: + # Inheritance is best-effort — don't let a tools.json hiccup + # abort colony creation. + logger.warning( + "Failed to inherit queen allowlist into colony '%s'", + colony_name, + exc_info=True, + ) + # ── 5. Update source queen session meta.json ───────────────── # Link the originating session back to the colony for discovery. source_meta_path = source_queen_dir / "meta.json" diff --git a/core/framework/server/tests/test_colony_tools.py b/core/framework/server/tests/test_colony_tools.py index af410932..c89ff115 100644 --- a/core/framework/server/tests/test_colony_tools.py +++ b/core/framework/server/tests/test_colony_tools.py @@ -251,6 +251,35 @@ async def test_tools_index_lists_colonies(colony_dir): assert entries[name]["has_allowlist"] is False +def test_queen_allowlist_inherits_into_new_colony(tmp_path, monkeypatch): + """A colony forked with a curated queen inherits her allowlist. + + Exercises the inheritance hook in + ``routes_execution.fork_session_into_colony`` without running the + full fork machinery — we just call + ``update_colony_tools_config`` the same way the hook does and + assert the colony's ``tools.json`` matches the queen's live list. + """ + colonies = tmp_path / "colonies" + colonies.mkdir() + monkeypatch.setattr("framework.host.colony_tools_config.COLONIES_DIR", colonies) + + from framework.host.colony_tools_config import ( + load_colony_tools_config, + update_colony_tools_config, + ) + + colony_name = "forked_child" + (colonies / colony_name).mkdir() + + # Simulate: queen has a curated allowlist (e.g. role default resolved + # to a concrete list). The inheritance hook copies it verbatim. + queen_live_allowlist = ["read_file", "web_scrape", "csv_read"] + update_colony_tools_config(colony_name, list(queen_live_allowlist)) + + assert load_colony_tools_config(colony_name) == queen_live_allowlist + + def test_legacy_metadata_field_migrates_to_sidecar(colony_dir): """A legacy enabled_mcp_tools field in metadata.json is hoisted to tools.json.""" colonies_dir, name = colony_dir diff --git a/core/framework/skills/overrides.py b/core/framework/skills/overrides.py index d9aca988..1dd65b25 100644 --- a/core/framework/skills/overrides.py +++ b/core/framework/skills/overrides.py @@ -64,6 +64,18 @@ class OverrideEntry: created_at: datetime | None = None created_by: str | None = None + def clone(self) -> OverrideEntry: + """Return a deep-enough copy (dict fields are re-allocated).""" + return OverrideEntry( + enabled=self.enabled, + provenance=self.provenance, + trust=self.trust, + param_overrides=dict(self.param_overrides), + notes=self.notes, + created_at=self.created_at, + created_by=self.created_by, + ) + def to_dict(self) -> dict[str, Any]: out: dict[str, Any] = {"provenance": str(self.provenance)} if self.enabled is not None: diff --git a/core/framework/tools/queen_lifecycle_tools.py b/core/framework/tools/queen_lifecycle_tools.py index 0063f290..b27e60dc 100644 --- a/core/framework/tools/queen_lifecycle_tools.py +++ b/core/framework/tools/queen_lifecycle_tools.py @@ -1616,14 +1616,39 @@ def register_queen_lifecycle_tools( } ) - # Register the write in the colony's override store so the UI can - # edit/toggle it and :func:`SkillsManager._apply_overrides` carries - # the right provenance. + # Seed the colony's override ledger from the queen's current + # state so the colony inherits everything she had enabled (preset + # capability packs, toggled-off framework defaults, etc.) at fork + # time. The colony then owns its own copy — later queen edits + # don't retroactively alter this colony's skill surface. + # On top of the seed we upsert the newly-written skill with + # QUEEN_CREATED provenance so the UI renders + edits it properly. try: + from framework.config import QUEENS_DIR + overrides_path = colony_dir / "skills_overrides.json" - store = SkillOverrideStore.load(overrides_path, scope_label=f"colony:{cn}") queen_id = getattr(session, "queen_name", None) or "unknown" - store.upsert( + colony_store = SkillOverrideStore.load(overrides_path, scope_label=f"colony:{cn}") + + queen_overrides_path = QUEENS_DIR / queen_id / "skills_overrides.json" + if queen_overrides_path.exists(): + queen_store = SkillOverrideStore.load( + queen_overrides_path, scope_label=f"queen:{queen_id}" + ) + # Shallow clone: queen's explicit toggles + master switch + # become the colony's starting state. Tombstones propagate + # so a queen-deleted UI skill doesn't resurrect here. + colony_store.all_defaults_disabled = queen_store.all_defaults_disabled + for sname, entry in queen_store.overrides.items(): + # Don't overwrite an entry the colony already set + # (rare on fresh fork; matters if this is a re-fork). + if sname in colony_store.overrides: + continue + colony_store.upsert(sname, entry.clone()) + for sname in queen_store.deleted_ui_skills: + colony_store.deleted_ui_skills.add(sname) + + colony_store.upsert( draft.name, OverrideEntry( enabled=True, @@ -1632,7 +1657,7 @@ def register_queen_lifecycle_tools( created_by=f"queen:{queen_id}", ), ) - store.save() + colony_store.save() except Exception: # Registration is best-effort; discovery still surfaces the # skill as project-scope even if the ledger fails to update. diff --git a/core/tests/test_create_colony_tool.py b/core/tests/test_create_colony_tool.py index d65099e5..2145ee0d 100644 --- a/core/tests/test_create_colony_tool.py +++ b/core/tests/test_create_colony_tool.py @@ -38,7 +38,7 @@ from framework.tools.queen_lifecycle_tools import register_queen_lifecycle_tools class _FakeSession: - def __init__(self, sid: str = "session_test_create_colony"): + def __init__(self, sid: str = "session_test_create_colony", queen_name: str = "sophia"): self.id = sid self.colony = None self.colony_runtime = None @@ -46,6 +46,7 @@ class _FakeSession: self.worker_path = None self.available_triggers: dict = {} self.active_trigger_ids: set = set() + self.queen_name = queen_name def _make_executor(): @@ -161,6 +162,64 @@ async def test_happy_path_emits_colony_created_event(patched_home: Path, patched assert ev.data.get("is_new") is True +@pytest.mark.asyncio +@pytest.mark.asyncio +async def test_colony_inherits_queen_override_state( + patched_home: Path, patched_fork: list[dict] +) -> None: + """Seed the colony's skills_overrides.json from the queen's at fork + time. A queen who enabled a preset (e.g. hive.x-automation) before + calling create_colony must produce a colony that also has it + enabled — without needing a second UI toggle on the colony page. + """ + from framework.config import QUEENS_DIR + from framework.skills.overrides import ( + OverrideEntry, + Provenance, + SkillOverrideStore, + ) + + # Pre-seed the queen's override file. + queen_home = QUEENS_DIR / "sophia" + queen_home.mkdir(parents=True, exist_ok=True) + qstore = SkillOverrideStore.load(queen_home / "skills_overrides.json") + qstore.upsert( + "hive.x-automation", + OverrideEntry(enabled=True, provenance=Provenance.PRESET), + ) + qstore.upsert( + "hive.note-taking", + OverrideEntry(enabled=False, provenance=Provenance.FRAMEWORK), + ) + qstore.save() + + executor, _ = _make_executor() + payload = await _call( + executor, + colony_name="inheritance_check", + task="t", + skill_name="bespoke-skill", + skill_description="Written during this create_colony call.", + skill_body=_DEFAULT_BODY, + ) + assert payload.get("status") == "created", f"Tool error: {payload}" + + colony_overrides = ( + patched_home / ".hive" / "colonies" / "inheritance_check" / "skills_overrides.json" + ) + cstore = SkillOverrideStore.load(colony_overrides) + + # Inherited entries from the queen: + assert cstore.get("hive.x-automation").enabled is True + assert cstore.get("hive.note-taking").enabled is False + + # Newly-written skill is also registered with queen_created provenance: + bespoke = cstore.get("bespoke-skill") + assert bespoke is not None + assert bespoke.provenance == Provenance.QUEEN_CREATED + assert bespoke.enabled is True + + @pytest.mark.asyncio async def test_happy_path_materializes_skill_under_colony_dir(patched_home: Path, patched_fork: list[dict]) -> None: """Inline skill content is written to ~/.hive/colonies/{colony}/.hive/skills/{name}/."""