gabriel / muse public
test_hd_keygen_unified.py python
538 lines 22.8 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 151 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."""
175 _, result = _keygen(monkeypatch, tmp_path)
176 assert result.exit_code == 0
177 all_text = result.output
178 mnemonic_line = None
179 for line in all_text.splitlines():
180 words = line.strip().split()
181 if len(words) == 24 and all(w.isalpha() for w in words):
182 mnemonic_line = line.strip()
183 break
184 assert mnemonic_line is not None, f"No 24-word line found:\n{all_text}"
185 assert validate_mnemonic(mnemonic_line)
186
187 def test_json_no_mnemonic_in_stdout(
188 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
189 ) -> None:
190 """Mnemonic is sensitive — must never appear in JSON stdout."""
191 _, result = _keygen(monkeypatch, tmp_path, ["--json"])
192 payload = json.loads(result.output.splitlines()[0])
193 assert "mnemonic" not in payload
194
195 def test_json_mnemonic_word_count_24(
196 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
197 ) -> None:
198 _, result = _keygen(monkeypatch, tmp_path, ["--json"])
199 payload = json.loads(result.output.splitlines()[0])
200 assert payload.get("mnemonic_word_count") == 24
201
202 def test_identity_toml_has_no_key_source(
203 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
204 ) -> None:
205 import tomllib
206 fake_home, result = _keygen(monkeypatch, tmp_path)
207 assert result.exit_code == 0
208 data = tomllib.loads((fake_home / ".muse" / "identity.toml").read_text())
209 assert "key_source" not in data[_HOSTNAME]
210
211 def test_force_overwrites_existing(
212 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
213 ) -> None:
214 _keygen(monkeypatch, tmp_path)
215 _, result = _keygen(monkeypatch, tmp_path, ["--force"])
216 assert result.exit_code == 0
217
218 def test_no_force_rejects_existing(
219 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
220 ) -> None:
221 _keygen(monkeypatch, tmp_path)
222 _, result = _keygen(monkeypatch, tmp_path) # second time, no --force
223 assert result.exit_code != 0
224
225 def test_strength_128_gives_12_words(
226 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
227 ) -> None:
228 _, result = _keygen(monkeypatch, tmp_path, ["--strength", "128", "--json"])
229 assert result.exit_code == 0
230 payload = json.loads(result.output.splitlines()[0])
231 assert payload["mnemonic_word_count"] == 12
232
233
234 # ---------------------------------------------------------------------------
235 # Agent keygen — derived from operator's mnemonic
236 # ---------------------------------------------------------------------------
237
238
239 class TestAgentKeygen:
240 """Agent keys must be derived from the operator's HD mnemonic."""
241
242 def _setup_operator(
243 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
244 ) -> pathlib.Path:
245 """Generate a human (operator) key first."""
246 fake_home = _patch_home(monkeypatch, tmp_path)
247 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB])
248 assert result.exit_code == 0, f"Operator keygen failed:\n{result.output}"
249 return fake_home
250
251 def test_agent_keygen_exits_zero(
252 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
253 ) -> None:
254 self._setup_operator(monkeypatch, tmp_path)
255 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
256 assert result.exit_code == 0, result.output
257
258 def test_agent_pem_at_expected_path(
259 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
260 ) -> None:
261 fake_home = self._setup_operator(monkeypatch, tmp_path)
262 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
263 pem = fake_home / ".muse" / "keys" / "localhost_10003__bot-alpha.pem"
264 assert pem.is_file(), f"Agent PEM not found at {pem}"
265
266 def test_agent_pem_mode_600(
267 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
268 ) -> None:
269 fake_home = self._setup_operator(monkeypatch, tmp_path)
270 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
271 pem = fake_home / ".muse" / "keys" / "localhost_10003__bot-alpha.pem"
272 mode = pem.stat().st_mode & 0o777
273 assert mode == 0o600
274
275 def test_agent_json_has_hd_path(
276 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
277 ) -> None:
278 self._setup_operator(monkeypatch, tmp_path)
279 result = runner.invoke(
280 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
281 )
282 assert result.exit_code == 0, result.output
283 payload = json.loads(result.output.splitlines()[0])
284 assert "hd_path" in payload
285 assert str(MUSE_PURPOSE) in payload["hd_path"]
286
287 def test_agent_json_has_provisioned_by(
288 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
289 ) -> None:
290 self._setup_operator(monkeypatch, tmp_path)
291 result = runner.invoke(
292 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
293 )
294 payload = json.loads(result.output.splitlines()[0])
295 assert "provisioned_by_fingerprint" in payload
296 assert len(payload["provisioned_by_fingerprint"]) == 64 # SHA-256 hex
297
298 def test_agent_key_different_from_human_key(
299 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
300 ) -> None:
301 fake_home = self._setup_operator(monkeypatch, tmp_path)
302 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
303
304 human_pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
305 agent_pem = fake_home / ".muse" / "keys" / "localhost_10003__bot-alpha.pem"
306
307 human_key = load_pem_private_key(human_pem.read_bytes(), password=None)
308 agent_key = load_pem_private_key(agent_pem.read_bytes(), password=None)
309
310 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
311 human_pub = human_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
312 agent_pub = agent_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
313 assert human_pub != agent_pub, "Agent and human keys must be distinct"
314
315 def test_two_agents_have_distinct_keys(
316 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
317 ) -> None:
318 fake_home = self._setup_operator(monkeypatch, tmp_path)
319 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
320 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-beta"])
321
322 alpha_pem = fake_home / ".muse" / "keys" / "localhost_10003__bot-alpha.pem"
323 beta_pem = fake_home / ".muse" / "keys" / "localhost_10003__bot-beta.pem"
324
325 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
326 alpha_key = load_pem_private_key(alpha_pem.read_bytes(), password=None)
327 beta_key = load_pem_private_key(beta_pem.read_bytes(), password=None)
328 alpha_pub = alpha_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
329 beta_pub = beta_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
330 assert alpha_pub != beta_pub, "Different agent handles must produce different keys"
331
332 def test_agent_keygen_without_operator_exits_nonzero(
333 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
334 ) -> None:
335 """Attempt to derive agent key before operator key is set up."""
336 _patch_home(monkeypatch, tmp_path)
337 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"])
338 assert result.exit_code != 0
339
340 def test_agent_key_deterministic(
341 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
342 ) -> None:
343 """Same operator mnemonic + same agent handle = same agent key."""
344 fake_home = self._setup_operator(monkeypatch, tmp_path)
345 result1 = runner.invoke(
346 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--json"]
347 )
348 fp1 = json.loads(result1.output.splitlines()[0])["fingerprint"]
349
350 # Re-derive: force-overwrite the agent key (same operator mnemonic on disk)
351 result2 = runner.invoke(
352 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha", "--force", "--json"]
353 )
354 fp2 = json.loads(result2.output.splitlines()[0])["fingerprint"]
355 assert fp1 == fp2, "Agent key not deterministic given same operator mnemonic + handle"
356
357
358 # ---------------------------------------------------------------------------
359 # derive_agent_sub_seed — unit tests (no CLI)
360 # ---------------------------------------------------------------------------
361
362
363 class TestDeriveAgentSubSeed:
364 """derive_agent_sub_seed must produce stable, domain-isolated sub-seeds."""
365
366 def test_returns_64_bytes(self) -> None:
367 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
368 slot = agent_id_to_slot("bot-alpha")
369 sub_seed = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
370 assert len(sub_seed) == 64
371
372 def test_deterministic(self) -> None:
373 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
374 slot = agent_id_to_slot("bot-alpha")
375 s1 = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
376 s2 = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
377 assert s1 == s2
378
379 def test_different_slots_different_sub_seeds(self) -> None:
380 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
381 slot_a = agent_id_to_slot("bot-alpha")
382 slot_b = agent_id_to_slot("bot-beta")
383 assert slot_a != slot_b
384 sub_a = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot_a)
385 sub_b = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot_b)
386 assert sub_a != sub_b
387
388 def test_different_domains_different_sub_seeds(self) -> None:
389 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
390 slot = agent_id_to_slot("bot-alpha")
391 DOMAIN_PAYMENTS = 1
392 sub_id = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
393 sub_pay = derive_agent_sub_seed(seed, DOMAIN_PAYMENTS, slot)
394 assert sub_id != sub_pay
395
396 def test_sub_seed_differs_from_parent_seed(self) -> None:
397 seed = mnemonic_to_seed(_TEST_MNEMONIC_12)
398 slot = agent_id_to_slot("bot-alpha")
399 sub = derive_agent_sub_seed(seed, DOMAIN_IDENTITY, slot)
400 assert sub != seed
401
402
403 # ---------------------------------------------------------------------------
404 # run_recover — re-derive from mnemonic
405 # ---------------------------------------------------------------------------
406
407
408 class TestRunRecover:
409 """muse auth recover must re-derive the exact same key from the mnemonic."""
410
411 def _do_recover(
412 self,
413 monkeypatch: pytest.MonkeyPatch,
414 tmp_path: pathlib.Path,
415 mnemonic: str,
416 extra_args: list[str] | None = None,
417 ) -> object:
418 fake_home = _patch_home(monkeypatch, tmp_path)
419 args = ["auth", "recover", "--hub", _HUB] + (extra_args or [])
420 return fake_home, runner.invoke(None, args, input=mnemonic)
421
422 def test_recover_exits_zero(
423 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
424 ) -> None:
425 _, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force"])
426 assert result.exit_code == 0, result.output
427
428 def test_recover_writes_pem(
429 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
430 ) -> None:
431 fake_home, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force"])
432 assert result.exit_code == 0
433 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
434 assert pem.is_file()
435
436 def test_recover_produces_same_fingerprint_as_keygen(
437 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
438 ) -> None:
439 """Key recovered from mnemonic must match the original keygen fingerprint."""
440 import muse.core.bip39 as bip39_mod
441
442 fixed_mnemonic = _TEST_MNEMONIC_12
443 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic)
444
445 # Keygen
446 fake_home = _patch_home(monkeypatch, tmp_path)
447 keygen_result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"])
448 assert keygen_result.exit_code == 0, keygen_result.output
449 keygen_fp = json.loads(keygen_result.output.splitlines()[0])["fingerprint"]
450
451 # Recover into same tmpdir (--force to overwrite)
452 recover_result = runner.invoke(
453 None,
454 ["auth", "recover", "--hub", _HUB, "--force", "--json"],
455 input=fixed_mnemonic,
456 )
457 assert recover_result.exit_code == 0, recover_result.output
458 recover_fp = json.loads(recover_result.output.splitlines()[0])["fingerprint"]
459
460 assert keygen_fp == recover_fp, \
461 f"Recovered fingerprint {recover_fp} != original {keygen_fp}"
462
463 def test_recover_invalid_mnemonic_exits_nonzero(
464 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
465 ) -> None:
466 _, result = self._do_recover(monkeypatch, tmp_path, "not valid mnemonic words here ok", ["--force"])
467 assert result.exit_code != 0
468
469 def test_recover_pem_mode_600(
470 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
471 ) -> None:
472 fake_home, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force"])
473 assert result.exit_code == 0
474 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
475 mode = pem.stat().st_mode & 0o777
476 assert mode == 0o600
477
478 def test_recover_json_has_fingerprint(
479 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
480 ) -> None:
481 _, result = self._do_recover(monkeypatch, tmp_path, _TEST_MNEMONIC_12, ["--force", "--json"])
482 assert result.exit_code == 0
483 payload = json.loads(result.output.splitlines()[0])
484 assert "fingerprint" in payload
485 assert len(payload["fingerprint"]) == 64
486
487
488 # ---------------------------------------------------------------------------
489 # Integration — full operator → agent → recover flow
490 # ---------------------------------------------------------------------------
491
492
493 class TestIntegrationFlow:
494 """Full flow: human keygen → agent keygen → recover → fingerprints match."""
495
496 def test_operator_then_agent_then_recover(
497 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
498 ) -> None:
499 import muse.core.bip39 as bip39_mod
500 # Use a fixed mnemonic so we can recover without reading from the keychain.
501 fixed_mnemonic = _TEST_MNEMONIC_12
502 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic)
503
504 _patch_home(monkeypatch, tmp_path)
505
506 # 1. Operator keygen
507 r1 = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"])
508 assert r1.exit_code == 0, r1.output
509 op_payload = json.loads(r1.output.splitlines()[0])
510 op_fp = op_payload["fingerprint"]
511
512 # 2. Agent keygen derives from the operator's mnemonic in keychain / ephemeral store
513 r2 = runner.invoke(
514 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "worker-1", "--json"]
515 )
516 assert r2.exit_code == 0, r2.output
517 agent_payload = json.loads(r2.output.splitlines()[0])
518 agent_fp = agent_payload["fingerprint"]
519 assert agent_fp != op_fp, "Agent fingerprint must differ from operator"
520
521 # 3. Recover operator key via stdin pipe (--force since PEM already exists)
522 r3 = runner.invoke(
523 None,
524 ["auth", "recover", "--hub", _HUB, "--force", "--json"],
525 input=fixed_mnemonic,
526 )
527 assert r3.exit_code == 0, r3.output
528 recovered_fp = json.loads(r3.output.splitlines()[0])["fingerprint"]
529 assert recovered_fp == op_fp, \
530 f"Recovered operator fp {recovered_fp!r} != original {op_fp!r}"
531
532 def test_slot_stability_across_keygen_invocations(self) -> None:
533 """agent_id_to_slot must return the same value before and after any keygen."""
534 handle = "production-agent-42"
535 slot_before = agent_id_to_slot(handle)
536 # Simulate "after keygen" by just calling again — slot is a pure function
537 slot_after = agent_id_to_slot(handle)
538 assert slot_before == slot_after
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 151 days ago