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