@spawn: owner + registry_id written into the SEALED registry entries (authority lives in registry, not the self-editable passport). ensure_project_has_owner() now keys off citizen_class=manager (was earliest-created, which mislabeled @aipass) and writes the registry entry. get_owner()/is_owner() resolvers added. 315 tests, seedgo 100%. @hooks: new registry_gate PreToolUse handler seals *_REGISTRY.json — blocks raw writes/tee/sed/rm + Edit/Write/MultiEdit, redirects to drone @spawn; per-clause bypass defeats compound-command smuggling; reads allowed. 82 tests, seedgo 100%. @ai_mail: wake-back reslope — SKIP_SENDERS blocklist replaced by an is_owner allowlist. Only the project owner is woken when their dispatched agent completes; all other guards intact (depth cap, lock, occupancy, honest messaging, dispatch_wake.log). seedgo 100% on the changed file. devpulse cross-part verify (REAL unmocked resolver): is_owner resolves devpulse-only; gate 13/13 incl compound-smuggle blocked + reads/drone-@spawn allowed; wake-back wakes owner / skips non-owner / respects depth-cap; 195 new-suite tests green together. Note: AIPASS_REGISTRY.json is gitignored — this ships the CODE; owner data regenerates per-install via ensure_project_has_owner. Still open (PART 4): gate watchdog+feedback on is_owner; portability of owner-only privileges across projects.
473 lines
17 KiB
Python
473 lines
17 KiB
Python
# =================== META ====================
|
|
# Name: test_owner_resolver.py
|
|
# Description: Tests for owner resolver and registry authority
|
|
# Version: 1.0.0
|
|
# Created: 2026-07-10
|
|
# Modified: 2026-07-10
|
|
# =============================================
|
|
|
|
"""Tests for owner resolver: get_owner, is_owner, ensure_project_has_owner, backfill."""
|
|
|
|
import json
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
|
|
@pytest.fixture
|
|
def registry_with_owner(tmp_path):
|
|
"""Create a registry file with one owner branch."""
|
|
reg = tmp_path / "AIPASS_REGISTRY.json"
|
|
reg.write_text(
|
|
json.dumps(
|
|
{
|
|
"metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 3},
|
|
"branches": [
|
|
{
|
|
"name": "alpha",
|
|
"path": "src/alpha",
|
|
"email": "@alpha",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "test",
|
|
"created": "2026-01-01",
|
|
"last_active": "2026-01-01",
|
|
},
|
|
{
|
|
"name": "devpulse",
|
|
"path": "src/devpulse",
|
|
"email": "@devpulse",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "orchestrator",
|
|
"created": "2026-01-02",
|
|
"last_active": "2026-01-02",
|
|
"owner": True,
|
|
"registry_id": "abc-123",
|
|
},
|
|
{
|
|
"name": "gamma",
|
|
"path": "src/gamma",
|
|
"email": "@gamma",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "test",
|
|
"created": "2026-01-03",
|
|
"last_active": "2026-01-03",
|
|
},
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return reg
|
|
|
|
|
|
@pytest.fixture
|
|
def registry_no_owner(tmp_path):
|
|
"""Create a registry file with no owner set."""
|
|
reg = tmp_path / "AIPASS_REGISTRY.json"
|
|
reg.write_text(
|
|
json.dumps(
|
|
{
|
|
"metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 2},
|
|
"branches": [
|
|
{
|
|
"name": "alpha",
|
|
"path": "src/alpha",
|
|
"email": "@alpha",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "test",
|
|
"created": "2026-04-16",
|
|
"last_active": "2026-04-16",
|
|
},
|
|
{
|
|
"name": "devpulse",
|
|
"path": "src/devpulse",
|
|
"email": "@devpulse",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "orchestrator",
|
|
"created": "2026-04-28",
|
|
"last_active": "2026-04-28",
|
|
},
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return reg
|
|
|
|
|
|
class TestGetOwner:
|
|
"""Tests for get_owner()."""
|
|
|
|
def test_returns_owner_entry(self, registry_with_owner, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import get_owner
|
|
|
|
with patch("aipass.spawn.apps.handlers.registry.find_registry", return_value=registry_with_owner):
|
|
result = get_owner(start_path=tmp_path)
|
|
|
|
assert result is not None
|
|
assert result["name"] == "devpulse"
|
|
assert result["owner"] is True
|
|
|
|
def test_returns_none_when_no_owner(self, registry_no_owner, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import get_owner
|
|
|
|
with patch("aipass.spawn.apps.handlers.registry.find_registry", return_value=registry_no_owner):
|
|
result = get_owner(start_path=tmp_path)
|
|
|
|
assert result is None
|
|
|
|
def test_returns_none_when_registry_missing(self, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import get_owner
|
|
|
|
missing = tmp_path / "MISSING_REGISTRY.json"
|
|
with patch("aipass.spawn.apps.handlers.registry.find_registry", return_value=missing):
|
|
result = get_owner(start_path=tmp_path)
|
|
|
|
assert result is None
|
|
|
|
def test_default_start_path_uses_cwd(self, registry_with_owner):
|
|
from aipass.spawn.apps.handlers.registry import get_owner
|
|
|
|
with patch("aipass.spawn.apps.handlers.registry.find_registry", return_value=registry_with_owner):
|
|
result = get_owner()
|
|
|
|
assert result is not None
|
|
assert result["name"] == "devpulse"
|
|
|
|
|
|
class TestIsOwner:
|
|
"""Tests for is_owner()."""
|
|
|
|
def test_true_for_owner_email_with_at(self, registry_with_owner, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import is_owner
|
|
|
|
with patch("aipass.spawn.apps.handlers.registry.find_registry", return_value=registry_with_owner):
|
|
assert is_owner("@devpulse", start_path=tmp_path) is True
|
|
|
|
def test_true_for_owner_email_without_at(self, registry_with_owner, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import is_owner
|
|
|
|
with patch("aipass.spawn.apps.handlers.registry.find_registry", return_value=registry_with_owner):
|
|
assert is_owner("devpulse", start_path=tmp_path) is True
|
|
|
|
def test_false_for_non_owner(self, registry_with_owner, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import is_owner
|
|
|
|
with patch("aipass.spawn.apps.handlers.registry.find_registry", return_value=registry_with_owner):
|
|
assert is_owner("@alpha", start_path=tmp_path) is False
|
|
|
|
def test_false_for_empty_email(self, registry_with_owner, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import is_owner
|
|
|
|
with patch("aipass.spawn.apps.handlers.registry.find_registry", return_value=registry_with_owner):
|
|
assert is_owner("", start_path=tmp_path) is False
|
|
|
|
def test_false_for_none_email(self, registry_with_owner, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import is_owner
|
|
|
|
with patch("aipass.spawn.apps.handlers.registry.find_registry", return_value=registry_with_owner):
|
|
assert is_owner(None, start_path=tmp_path) is False
|
|
|
|
def test_false_when_no_owner_in_registry(self, registry_no_owner, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import is_owner
|
|
|
|
with patch("aipass.spawn.apps.handlers.registry.find_registry", return_value=registry_no_owner):
|
|
assert is_owner("@devpulse", start_path=tmp_path) is False
|
|
|
|
|
|
class TestEnsureProjectHasOwner:
|
|
"""Tests for ensure_project_has_owner() — registry-entry based."""
|
|
|
|
def test_sets_owner_on_manager_branch(self, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import ensure_project_has_owner
|
|
|
|
reg = tmp_path / "TEST_REGISTRY.json"
|
|
reg.write_text(
|
|
json.dumps(
|
|
{
|
|
"metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 2},
|
|
"branches": [
|
|
{
|
|
"name": "alpha",
|
|
"path": "src/alpha",
|
|
"email": "@alpha",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "test",
|
|
"created": "2026-01-01",
|
|
"last_active": "2026-01-01",
|
|
},
|
|
{
|
|
"name": "devpulse",
|
|
"path": "src/devpulse",
|
|
"email": "@devpulse",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "test",
|
|
"created": "2026-01-02",
|
|
"last_active": "2026-01-02",
|
|
},
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
alpha_dir = tmp_path / "src" / "alpha" / ".trinity"
|
|
alpha_dir.mkdir(parents=True)
|
|
(alpha_dir / "passport.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"identity": {"citizen_class": "aipass_framework"},
|
|
"citizenship": {"registry_id": "abc"},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
dp_dir = tmp_path / "src" / "devpulse" / ".trinity"
|
|
dp_dir.mkdir(parents=True)
|
|
(dp_dir / "passport.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"identity": {"citizen_class": "manager"},
|
|
"citizenship": {"registry_id": "abc"},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = ensure_project_has_owner(reg)
|
|
assert result is True
|
|
|
|
data = json.loads(reg.read_text(encoding="utf-8"))
|
|
devpulse_entry = next(b for b in data["branches"] if b["name"] == "devpulse")
|
|
alpha_entry = next(b for b in data["branches"] if b["name"] == "alpha")
|
|
assert devpulse_entry.get("owner") is True
|
|
assert alpha_entry.get("owner") is None or alpha_entry.get("owner") is not True
|
|
|
|
def test_noop_when_owner_already_set(self, registry_with_owner):
|
|
from aipass.spawn.apps.handlers.registry import ensure_project_has_owner
|
|
|
|
result = ensure_project_has_owner(registry_with_owner)
|
|
assert result is False
|
|
|
|
def test_returns_false_for_empty_registry(self, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import ensure_project_has_owner
|
|
|
|
reg = tmp_path / "TEST_REGISTRY.json"
|
|
reg.write_text(
|
|
json.dumps(
|
|
{
|
|
"metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 0},
|
|
"branches": [],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = ensure_project_has_owner(reg)
|
|
assert result is False
|
|
|
|
|
|
class TestBackfillOwnerAndRegistryId:
|
|
"""Tests for backfill_owner_and_registry_id()."""
|
|
|
|
def test_backfills_registry_id_and_owner(self, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import backfill_owner_and_registry_id
|
|
|
|
reg = tmp_path / "TEST_REGISTRY.json"
|
|
reg.write_text(
|
|
json.dumps(
|
|
{
|
|
"metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 2},
|
|
"branches": [
|
|
{
|
|
"name": "alpha",
|
|
"path": "src/alpha",
|
|
"email": "@alpha",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "test",
|
|
"created": "2026-01-01",
|
|
"last_active": "2026-01-01",
|
|
},
|
|
{
|
|
"name": "devpulse",
|
|
"path": "src/devpulse",
|
|
"email": "@devpulse",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "test",
|
|
"created": "2026-01-02",
|
|
"last_active": "2026-01-02",
|
|
},
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
alpha_dir = tmp_path / "src" / "alpha" / ".trinity"
|
|
alpha_dir.mkdir(parents=True)
|
|
(alpha_dir / "passport.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"identity": {"citizen_class": "aipass_framework"},
|
|
"citizenship": {"registry_id": "uuid-alpha"},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
dp_dir = tmp_path / "src" / "devpulse" / ".trinity"
|
|
dp_dir.mkdir(parents=True)
|
|
(dp_dir / "passport.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"identity": {"citizen_class": "manager"},
|
|
"citizenship": {"registry_id": "uuid-dp"},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = backfill_owner_and_registry_id(reg)
|
|
assert result is True
|
|
|
|
data = json.loads(reg.read_text(encoding="utf-8"))
|
|
alpha_entry = next(b for b in data["branches"] if b["name"] == "alpha")
|
|
dp_entry = next(b for b in data["branches"] if b["name"] == "devpulse")
|
|
|
|
assert alpha_entry["registry_id"] == "uuid-alpha"
|
|
assert dp_entry["registry_id"] == "uuid-dp"
|
|
assert dp_entry["owner"] is True
|
|
assert alpha_entry.get("owner") is None or alpha_entry.get("owner") is not True
|
|
|
|
def test_noop_when_already_backfilled(self, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import backfill_owner_and_registry_id
|
|
|
|
reg = tmp_path / "TEST_REGISTRY.json"
|
|
reg.write_text(
|
|
json.dumps(
|
|
{
|
|
"metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 1},
|
|
"branches": [
|
|
{
|
|
"name": "devpulse",
|
|
"path": "src/devpulse",
|
|
"email": "@devpulse",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "test",
|
|
"created": "2026-01-01",
|
|
"last_active": "2026-01-01",
|
|
"owner": True,
|
|
"registry_id": "uuid-dp",
|
|
},
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
dp_dir = tmp_path / "src" / "devpulse" / ".trinity"
|
|
dp_dir.mkdir(parents=True)
|
|
(dp_dir / "passport.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"identity": {"citizen_class": "manager"},
|
|
"citizenship": {"registry_id": "uuid-dp"},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = backfill_owner_and_registry_id(reg)
|
|
assert result is False
|
|
|
|
def test_skips_branches_without_passport(self, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import backfill_owner_and_registry_id
|
|
|
|
reg = tmp_path / "TEST_REGISTRY.json"
|
|
reg.write_text(
|
|
json.dumps(
|
|
{
|
|
"metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 1},
|
|
"branches": [
|
|
{
|
|
"name": "ghost",
|
|
"path": "src/ghost",
|
|
"email": "@ghost",
|
|
"status": "active",
|
|
"profile": "library",
|
|
"description": "test",
|
|
"created": "2026-01-01",
|
|
"last_active": "2026-01-01",
|
|
},
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = backfill_owner_and_registry_id(reg)
|
|
assert result is False
|
|
|
|
|
|
class TestAddToRegistryWithRegistryId:
|
|
"""Tests for add_to_registry with registry_id parameter."""
|
|
|
|
def test_includes_registry_id_when_provided(self, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import add_to_registry
|
|
|
|
reg = tmp_path / "TEST_REGISTRY.json"
|
|
reg.write_text(
|
|
json.dumps(
|
|
{
|
|
"metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 0},
|
|
"branches": [],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
result = add_to_registry(
|
|
reg,
|
|
"NEW_BRANCH",
|
|
"src/new_branch",
|
|
"library",
|
|
"@new_branch",
|
|
purpose="test branch",
|
|
registry_id="uuid-new",
|
|
)
|
|
assert result is True
|
|
|
|
data = json.loads(reg.read_text(encoding="utf-8"))
|
|
entry = data["branches"][0]
|
|
assert entry["registry_id"] == "uuid-new"
|
|
|
|
def test_omits_registry_id_when_empty(self, tmp_path):
|
|
from aipass.spawn.apps.handlers.registry import add_to_registry
|
|
|
|
reg = tmp_path / "TEST_REGISTRY.json"
|
|
reg.write_text(
|
|
json.dumps(
|
|
{
|
|
"metadata": {"version": "1.0.0", "last_updated": "2026-07-10", "total_branches": 0},
|
|
"branches": [],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
add_to_registry(reg, "NEW_BRANCH", "src/new_branch", "library", "@new_branch")
|
|
|
|
data = json.loads(reg.read_text(encoding="utf-8"))
|
|
entry = data["branches"][0]
|
|
assert "registry_id" not in entry
|