fix: colony skill leakage
This commit is contained in:
@@ -767,8 +767,16 @@ async def fork_session_into_colony(
|
||||
session.id,
|
||||
)
|
||||
|
||||
# "is_new" keys off worker.json, not bare dir existence: the queen's
|
||||
# create_colony tool now pre-creates colony_dir (so it can
|
||||
# materialize the colony-scoped skill folder BEFORE the fork), which
|
||||
# would wrongly flag every fresh colony as "already-exists" if we
|
||||
# used ``not colony_dir.exists()``. A colony is "new" until its
|
||||
# worker config has actually been written.
|
||||
colony_dir = Path.home() / ".hive" / "colonies" / colony_name
|
||||
is_new = not colony_dir.exists()
|
||||
worker_name = "worker"
|
||||
worker_config_path = colony_dir / f"{worker_name}.json"
|
||||
is_new = not worker_config_path.exists()
|
||||
colony_dir.mkdir(parents=True, exist_ok=True)
|
||||
(colony_dir / "data").mkdir(exist_ok=True)
|
||||
|
||||
@@ -815,10 +823,9 @@ async def fork_session_into_colony(
|
||||
exc,
|
||||
)
|
||||
|
||||
# Fixed worker name -- sessions are the unit of parallelism, not workers
|
||||
worker_name = "worker"
|
||||
|
||||
worker_config_path = colony_dir / f"{worker_name}.json"
|
||||
# Fixed worker name and config path are already computed above so
|
||||
# ``is_new`` can be derived from worker.json rather than the colony
|
||||
# directory (see comment on the ``is_new`` block).
|
||||
|
||||
# ── 1. Gather queen state ─────────────────────────────────────
|
||||
# Queen-lifecycle + agent-management tools are registered ONLY against
|
||||
|
||||
@@ -21,10 +21,32 @@ Each skill is a directory containing a `SKILL.md`. At startup, only the frontmat
|
||||
|
||||
### Choosing where to put a new skill
|
||||
|
||||
- **Project-scoped**: put under `<project>/.hive/skills/` when the skill is tied to that codebase's APIs, conventions, or infra.
|
||||
- **User-scoped**: put under `~/.hive/skills/` when the skill is reusable across projects for this machine/user.
|
||||
- **Colony-scoped (via `create_colony`)**: when the skill is the operational protocol a single colony needs — its API auth, DOM selectors, DB schema, task-queue conventions — do NOT place it under `~/.hive/skills/` or `<project>/.hive/skills/` yourself. Those roots are SHARED and every colony on the machine will see it. Instead, pass the skill content INLINE to the `create_colony` tool (`skill_name`, `skill_description`, `skill_body`, optional `skill_files`). The tool materializes the folder under `~/.hive/colonies/<colony_name>/.hive/skills/<skill-name>/` where it is discovered as **project scope** by only that colony's workers. See the subsection below.
|
||||
- **Project-scoped**: put under `<project>/.hive/skills/` when the skill is tied to that codebase's APIs, conventions, or infra and multiple agents in the project should share it.
|
||||
- **User-scoped**: put under `~/.hive/skills/` when the skill is reusable across projects for this machine/user and all agents should see it.
|
||||
- **Framework default**: add under `core/framework/skills/_default_skills/` AND register in `framework/skills/defaults.py::SKILL_REGISTRY` only when the skill is a universal operational protocol shipped with Hive. Default skills use the `hive.<name>` naming convention and include `type: default-skill` in metadata.
|
||||
|
||||
### Colony-scoped skills via `create_colony`
|
||||
|
||||
A colony-scoped skill is one that belongs to exactly ONE colony — e.g. it encodes the HoneyComb staging API the `honeycomb_research` colony polls, or the LinkedIn outbound flow the `linkedin_outbound_campaign` colony runs. Writing such a skill at `~/.hive/skills/` or `<project>/.hive/skills/` leaks it to every other colony, which will then see it at selection time.
|
||||
|
||||
**Do not reach for `write_file` to create the folder.** The `create_colony` tool takes the skill content INLINE and places it for you:
|
||||
|
||||
```
|
||||
create_colony(
|
||||
colony_name="honeycomb_research",
|
||||
task="Build a daily honeycomb market report…",
|
||||
skill_name="honeycomb-api-protocol",
|
||||
skill_description="How to query the HoneyComb staging API…",
|
||||
skill_body="## Operational Protocol\n\nAuth: …",
|
||||
skill_files=[{"path": "scripts/fetch_tickers.py", "content": "…"}], # optional
|
||||
)
|
||||
```
|
||||
|
||||
The tool writes `~/.hive/colonies/honeycomb_research/.hive/skills/honeycomb-api-protocol/SKILL.md` (plus any `skill_files`), which `SkillDiscovery` picks up as project scope when that colony's workers start — and ONLY that colony's workers. No cross-colony leakage.
|
||||
|
||||
Do not write colony-bound skill folders by hand under `~/.hive/skills/`. A skill placed there is user-scoped and becomes visible to every colony on the machine — defeating the isolation you wanted.
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
@@ -124,8 +146,8 @@ For Python scripts in a Hive project, prefer `uv run scripts/foo.py ...`.
|
||||
### Creating a new skill — workflow
|
||||
|
||||
1. Pick a `<skill-name>` (lowercase-hyphenated).
|
||||
2. Decide scope: project (`<project>/.hive/skills/`), user (`~/.hive/skills/`), or framework default (`core/framework/skills/_default_skills/` + registry entry).
|
||||
3. Create the directory and write `SKILL.md` with frontmatter + body.
|
||||
2. Decide scope: **colony** (pass content INLINE to `create_colony` — STOP here, do not hand-author the folder), project (`<project>/.hive/skills/`), user (`~/.hive/skills/`), or framework default (`core/framework/skills/_default_skills/` + registry entry).
|
||||
3. For the non-colony scopes: create the directory and write `SKILL.md` with frontmatter + body.
|
||||
4. Add `scripts/`, `references/`, `assets/` only if needed.
|
||||
5. Validate the frontmatter: name matches dir, description is specific, no forbidden characters.
|
||||
6. Validate using the Hive CLI:
|
||||
|
||||
@@ -1130,7 +1130,10 @@ def register_queen_lifecycle_tools(
|
||||
# the skill content INLINE as tool arguments (skill_name,
|
||||
# skill_description, skill_body, and optional skill_files for
|
||||
# supporting scripts/references). The tool materializes the skill
|
||||
# folder under ``~/.hive/skills/{name}/`` itself, then forks.
|
||||
# folder under ``~/.hive/colonies/{colony_name}/.hive/skills/{name}/``
|
||||
# itself — colony-scoped, discovered as project scope by the
|
||||
# colony's worker and invisible to every other colony on the
|
||||
# machine — then forks.
|
||||
#
|
||||
# Why inline instead of a pre-authored folder path: earlier versions
|
||||
# required the queen to write SKILL.md with her own write_file tool
|
||||
@@ -1140,8 +1143,17 @@ def register_queen_lifecycle_tools(
|
||||
# "refusing to overwrite" error and didn't know how to recover. By
|
||||
# inlining the content we make colony creation a single atomic
|
||||
# operation with domain-level semantics: the queen owns her skill
|
||||
# namespace, so calling create_colony with an existing name simply
|
||||
# replaces the old skill (her latest content wins).
|
||||
# namespace inside the colony, so calling create_colony with an
|
||||
# existing name simply replaces the old skill (her latest content
|
||||
# wins).
|
||||
#
|
||||
# Why colony-scoped instead of user-scoped: an earlier version
|
||||
# materialized the folder at ``~/.hive/skills/{name}/``. That made
|
||||
# every colony on the machine see every colony-specific skill via
|
||||
# user-scope discovery — a worker in colony A could be offered
|
||||
# colony B's hyper-specific skill during selection. Writing into
|
||||
# the colony's own project dir kills that leak while still keeping
|
||||
# re-runs idempotent.
|
||||
|
||||
import re as _re
|
||||
import shutil as _shutil
|
||||
@@ -1155,8 +1167,16 @@ def register_queen_lifecycle_tools(
|
||||
skill_description: str,
|
||||
skill_body: str,
|
||||
skill_files: list[dict] | None,
|
||||
colony_dir: Path,
|
||||
) -> tuple[Path | None, str | None, bool]:
|
||||
"""Write a skill folder at ``~/.hive/skills/{name}/`` from inline content.
|
||||
"""Write a skill folder under ``{colony_dir}/.hive/skills/{name}/`` from inline content.
|
||||
|
||||
The skill is scoped to a single colony: ``SkillDiscovery`` scans
|
||||
``{project_root}/.hive/skills/`` as project-scope, and the
|
||||
colony's worker uses ``project_root = colony_dir`` — so only
|
||||
that colony's workers see it, not every colony on the machine.
|
||||
We deliberately avoid ``~/.hive/skills/`` here because that
|
||||
directory is scanned as user scope and leaks into every agent.
|
||||
|
||||
Returns ``(installed_path, error, replaced)``. On success
|
||||
``error`` is ``None`` and ``installed_path`` is the final
|
||||
@@ -1228,7 +1248,7 @@ def register_queen_lifecycle_tools(
|
||||
), False
|
||||
normalized_files.append((rel_path, content))
|
||||
|
||||
target_root = Path.home() / ".hive" / "skills"
|
||||
target_root = colony_dir / ".hive" / "skills"
|
||||
target = target_root / name
|
||||
try:
|
||||
target_root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1276,13 +1296,15 @@ def register_queen_lifecycle_tools(
|
||||
The queen passes skill content inline: ``skill_name``,
|
||||
``skill_description``, ``skill_body``, and optional
|
||||
``skill_files`` (supporting scripts/references). The tool
|
||||
writes ``~/.hive/skills/{skill_name}/SKILL.md`` and any extras,
|
||||
then forks the queen session into a new colony directory and
|
||||
stores the task in ``worker.json``. NOTHING RUNS after fork.
|
||||
writes ``~/.hive/colonies/{colony_name}/.hive/skills/{skill_name}/``
|
||||
(colony-scoped, only this colony's workers see it), then forks
|
||||
the queen session into that colony directory and stores the
|
||||
task in ``worker.json``. NOTHING RUNS after fork.
|
||||
|
||||
If a skill of the same name already exists, it is overwritten —
|
||||
the queen owns her skill namespace, and calling create_colony
|
||||
with an existing name means "my latest content wins."
|
||||
If a skill of the same name already exists inside this colony,
|
||||
it is overwritten — the queen owns her skill namespace inside
|
||||
the colony, and calling create_colony with an existing name
|
||||
means "my latest content wins."
|
||||
|
||||
When *tasks* is provided, each entry is seeded into the
|
||||
colony's ``progress.db`` task queue in a single transaction.
|
||||
@@ -1300,11 +1322,22 @@ def register_queen_lifecycle_tools(
|
||||
{"error": ("colony_name must be lowercase alphanumeric with underscores (e.g. 'honeycomb_research').")}
|
||||
)
|
||||
|
||||
# Pre-create the colony dir so the skill can be materialized
|
||||
# INSIDE it (project scope, colony-local). fork_session_into_colony
|
||||
# keys "is_new" off worker.json rather than the dir itself, so
|
||||
# pre-creating here does not wrongly flag fresh colonies as "old".
|
||||
colony_dir = Path.home() / ".hive" / "colonies" / cn
|
||||
try:
|
||||
colony_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
return json.dumps({"error": f"failed to create colony dir {colony_dir}: {e}"})
|
||||
|
||||
installed_skill, skill_err, skill_replaced = _materialize_skill_folder(
|
||||
skill_name=skill_name,
|
||||
skill_description=skill_description,
|
||||
skill_body=skill_body,
|
||||
skill_files=skill_files,
|
||||
colony_dir=colony_dir,
|
||||
)
|
||||
if skill_err is not None:
|
||||
return json.dumps(
|
||||
@@ -1425,11 +1458,15 @@ def register_queen_lifecycle_tools(
|
||||
"chat, use run_parallel_workers instead.\n\n"
|
||||
"ATOMIC CALL: you pass the skill content INLINE as "
|
||||
"arguments (skill_name, skill_description, skill_body, "
|
||||
"optional skill_files). Do NOT write the skill folder "
|
||||
"yourself beforehand — this tool materializes "
|
||||
"~/.hive/skills/{skill_name}/ for you and then forks. If a "
|
||||
"skill of the same name already exists it is replaced by "
|
||||
"your latest content (you own your skill namespace).\n\n"
|
||||
"optional skill_files). The tool writes the folder at "
|
||||
"~/.hive/colonies/{colony_name}/.hive/skills/{skill_name}/ "
|
||||
"— scoped to THIS colony only (project scope); no other "
|
||||
"colony on the machine can see it. Do NOT write the folder "
|
||||
"yourself with write_file; folders hand-authored at "
|
||||
"~/.hive/skills/ are user-scoped and LEAK to every colony. "
|
||||
"If a skill of the same name already exists under this "
|
||||
"colony, it is replaced by your latest content (you own "
|
||||
"your skill namespace inside the colony).\n\n"
|
||||
"NOTHING RUNS AFTER FORK. This tool is file-system only: "
|
||||
"it writes the skill folder, copies the queen session "
|
||||
"into a new colony directory, and stores the task in "
|
||||
@@ -1486,10 +1523,11 @@ def register_queen_lifecycle_tools(
|
||||
"Identifier for the skill folder. Lowercase "
|
||||
"[a-z0-9-], no leading/trailing/consecutive "
|
||||
"hyphens, ≤64 chars. Becomes the directory "
|
||||
"under ~/.hive/skills/ and the frontmatter "
|
||||
"'name' field. Example: "
|
||||
"'honeycomb-api-protocol'. Reusing an existing "
|
||||
"name replaces that skill."
|
||||
"under ~/.hive/colonies/<colony_name>/.hive/"
|
||||
"skills/ and the frontmatter 'name' field. "
|
||||
"Example: 'honeycomb-api-protocol'. Reusing "
|
||||
"an existing name within this colony replaces "
|
||||
"that skill."
|
||||
),
|
||||
},
|
||||
"skill_description": {
|
||||
|
||||
@@ -4,10 +4,14 @@ Contract (atomic inline-skill flow):
|
||||
|
||||
The queen calls ``create_colony(colony_name, task, skill_name,
|
||||
skill_description, skill_body, skill_files?, tasks?)`` in a single
|
||||
call. The tool materializes ``~/.hive/skills/{skill_name}/`` from the
|
||||
call. The tool materializes
|
||||
``~/.hive/colonies/{colony_name}/.hive/skills/{skill_name}/`` from the
|
||||
inline content (writing SKILL.md and any supporting files), then forks
|
||||
the queen session into a colony. Reusing an existing skill name simply
|
||||
replaces the old skill — the queen owns her skill namespace.
|
||||
the queen session into that colony. The skill is **colony-scoped** —
|
||||
discovered as project scope by that colony's workers, invisible to
|
||||
every other colony on the machine. Reusing an existing skill name
|
||||
inside the colony simply replaces the old skill — the queen owns her
|
||||
skill namespace inside the colony.
|
||||
|
||||
We monkeypatch ``fork_session_into_colony`` so the test doesn't need a
|
||||
real queen / session directory. We also redirect ``$HOME`` so the test's
|
||||
@@ -61,11 +65,16 @@ async def _call(executor, **inputs) -> dict:
|
||||
|
||||
@pytest.fixture
|
||||
def patched_home(tmp_path, monkeypatch):
|
||||
"""Redirect $HOME so ~/.hive/skills/ lands in tmp_path."""
|
||||
"""Redirect $HOME so ~/.hive/colonies/ lands in tmp_path."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _colony_skill_path(home: Path, colony_name: str, skill_name: str) -> Path:
|
||||
"""Where the tool now materializes the skill (colony-scoped project dir)."""
|
||||
return home / ".hive" / "colonies" / colony_name / ".hive" / "skills" / skill_name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_fork(monkeypatch):
|
||||
"""Stub out fork_session_into_colony so we don't need a real queen."""
|
||||
@@ -153,10 +162,10 @@ async def test_happy_path_emits_colony_created_event(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_happy_path_materializes_skill_under_home(
|
||||
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/skills/{name}/."""
|
||||
"""Inline skill content is written to ~/.hive/colonies/{colony}/.hive/skills/{name}/."""
|
||||
executor, session = _make_executor()
|
||||
|
||||
description = (
|
||||
@@ -189,7 +198,10 @@ async def test_happy_path_materializes_skill_under_home(
|
||||
assert payload["skill_name"] == "honeycomb-api-protocol"
|
||||
assert payload["skill_replaced"] is False
|
||||
|
||||
installed = patched_home / ".hive" / "skills" / "honeycomb-api-protocol" / "SKILL.md"
|
||||
installed = (
|
||||
_colony_skill_path(patched_home, "honeycomb_research", "honeycomb-api-protocol")
|
||||
/ "SKILL.md"
|
||||
)
|
||||
assert installed.exists()
|
||||
text = installed.read_text(encoding="utf-8")
|
||||
assert text.startswith("---\n")
|
||||
@@ -197,6 +209,10 @@ async def test_happy_path_materializes_skill_under_home(
|
||||
assert f"description: {description}" in text
|
||||
assert "HoneyComb API Operational Protocol" in text
|
||||
|
||||
# Critically: the skill must NOT land in the shared user-scope dir —
|
||||
# that was the leak we are fixing.
|
||||
assert not (patched_home / ".hive" / "skills" / "honeycomb-api-protocol").exists()
|
||||
|
||||
# Fork was called with the right args
|
||||
assert len(patched_fork) == 1
|
||||
assert patched_fork[0]["colony_name"] == "honeycomb_research"
|
||||
@@ -204,6 +220,52 @@ async def test_happy_path_materializes_skill_under_home(
|
||||
assert patched_fork[0]["session"] is session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_colonies_do_not_share_skill_namespace(
|
||||
patched_home: Path, patched_fork: list[dict]
|
||||
) -> None:
|
||||
"""A skill authored via create_colony is invisible to other colonies' worker dirs.
|
||||
|
||||
This is the core isolation guarantee: colony A's create_colony call
|
||||
must NOT plant files under colony B's project root or under the
|
||||
user-global skills dir.
|
||||
"""
|
||||
executor, _ = _make_executor()
|
||||
|
||||
payload_a = await _call(
|
||||
executor,
|
||||
colony_name="alpha",
|
||||
task="t",
|
||||
skill_name="alpha-only-skill",
|
||||
skill_description="Only the alpha colony should see this skill.",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert payload_a.get("status") == "created", payload_a
|
||||
|
||||
payload_b = await _call(
|
||||
executor,
|
||||
colony_name="bravo",
|
||||
task="t",
|
||||
skill_name="bravo-only-skill",
|
||||
skill_description="Only the bravo colony should see this skill.",
|
||||
skill_body=_DEFAULT_BODY,
|
||||
)
|
||||
assert payload_b.get("status") == "created", payload_b
|
||||
|
||||
alpha_dir = patched_home / ".hive" / "colonies" / "alpha" / ".hive" / "skills"
|
||||
bravo_dir = patched_home / ".hive" / "colonies" / "bravo" / ".hive" / "skills"
|
||||
user_skills = patched_home / ".hive" / "skills"
|
||||
|
||||
# Each colony only contains its own skill
|
||||
assert (alpha_dir / "alpha-only-skill" / "SKILL.md").exists()
|
||||
assert not (alpha_dir / "bravo-only-skill").exists()
|
||||
assert (bravo_dir / "bravo-only-skill" / "SKILL.md").exists()
|
||||
assert not (bravo_dir / "alpha-only-skill").exists()
|
||||
|
||||
# Nothing landed in the shared user-global dir.
|
||||
assert not user_skills.exists() or not any(user_skills.iterdir())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_files_are_written_alongside_skill_md(
|
||||
patched_home: Path, patched_fork: list[dict]
|
||||
@@ -225,7 +287,7 @@ async def test_skill_files_are_written_alongside_skill_md(
|
||||
)
|
||||
assert payload.get("status") == "created", payload
|
||||
|
||||
skill_dir = patched_home / ".hive" / "skills" / "fancy-skill"
|
||||
skill_dir = _colony_skill_path(patched_home, "fancy_skill", "fancy-skill")
|
||||
assert (skill_dir / "SKILL.md").exists()
|
||||
assert (skill_dir / "scripts" / "run.sh").read_text() == "#!/bin/sh\necho hi\n"
|
||||
assert (skill_dir / "references" / "shapes.md").read_text() == "# Shapes\nfoo\n"
|
||||
@@ -235,10 +297,10 @@ async def test_skill_files_are_written_alongside_skill_md(
|
||||
async def test_existing_skill_is_replaced(
|
||||
patched_home: Path, patched_fork: list[dict]
|
||||
) -> None:
|
||||
"""Reusing a skill_name replaces the old skill with fresh content."""
|
||||
"""Reusing a skill_name within the same colony replaces the old skill."""
|
||||
executor, _ = _make_executor()
|
||||
|
||||
skill_root = patched_home / ".hive" / "skills" / "x-job-market-replier"
|
||||
skill_root = _colony_skill_path(patched_home, "replier_colony", "x-job-market-replier")
|
||||
skill_root.mkdir(parents=True)
|
||||
(skill_root / "SKILL.md").write_text(
|
||||
"---\nname: x-job-market-replier\ndescription: stale\n---\n\nold body\n",
|
||||
@@ -460,6 +522,8 @@ async def test_fork_failure_keeps_materialized_skill(
|
||||
assert "error" in payload
|
||||
assert "fork failed" in payload["error"]
|
||||
assert "skill_installed" in payload
|
||||
installed = patched_home / ".hive" / "skills" / "durable-skill" / "SKILL.md"
|
||||
installed = (
|
||||
_colony_skill_path(patched_home, "will_fail", "durable-skill") / "SKILL.md"
|
||||
)
|
||||
assert installed.exists()
|
||||
assert "hint" in payload
|
||||
|
||||
Reference in New Issue
Block a user