gabriel / musehub public
test_identity_repo_phase3.py python
272 lines 11.3 KB
Raw
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