gabriel / muse public
test_hd_keygen_unified.py python
540 lines 22.9 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Unified TDD tests for HD-only keygen architecture.
2
3 This file validates:
4 - agent_id_to_slot: stable, deterministic, BIP32-safe slot mapping
5 - Human keygen: fresh mnemonic, no --hd flag needed (HD is the only mode)
6 - Agent keygen: derived from operator's mnemonic via derive_agent_sub_seed
7 - run_recover: re-derives same fingerprint from stored mnemonic
8 - No JBOK: generate_keypair must not exist
9 - Integration flow: keygen → agent keygen → recover round-trip
10 """
11
12 from __future__ import annotations
13
14 import base64
15 import hashlib
16 import json
17 import pathlib
18
19 import pytest
20 from cryptography.hazmat.primitives.serialization import load_pem_private_key
21
22 from tests.cli_test_helper import CliRunner
23 from muse.core import keypair as kp_module
24 from muse.core import identity as id_module
25 from muse.core.bip39 import mnemonic_to_seed, validate_mnemonic
26 from muse.core.hdkeys import (
27 DOMAIN_IDENTITY,
28 ENTITY_AGENT,
29 ENTITY_HUMAN,
30 MUSE_PURPOSE,
31 ROLE_SIGN,
32 agent_id_to_slot,
33 derive_agent_sub_seed,
34 derive_identity_key,
35 muse_path,
36 )
37
38 runner = CliRunner()
39
40 _HUB = "http://localhost:10003"
41 _HOSTNAME = "localhost:10003"
42 # A well-known BIP39 test mnemonic (abandon × 11 + about)
43 _TEST_MNEMONIC_12 = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
44
45
46 # ---------------------------------------------------------------------------
47 # Helpers
48 # ---------------------------------------------------------------------------
49
50
51 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
52 fake_home = tmp_path / "home"
53 fake_home.mkdir(parents=True, exist_ok=True)
54 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
55 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
56 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
57 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
58 return fake_home
59
60
61 def _keygen(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path,
62 extra_args: list[str] | None = None) -> tuple[pathlib.Path, object]:
63 """Run ``muse auth keygen --hub <HUB>`` and return (fake_home, result)."""
64 fake_home = _patch_home(monkeypatch, tmp_path)
65 args = ["auth", "keygen", "--hub", _HUB] + (extra_args or [])
66 result = runner.invoke(None, args)
67 return fake_home, result
68
69
70 # ---------------------------------------------------------------------------
71 # agent_id_to_slot — unit tests
72 # ---------------------------------------------------------------------------
73
74
75 class TestAgentIdToSlot:
76 """agent_id_to_slot must map handle strings to stable, valid BIP32 indices."""
77
78 def test_returns_int(self) -> None:
79 slot = agent_id_to_slot("my-agent")
80 assert isinstance(slot, int)
81
82 def test_in_valid_bip32_range(self) -> None:
83 """All slots must be in [0, 2^31 - 1] (hardened offset applied by caller)."""
84 for handle in ["alpha", "beta", "gamma-007", "a" * 100]:
85 slot = agent_id_to_slot(handle)
86 assert 0 <= slot <= 0x7FFF_FFFF, f"slot={slot} out of range for {handle!r}"
87
88 def test_deterministic(self) -> None:
89 """Same handle must always produce the same slot."""
90 handle = "agentception-abc123"
91 assert agent_id_to_slot(handle) == agent_id_to_slot(handle)
92
93 def test_distinct_handles_likely_distinct_slots(self) -> None:
94 """Different handles should not collide (SHA-256 collision resistance)."""
95 handles = ["alice", "bob", "carol", "dave", "eve", "frank"]
96 slots = [agent_id_to_slot(h) for h in handles]
97 assert len(set(slots)) == len(slots), f"Unexpected slot collision: {slots}"
98
99 def test_known_vector(self) -> None:
100 """Verify the slot for 'agentception' against a manually computed value."""
101 import hashlib as _hashlib
102 handle = "agentception"
103 digest = _hashlib.sha256(handle.encode()).digest()
104 expected = int.from_bytes(digest[:4], "big") & 0x7FFF_FFFF
105 assert agent_id_to_slot(handle) == expected
106
107 def test_empty_string_handled(self) -> None:
108 """Edge case: empty string handle should not crash."""
109 slot = agent_id_to_slot("")
110 assert 0 <= slot <= 0x7FFF_FFFF
111
112 def test_unicode_handle(self) -> None:
113 """Unicode agent handles should produce valid slots."""
114 slot = agent_id_to_slot("音楽エージェント")
115 assert 0 <= slot <= 0x7FFF_FFFF
116
117
118 # ---------------------------------------------------------------------------
119 # No JBOK — generate_keypair must not exist
120 # ---------------------------------------------------------------------------
121
122
123 class TestNoJbok:
124 """JBOK mode is deleted. generate_keypair must not exist anywhere."""
125
126 def test_generate_keypair_not_in_module(self) -> None:
127 import importlib
128 kp = importlib.import_module("muse.core.keypair")
129 assert not hasattr(kp, "generate_keypair"), \
130 "generate_keypair still exists — JBOK was not fully removed"
131
132 def test_generate_keypair_not_importable(self) -> None:
133 with pytest.raises(ImportError):
134 from muse.core.keypair import generate_keypair # noqa: F401
135
136 def test_generate_hd_keypair_exists(self) -> None:
137 from muse.core.keypair import generate_hd_keypair
138 assert callable(generate_hd_keypair)
139
140
141 # ---------------------------------------------------------------------------
142 # Human keygen — no --hd flag, 24-word default
143 # ---------------------------------------------------------------------------
144
145
146 class TestHumanKeygen:
147 """Human keygen: HD is the only mode. No --hd flag required."""
148
149 def test_exits_zero(
150 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
151 ) -> None:
152 _, result = _keygen(monkeypatch, tmp_path)
153 assert result.exit_code == 0, result.output
154
155 def test_pem_written(
156 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
157 ) -> None:
158 fake_home, result = _keygen(monkeypatch, tmp_path)
159 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
160 assert pem.is_file(), f"PEM not created. Output:\n{result.output}"
161
162 def test_pem_mode_600(
163 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
164 ) -> None:
165 fake_home, result = _keygen(monkeypatch, tmp_path)
166 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
167 assert result.exit_code == 0
168 mode = pem.stat().st_mode & 0o777
169 assert mode == 0o600, f"PEM mode is {oct(mode)}, expected 0o600"
170
171 def test_default_24_word_mnemonic(
172 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
173 ) -> None:
174 """Default strength=256 produces a 24-word mnemonic (visible on a TTY)."""
175 import muse.cli.commands.auth as auth_mod
176 monkeypatch.setattr(auth_mod, "_stderr_isatty", lambda: True)
177 _, result = _keygen(monkeypatch, tmp_path)
178 assert result.exit_code == 0
179 all_text = result.output
180 mnemonic_line = None
181 for line in all_text.splitlines():
182 words = line.strip().split()
183 if len(words) == 24 and all(w.isalpha() for w in words):
184 mnemonic_line = line.strip()
185 break
186 assert mnemonic_line is not None, f"No 24-word line found:\n{all_text}"
187 assert validate_mnemonic(mnemonic_line)
188
189 def test_json_no_mnemonic_in_stdout(
190 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
191 ) -> None:
192 """Mnemonic is sensitive — must never appear in JSON stdout."""
193 _, result = _keygen(monkeypatch, tmp_path, ["--json"])
194 payload = json.loads(result.output.splitlines()[0])
195 assert "mnemonic" not in payload
196
197 def test_json_mnemonic_word_count_24(
198 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
199 ) -> None:
200 _, result = _keygen(monkeypatch, tmp_path, ["--json"])
201 payload = json.loads(result.output.splitlines()[0])
202 assert payload.get("mnemonic_word_count") == 24
203
204 def test_identity_toml_has_no_key_source(
205 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
206 ) -> None:
207 import tomllib
208 fake_home, result = _keygen(monkeypatch, tmp_path)
209 assert result.exit_code == 0
210 data = tomllib.loads((fake_home / ".muse" / "identity.toml").read_text())
211 assert "key_source" not in data[_HOSTNAME]
212
213 def test_force_overwrites_existing(
214 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
215 ) -> None:
216 _keygen(monkeypatch, tmp_path)
217 _, result = _keygen(monkeypatch, tmp_path, ["--force"])
218 assert result.exit_code == 0
219
220 def test_no_force_rejects_existing(
221 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
222 ) -> None:
223 _keygen(monkeypatch, tmp_path)
224 _, result = _keygen(monkeypatch, tmp_path) # second time, no --force
225 assert result.exit_code != 0
226
227 def test_strength_128_gives_12_words(
228 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
229 ) -> None:
230 _, result = _keygen(monkeypatch, tmp_path, ["--strength", "128", "--json"])
231 assert result.exit_code == 0
232 payload = json.loads(result.output.splitlines()[0])
233 assert payload["mnemonic_word_count"] == 12
234
235
236 # ---------------------------------------------------------------------------
237 # Agent keygen — derived from operator's mnemonic
238 # ---------------------------------------------------------------------------
239
240
241 class TestAgentKeygen:
242 """Agent keys must be derived from the operator's HD mnemonic."""
243
244 def _setup_operator(
245 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
246 ) -> pathlib.Path:
247 """Generate a human (operator) key first."""
248 fake_home = _patch_home(monkeypatch, tmp_path)
249 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB])
250 assert result.exit_code == 0, f"Operator keygen failed:\n{result.output}"
251 return fake_home
252
253 def test_agent_keygen_exits_zero(
254 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
255 ) -> None:
256 self._setup_operator(monkeypatch, tmp_path)
257 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
258 assert result.exit_code == 0, result.output
259
260 def test_agent_pem_at_expected_path(
261 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
262 ) -> None:
263 fake_home = self._setup_operator(monkeypatch, tmp_path)
264 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
265 pem = fake_home / ".muse" / "keys" / "localhost_10003__bot-alpha.pem"
266 assert pem.is_file(), f"Agent PEM not found at {pem}"
267
268 def test_agent_pem_mode_600(
269 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
270 ) -> None:
271 fake_home = self._setup_operator(monkeypatch, tmp_path)
272 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
273 pem = fake_home / ".muse" / "keys" / "localhost_10003__bot-alpha.pem"
274 mode = pem.stat().st_mode & 0o777
275 assert mode == 0o600
276
277 def test_agent_json_has_hd_path(
278 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
279 ) -> None:
280 self._setup_operator(monkeypatch, tmp_path)
281 result = runner.invoke(
282 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
283 )
284 assert result.exit_code == 0, result.output
285 payload = json.loads(result.output.splitlines()[0])
286 assert "hd_path" in payload
287 assert str(MUSE_PURPOSE) in payload["hd_path"]
288
289 def test_agent_json_has_provisioned_by(
290 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
291 ) -> None:
292 self._setup_operator(monkeypatch, tmp_path)
293 result = runner.invoke(
294 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
295 )
296 payload = json.loads(result.output.splitlines()[0])
297 assert "provisioned_by_fingerprint" in payload
298 assert len(payload["provisioned_by_fingerprint"]) == 64 # SHA-256 hex
299
300 def test_agent_key_different_from_human_key(
301 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
302 ) -> None:
303 fake_home = self._setup_operator(monkeypatch, tmp_path)
304 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
305
306 human_pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
307 agent_pem = fake_home / ".muse" / "keys" / "localhost_10003__bot-alpha.pem"
308
309 human_key = load_pem_private_key(human_pem.read_bytes(), password=None)
310 agent_key = load_pem_private_key(agent_pem.read_bytes(), password=None)
311
312 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
313 human_pub = human_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
314 agent_pub = agent_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
315 assert human_pub != agent_pub, "Agent and human keys must be distinct"
316
317 def test_two_agents_have_distinct_keys(
318 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
319 ) -> None:
320 fake_home = self._setup_operator(monkeypatch, tmp_path)
321 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
322 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-beta"])
323
324 alpha_pem = fake_home / ".muse" / "keys" / "localhost_10003__bot-alpha.pem"
325 beta_pem = fake_home / ".muse" / "keys" / "localhost_10003__bot-beta.pem"
326
327 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
328 alpha_key = load_pem_private_key(alpha_pem.read_bytes(), password=None)
329 beta_key = load_pem_private_key(beta_pem.read_bytes(), password=None)
330 alpha_pub = alpha_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
331 beta_pub = beta_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
332 assert alpha_pub != beta_pub, "Different agent handles must produce different keys"
333
334 def test_agent_keygen_without_operator_exits_nonzero(
335 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
336 ) -> None:
337 """Attempt to derive agent key before operator key is set up."""
338 _patch_home(monkeypatch, tmp_path)
339 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
340 assert result.exit_code != 0
341
342 def test_agent_key_deterministic(
343 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
344 ) -> None:
345 """Same operator mnemonic + same agent handle = same agent key."""
346 fake_home = self._setup_operator(monkeypatch, tmp_path)
347 result1 = runner.invoke(
348 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
349 )
350 fp1 = json.loads(result1.output.splitlines()[0])["fingerprint"]
351
352 # Re-derive: force-overwrite the agent key (same operator mnemonic on disk)
353 result2 = runner.invoke(
354 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--force", "--json"]
355 )
356 fp2 = json.loads(result2.output.splitlines()[0])["fingerprint"]
357 assert fp1 == fp2, "Agent key not deterministic given same operator mnemonic + handle"
358
359
360 # ---------------------------------------------------------------------------
361 # derive_agent_sub_seed — unit tests (no CLI)
362 # ---------------------------------------------------------------------------
363
364
365 class TestDeriveAgentSubSeed:
366 """derive_agent_sub_seed must produce stable, domain-isolated sub-seeds."""
367
368 def test_returns_64_bytes(self) -> None:
369 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
370 slot = agent_id_to_slot("bot-alpha")
371 sub_seed = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
372 assert len(sub_seed) == 64
373
374 def test_deterministic(self) -> None:
375 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
376 slot = agent_id_to_slot("bot-alpha")
377 s1 = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
378 s2 = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
379 assert s1 == s2
380
381 def test_different_slots_different_sub_seeds(self) -> None:
382 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
383 slot_a = agent_id_to_slot("bot-alpha")
384 slot_b = agent_id_to_slot("bot-beta")
385 assert slot_a != slot_b
386 sub_a = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot_a)
387 sub_b = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot_b)
388 assert sub_a != sub_b
389
390 def test_different_domains_different_sub_seeds(self) -> None:
391 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
392 slot = agent_id_to_slot("bot-alpha")
393 DOMAIN_PAYMENTS = 1
394 sub_id = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
395 sub_pay = derive_agent_sub_seed(seed, DOMAIN_PAYMENTS, slot)
396 assert sub_id != sub_pay
397
398 def test_sub_seed_differs_from_parent_seed(self) -> None:
399 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
400 slot = agent_id_to_slot("bot-alpha")
401 sub = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
402 assert sub != seed
403
404
405 # ---------------------------------------------------------------------------
406 # run_recover — re-derive from mnemonic
407 # ---------------------------------------------------------------------------
408
409
410 class TestRunRecover:
411 """muse auth recover must re-derive the exact same key from the mnemonic."""
412
413 def _do_recover(
414 self,
415 monkeypatch: pytest.MonkeyPatch,
416 tmp_path: pathlib.Path,
417 mnemonic: str,
418 extra_args: list[str] | None = None,
419 ) -> object:
420 fake_home = _patch_home(monkeypatch, tmp_path)
421 args = ["auth", "recover", "--hub", _HUB] + (extra_args or [])
422 return fake_home, runner.invoke(None, args, input=mnemonic)
423
424 def test_recover_exits_zero(
425 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
426 ) -> None:
427 _, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force"])
428 assert result.exit_code == 0, result.output
429
430 def test_recover_writes_pem(
431 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
432 ) -> None:
433 fake_home, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force"])
434 assert result.exit_code == 0
435 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
436 assert pem.is_file()
437
438 def test_recover_produces_same_fingerprint_as_keygen(
439 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
440 ) -> None:
441 """Key recovered from mnemonic must match the original keygen fingerprint."""
442 import muse.core.bip39 as bip39_mod
443
444 fixed_mnemonic = _TEST_MNEMONIC_12
445 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic)
446
447 # Keygen
448 fake_home = _patch_home(monkeypatch, tmp_path)
449 keygen_result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"])
450 assert keygen_result.exit_code == 0, keygen_result.output
451 keygen_fp = json.loads(keygen_result.output.splitlines()[0])["fingerprint"]
452
453 # Recover into same tmpdir (--force to overwrite)
454 recover_result = runner.invoke(
455 None,
456 ["auth", "recover", "--hub", _HUB, "--force", "--json"],
457 input=fixed_mnemonic,
458 )
459 assert recover_result.exit_code == 0, recover_result.output
460 recover_fp = json.loads(recover_result.output.splitlines()[0])["fingerprint"]
461
462 assert keygen_fp == recover_fp, \
463 f"Recovered fingerprint {recover_fp} != original {keygen_fp}"
464
465 def test_recover_invalid_mnemonic_exits_nonzero(
466 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
467 ) -> None:
468 _, result = self._do_recover(monkeypatch, tmp_path, "not valid mnemonic words here ok", ["--force"])
469 assert result.exit_code != 0
470
471 def test_recover_pem_mode_600(
472 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
473 ) -> None:
474 fake_home, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force"])
475 assert result.exit_code == 0
476 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
477 mode = pem.stat().st_mode & 0o777
478 assert mode == 0o600
479
480 def test_recover_json_has_fingerprint(
481 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
482 ) -> None:
483 _, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force", "--json"])
484 assert result.exit_code == 0
485 payload = json.loads(result.output.splitlines()[0])
486 assert "fingerprint" in payload
487 assert len(payload["fingerprint"]) == 64
488
489
490 # ---------------------------------------------------------------------------
491 # Integration — full operator → agent → recover flow
492 # ---------------------------------------------------------------------------
493
494
495 class TestIntegrationFlow:
496 """Full flow: human keygen → agent keygen → recover → fingerprints match."""
497
498 def test_operator_then_agent_then_recover(
499 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
500 ) -> None:
501 import muse.core.bip39 as bip39_mod
502 # Use a fixed mnemonic so we can recover without reading from the keychain.
503 fixed_mnemonic = _TEST_MNEMONIC_12
504 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic)
505
506 _patch_home(monkeypatch, tmp_path)
507
508 # 1. Operator keygen
509 r1 = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"])
510 assert r1.exit_code == 0, r1.output
511 op_payload = json.loads(r1.output.splitlines()[0])
512 op_fp = op_payload["fingerprint"]
513
514 # 2. Agent keygen derives from the operator's mnemonic in keychain / ephemeral store
515 r2 = runner.invoke(
516 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "worker-1", "--json"]
517 )
518 assert r2.exit_code == 0, r2.output
519 agent_payload = json.loads(r2.output.splitlines()[0])
520 agent_fp = agent_payload["fingerprint"]
521 assert agent_fp != op_fp, "Agent fingerprint must differ from operator"
522
523 # 3. Recover operator key via stdin pipe (--force since PEM already exists)
524 r3 = runner.invoke(
525 None,
526 ["auth", "recover", "--hub", _HUB, "--force", "--json"],
527 input=fixed_mnemonic,
528 )
529 assert r3.exit_code == 0, r3.output
530 recovered_fp = json.loads(r3.output.splitlines()[0])["fingerprint"]
531 assert recovered_fp == op_fp, \
532 f"Recovered operator fp {recovered_fp!r} != original {op_fp!r}"
533
534 def test_slot_stability_across_keygen_invocations(self) -> None:
535 """agent_id_to_slot must return the same value before and after any keygen."""
536 handle = "production-agent-42"
537 slot_before = agent_id_to_slot(handle)
538 # Simulate "after keygen" by just calling again — slot is a pure function
539 slot_after = agent_id_to_slot(handle)
540 assert slot_before == slot_after
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago