test_identity_repo_phase3.py
python
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef
debug(push/stream): instrument O-frame decode path with INF…
Sonnet 4.6
patch
123 days ago
| 1 | """Phase 3 — Agent SPAWNS relationship committed to parent's identity repo. |
| 2 | |
| 3 | TDD regression suite: every test starts RED and turns GREEN as the feature |
| 4 | is implemented. Their permanent role is to prevent regressions. |
| 5 | |
| 6 | What this phase covers: |
| 7 | - register_agent_identity() commits a RelationshipRecord to the parent's |
| 8 | identity repo (not the agent's) |
| 9 | - The relationship file path is relationships/{parent}--spawns--{agent}.json |
| 10 | - The RelationshipRecord has correct from_handle, to_handle, edge_type |
| 11 | - The parent's identity repo gains exactly one new commit per agent spawned |
| 12 | - The agent's own identity repo is NOT modified (SPAWNS lives on the parent) |
| 13 | - No-op when the parent has no identity repo (migration-period safety) |
| 14 | """ |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | import json |
| 18 | |
| 19 | import pytest |
| 20 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 21 | from muse.core.types import encode_pubkey, encode_sig |
| 22 | from muse.plugins.identity.records import relationship_path |
| 23 | from sqlalchemy import select |
| 24 | from sqlalchemy.ext.asyncio import AsyncSession |
| 25 | |
| 26 | from musehub.crypto.keys import key_fingerprint |
| 27 | from musehub.db import musehub_models as db |
| 28 | from musehub.services.musehub_auth import ( |
| 29 | create_challenge, |
| 30 | register_agent_identity, |
| 31 | verify_and_authenticate, |
| 32 | ) |
| 33 | from musehub.types.json_types import JSONObject |
| 34 | |
| 35 | |
| 36 | # ── helpers ─────────────────────────────────────────────────────────────────── |
| 37 | |
| 38 | |
| 39 | def _keypair() -> tuple[Ed25519PrivateKey, bytes]: |
| 40 | priv = Ed25519PrivateKey.generate() |
| 41 | pub = priv.public_key().public_bytes_raw() |
| 42 | return priv, pub |
| 43 | |
| 44 | |
| 45 | def _sign_nonce(priv: Ed25519PrivateKey, nonce_hex: str) -> str: |
| 46 | sig_bytes = priv.sign(bytes.fromhex(nonce_hex)) |
| 47 | return encode_sig("ed25519", sig_bytes) |
| 48 | |
| 49 | |
| 50 | async def _register_human( |
| 51 | session: AsyncSession, handle: str |
| 52 | ) -> tuple[str, str]: |
| 53 | """Register a human identity; returns (identity_id, public_key_b64).""" |
| 54 | priv, pub = _keypair() |
| 55 | pub_b64 = encode_pubkey("ed25519", pub) |
| 56 | fp = key_fingerprint(pub) |
| 57 | nonce = await create_challenge(session, fingerprint=fp, algorithm="ed25519") |
| 58 | sig = _sign_nonce(priv, nonce) |
| 59 | result = await verify_and_authenticate( |
| 60 | session=session, |
| 61 | challenge_token=nonce, |
| 62 | public_key_b64=pub_b64, |
| 63 | signature_b64=sig, |
| 64 | handle=handle, |
| 65 | display_name=handle, |
| 66 | label="key", |
| 67 | ) |
| 68 | return result.identity_id, pub_b64 |
| 69 | |
| 70 | |
| 71 | async def _spawn_agent( |
| 72 | session: AsyncSession, agent_handle: str, spawned_by: str |
| 73 | ) -> None: |
| 74 | """Register a fresh agent identity under a given parent.""" |
| 75 | _, pub = _keypair() |
| 76 | pub_b64 = encode_pubkey("ed25519", pub) |
| 77 | fp = key_fingerprint(pub) |
| 78 | await register_agent_identity( |
| 79 | session=session, |
| 80 | handle=agent_handle, |
| 81 | public_key_b64=pub_b64, |
| 82 | fingerprint=fp, |
| 83 | algorithm="ed25519", |
| 84 | spawned_by=spawned_by, |
| 85 | ) |
| 86 | |
| 87 | |
| 88 | async def _get_identity_repo_commits( |
| 89 | session: AsyncSession, owner: str |
| 90 | ) -> list[db.MusehubCommit]: |
| 91 | repo_result = await session.execute( |
| 92 | select(db.MusehubRepo).where( |
| 93 | db.MusehubRepo.owner == owner, |
| 94 | db.MusehubRepo.slug == "identity", |
| 95 | ) |
| 96 | ) |
| 97 | repo = repo_result.scalar_one() |
| 98 | commits_result = await session.execute( |
| 99 | select(db.MusehubCommit).where( |
| 100 | db.MusehubCommit.repo_id == repo.repo_id, |
| 101 | db.MusehubCommit.branch == "main", |
| 102 | ).order_by(db.MusehubCommit.timestamp) |
| 103 | ) |
| 104 | return list(commits_result.scalars().all()) |
| 105 | |
| 106 | |
| 107 | async def _read_manifest_from_commit( |
| 108 | session: AsyncSession, commit: db.MusehubCommit |
| 109 | ) -> JSONObject: |
| 110 | import msgpack |
| 111 | snap = await session.get(db.MusehubSnapshot, commit.snapshot_id) |
| 112 | assert snap is not None |
| 113 | return msgpack.unpackb(snap.manifest_blob, raw=False) |
| 114 | |
| 115 | |
| 116 | async def _read_object_bytes(session: AsyncSession, object_id: str) -> bytes: |
| 117 | from pathlib import Path |
| 118 | obj = await session.get(db.MusehubObject, object_id) |
| 119 | assert obj is not None |
| 120 | disk_uri = obj.disk_path or obj.storage_uri or "" |
| 121 | return Path(disk_uri.removeprefix("local://")).read_bytes() |
| 122 | |
| 123 | |
| 124 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 125 | # 1. SPAWNS relationship committed to parent's identity repo |
| 126 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 127 | |
| 128 | |
| 129 | class TestSpawnsRelationshipCreated: |
| 130 | async def test_spawning_adds_commit_to_parent_identity_repo( |
| 131 | self, db_session: AsyncSession |
| 132 | ) -> None: |
| 133 | """register_agent_identity must add a new commit to the parent's identity repo.""" |
| 134 | await _register_human(db_session, "parent3a") |
| 135 | await _spawn_agent(db_session, "agent3a", spawned_by="parent3a") |
| 136 | |
| 137 | commits = await _get_identity_repo_commits(db_session, "parent3a") |
| 138 | assert len(commits) == 2, ( |
| 139 | f"Expected 2 commits on parent's identity repo after spawning an agent, " |
| 140 | f"got {len(commits)}." |
| 141 | ) |
| 142 | |
| 143 | async def test_spawns_commit_contains_relationship_file( |
| 144 | self, db_session: AsyncSession |
| 145 | ) -> None: |
| 146 | """The SPAWNS commit must include the relationship file at the correct path.""" |
| 147 | await _register_human(db_session, "parent3b") |
| 148 | await _spawn_agent(db_session, "agent3b", spawned_by="parent3b") |
| 149 | |
| 150 | commits = await _get_identity_repo_commits(db_session, "parent3b") |
| 151 | manifest = await _read_manifest_from_commit(db_session, commits[-1]) |
| 152 | |
| 153 | expected_path = relationship_path("parent3b", "spawns", "agent3b") |
| 154 | assert expected_path in manifest, ( |
| 155 | f"Expected {expected_path!r} in parent's identity repo manifest, " |
| 156 | f"got keys: {list(manifest)!r}." |
| 157 | ) |
| 158 | |
| 159 | async def test_spawning_two_agents_adds_two_commits_to_parent( |
| 160 | self, db_session: AsyncSession |
| 161 | ) -> None: |
| 162 | """Each agent spawned produces its own commit on the parent's identity repo.""" |
| 163 | await _register_human(db_session, "parent3c") |
| 164 | await _spawn_agent(db_session, "agent3c1", spawned_by="parent3c") |
| 165 | await _spawn_agent(db_session, "agent3c2", spawned_by="parent3c") |
| 166 | |
| 167 | commits = await _get_identity_repo_commits(db_session, "parent3c") |
| 168 | assert len(commits) == 3, ( |
| 169 | f"Expected 3 commits (initial + 2 spawns), got {len(commits)}." |
| 170 | ) |
| 171 | |
| 172 | async def test_agent_own_identity_repo_unchanged( |
| 173 | self, db_session: AsyncSession |
| 174 | ) -> None: |
| 175 | """The SPAWNS relationship lives on the parent — the agent's own repo must |
| 176 | have exactly 1 commit (its initial registration only).""" |
| 177 | await _register_human(db_session, "parent3d") |
| 178 | await _spawn_agent(db_session, "agent3d", spawned_by="parent3d") |
| 179 | |
| 180 | agent_commits = await _get_identity_repo_commits(db_session, "agent3d") |
| 181 | assert len(agent_commits) == 1, ( |
| 182 | f"Agent's own identity repo should have only 1 commit (initial registration), " |
| 183 | f"got {len(agent_commits)}." |
| 184 | ) |
| 185 | |
| 186 | |
| 187 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 188 | # 2. RelationshipRecord content is correct |
| 189 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 190 | |
| 191 | |
| 192 | class TestRelationshipRecordContent: |
| 193 | async def test_relationship_record_from_handle( |
| 194 | self, db_session: AsyncSession |
| 195 | ) -> None: |
| 196 | await _register_human(db_session, "parent3e") |
| 197 | await _spawn_agent(db_session, "agent3e", spawned_by="parent3e") |
| 198 | |
| 199 | commits = await _get_identity_repo_commits(db_session, "parent3e") |
| 200 | manifest = await _read_manifest_from_commit(db_session, commits[-1]) |
| 201 | rel_path = relationship_path("parent3e", "spawns", "agent3e") |
| 202 | raw = await _read_object_bytes(db_session, manifest[rel_path]) |
| 203 | record = json.loads(raw) |
| 204 | |
| 205 | assert record["from_handle"] == "parent3e" |
| 206 | |
| 207 | async def test_relationship_record_to_handle( |
| 208 | self, db_session: AsyncSession |
| 209 | ) -> None: |
| 210 | await _register_human(db_session, "parent3f") |
| 211 | await _spawn_agent(db_session, "agent3f", spawned_by="parent3f") |
| 212 | |
| 213 | commits = await _get_identity_repo_commits(db_session, "parent3f") |
| 214 | manifest = await _read_manifest_from_commit(db_session, commits[-1]) |
| 215 | rel_path = relationship_path("parent3f", "spawns", "agent3f") |
| 216 | raw = await _read_object_bytes(db_session, manifest[rel_path]) |
| 217 | record = json.loads(raw) |
| 218 | |
| 219 | assert record["to_handle"] == "agent3f" |
| 220 | |
| 221 | async def test_relationship_record_edge_type_is_spawns( |
| 222 | self, db_session: AsyncSession |
| 223 | ) -> None: |
| 224 | await _register_human(db_session, "parent3g") |
| 225 | await _spawn_agent(db_session, "agent3g", spawned_by="parent3g") |
| 226 | |
| 227 | commits = await _get_identity_repo_commits(db_session, "parent3g") |
| 228 | manifest = await _read_manifest_from_commit(db_session, commits[-1]) |
| 229 | rel_path = relationship_path("parent3g", "spawns", "agent3g") |
| 230 | raw = await _read_object_bytes(db_session, manifest[rel_path]) |
| 231 | record = json.loads(raw) |
| 232 | |
| 233 | assert record["edge_type"] == "spawns", ( |
| 234 | f"Expected edge_type='spawns', got {record['edge_type']!r}." |
| 235 | ) |
| 236 | |
| 237 | |
| 238 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 239 | # 3. No-op when parent has no identity repo |
| 240 | # ═══════════════════════════════════════════════════════════════════════════════ |
| 241 | |
| 242 | |
| 243 | class TestSpawnsNoOpWhenParentMissing: |
| 244 | async def test_spawning_without_parent_identity_repo_does_not_raise( |
| 245 | self, db_session: AsyncSession |
| 246 | ) -> None: |
| 247 | """If the parent has no identity repo (pre-migration user), spawning must |
| 248 | not raise — the SPAWNS commit is simply skipped.""" |
| 249 | # Spawn an agent whose parent ("ghost-parent") has never registered |
| 250 | # and therefore has no identity repo. |
| 251 | _, pub = _keypair() |
| 252 | pub_b64 = encode_pubkey("ed25519", pub) |
| 253 | fp = key_fingerprint(pub) |
| 254 | |
| 255 | # Must not raise. |
| 256 | await register_agent_identity( |
| 257 | session=db_session, |
| 258 | handle="agent3h", |
| 259 | public_key_b64=pub_b64, |
| 260 | fingerprint=fp, |
| 261 | algorithm="ed25519", |
| 262 | spawned_by="ghost-parent", |
| 263 | ) |
| 264 | |
| 265 | # Agent's own repo still created correctly. |
| 266 | result = await db_session.execute( |
| 267 | select(db.MusehubRepo).where( |
| 268 | db.MusehubRepo.owner == "agent3h", |
| 269 | db.MusehubRepo.slug == "identity", |
| 270 | ) |
| 271 | ) |
| 272 | assert result.scalar_one_or_none() is not None |
File History
1 commit
sha256:a34090cc4a394a78bd72cbbe34b08cc59525141e19135b6c0ab154f10611b9ef
debug(push/stream): instrument O-frame decode path with INF…
Sonnet 4.6
patch
123 days ago