"""Phase 3 — Agent SPAWNS relationship committed to parent's identity repo. TDD regression suite: every test starts RED and turns GREEN as the feature is implemented. Their permanent role is to prevent regressions. What this phase covers: - register_agent_identity() commits a RelationshipRecord to the parent's identity repo (not the agent's) - The relationship file path is relationships/{parent}--spawns--{agent}.json - The RelationshipRecord has correct from_handle, to_handle, edge_type - The parent's identity repo gains exactly one new commit per agent spawned - The agent's own identity repo is NOT modified (SPAWNS lives on the parent) - No-op when the parent has no identity repo (migration-period safety) """ from __future__ import annotations import json import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from muse.core.types import encode_pubkey, encode_sig from muse.plugins.identity.records import relationship_path from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from musehub.crypto.keys import key_fingerprint from musehub.db.musehub_repo_models import MusehubCommit, MusehubCommitRef, MusehubObject, MusehubRepo, MusehubSnapshot from musehub.services.musehub_auth import ( create_challenge, register_agent_identity, verify_and_authenticate, ) from musehub.types.json_types import JSONObject # ── helpers ─────────────────────────────────────────────────────────────────── def _keypair() -> tuple[Ed25519PrivateKey, bytes]: priv = Ed25519PrivateKey.generate() pub = priv.public_key().public_bytes_raw() return priv, pub def _sign_nonce(priv: Ed25519PrivateKey, nonce_hex: str) -> str: sig_bytes = priv.sign(bytes.fromhex(nonce_hex)) return encode_sig("ed25519", sig_bytes) async def _register_human( session: AsyncSession, handle: str ) -> tuple[str, str]: """Register a human identity; returns (identity_id, public_key_b64).""" priv, pub = _keypair() pub_b64 = encode_pubkey("ed25519", pub) fp = key_fingerprint(pub) nonce = await create_challenge(session, fingerprint=fp, algorithm="ed25519") sig = _sign_nonce(priv, nonce) result = await verify_and_authenticate( session=session, challenge_token=nonce, public_key_b64=pub_b64, signature_b64=sig, handle=handle, display_name=handle, label="key", ) return result.identity_id, pub_b64 async def _spawn_agent( session: AsyncSession, agent_handle: str, spawned_by: str ) -> None: """Register a fresh agent identity under a given parent.""" _, pub = _keypair() pub_b64 = encode_pubkey("ed25519", pub) fp = key_fingerprint(pub) await register_agent_identity( session=session, handle=agent_handle, public_key_b64=pub_b64, fingerprint=fp, algorithm="ed25519", spawned_by=spawned_by, ) async def _get_identity_repo_commits( session: AsyncSession, owner: str ) -> list[MusehubCommit]: repo_result = await session.execute( select(MusehubRepo).where( MusehubRepo.owner == owner, MusehubRepo.slug == "identity", ) ) repo = repo_result.scalar_one() commits_result = await session.execute( select(MusehubCommit) .join(MusehubCommitRef, MusehubCommitRef.commit_id == MusehubCommit.commit_id) .where( MusehubCommitRef.repo_id == repo.repo_id, MusehubCommit.branch == "main", ).order_by(MusehubCommit.timestamp) ) return list(commits_result.scalars().all()) async def _read_manifest_from_commit( session: AsyncSession, commit: MusehubCommit ) -> JSONObject: import msgpack snap = await session.get(MusehubSnapshot, commit.snapshot_id) assert snap is not None return msgpack.unpackb(snap.manifest_blob, raw=False) async def _read_object_bytes(session: AsyncSession, object_id: str) -> bytes: from musehub.storage.backends import read_object_bytes obj = await session.get(MusehubObject, object_id) assert obj is not None raw = await read_object_bytes(obj) assert raw is not None return raw # ═══════════════════════════════════════════════════════════════════════════════ # 1. SPAWNS relationship committed to parent's identity repo # ═══════════════════════════════════════════════════════════════════════════════ class TestSpawnsRelationshipCreated: async def test_spawning_adds_commit_to_parent_identity_repo( self, db_session: AsyncSession ) -> None: """register_agent_identity must add a new commit to the parent's identity repo.""" await _register_human(db_session, "parent3a") await _spawn_agent(db_session, "agent3a", spawned_by="parent3a") commits = await _get_identity_repo_commits(db_session, "parent3a") assert len(commits) == 2, ( f"Expected 2 commits on parent's identity repo after spawning an agent, " f"got {len(commits)}." ) async def test_spawns_commit_contains_relationship_file( self, db_session: AsyncSession ) -> None: """The SPAWNS commit must include the relationship file at the correct path.""" await _register_human(db_session, "parent3b") await _spawn_agent(db_session, "agent3b", spawned_by="parent3b") commits = await _get_identity_repo_commits(db_session, "parent3b") manifest = await _read_manifest_from_commit(db_session, commits[-1]) expected_path = relationship_path("parent3b", "spawns", "agent3b") assert expected_path in manifest, ( f"Expected {expected_path!r} in parent's identity repo manifest, " f"got keys: {list(manifest)!r}." ) async def test_spawning_two_agents_adds_two_commits_to_parent( self, db_session: AsyncSession ) -> None: """Each agent spawned produces its own commit on the parent's identity repo.""" await _register_human(db_session, "parent3c") await _spawn_agent(db_session, "agent3c1", spawned_by="parent3c") await _spawn_agent(db_session, "agent3c2", spawned_by="parent3c") commits = await _get_identity_repo_commits(db_session, "parent3c") assert len(commits) == 3, ( f"Expected 3 commits (initial + 2 spawns), got {len(commits)}." ) async def test_agent_own_identity_repo_unchanged( self, db_session: AsyncSession ) -> None: """The SPAWNS relationship lives on the parent — the agent's own repo must have exactly 1 commit (its initial registration only).""" await _register_human(db_session, "parent3d") await _spawn_agent(db_session, "agent3d", spawned_by="parent3d") agent_commits = await _get_identity_repo_commits(db_session, "agent3d") assert len(agent_commits) == 1, ( f"Agent's own identity repo should have only 1 commit (initial registration), " f"got {len(agent_commits)}." ) # ═══════════════════════════════════════════════════════════════════════════════ # 2. RelationshipRecord content is correct # ═══════════════════════════════════════════════════════════════════════════════ class TestRelationshipRecordContent: async def test_relationship_record_from_handle( self, db_session: AsyncSession ) -> None: await _register_human(db_session, "parent3e") await _spawn_agent(db_session, "agent3e", spawned_by="parent3e") commits = await _get_identity_repo_commits(db_session, "parent3e") manifest = await _read_manifest_from_commit(db_session, commits[-1]) rel_path = relationship_path("parent3e", "spawns", "agent3e") raw = await _read_object_bytes(db_session, manifest[rel_path]) record = json.loads(raw) assert record["from_handle"] == "parent3e" async def test_relationship_record_to_handle( self, db_session: AsyncSession ) -> None: await _register_human(db_session, "parent3f") await _spawn_agent(db_session, "agent3f", spawned_by="parent3f") commits = await _get_identity_repo_commits(db_session, "parent3f") manifest = await _read_manifest_from_commit(db_session, commits[-1]) rel_path = relationship_path("parent3f", "spawns", "agent3f") raw = await _read_object_bytes(db_session, manifest[rel_path]) record = json.loads(raw) assert record["to_handle"] == "agent3f" async def test_relationship_record_edge_type_is_spawns( self, db_session: AsyncSession ) -> None: await _register_human(db_session, "parent3g") await _spawn_agent(db_session, "agent3g", spawned_by="parent3g") commits = await _get_identity_repo_commits(db_session, "parent3g") manifest = await _read_manifest_from_commit(db_session, commits[-1]) rel_path = relationship_path("parent3g", "spawns", "agent3g") raw = await _read_object_bytes(db_session, manifest[rel_path]) record = json.loads(raw) assert record["edge_type"] == "spawns", ( f"Expected edge_type='spawns', got {record['edge_type']!r}." ) # ═══════════════════════════════════════════════════════════════════════════════ # 3. No-op when parent has no identity repo # ═══════════════════════════════════════════════════════════════════════════════ class TestSpawnsNoOpWhenParentMissing: async def test_spawning_without_parent_identity_repo_does_not_raise( self, db_session: AsyncSession ) -> None: """If the parent has no identity repo (pre-migration user), spawning must not raise — the SPAWNS commit is simply skipped.""" # Spawn an agent whose parent ("ghost-parent") has never registered # and therefore has no identity repo. _, pub = _keypair() pub_b64 = encode_pubkey("ed25519", pub) fp = key_fingerprint(pub) # Must not raise. await register_agent_identity( session=db_session, handle="agent3h", public_key_b64=pub_b64, fingerprint=fp, algorithm="ed25519", spawned_by="ghost-parent", ) # Agent's own repo still created correctly. result = await db_session.execute( select(MusehubRepo).where( MusehubRepo.owner == "agent3h", MusehubRepo.slug == "identity", ) ) assert result.scalar_one_or_none() is not None