gabriel / muse public
test_cmd_agent.py python
846 lines 29.4 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Comprehensive tests for ``muse agent`` CLI commands.
2
3 Covers all eight required categories:
4 1. Unit — pure helper functions (_derive_agent_seed, _fingerprint, etc.)
5 2. Integration — run_keygen / run_list / run_register with a real (tmp) identity store
6 3. E2E — full CLI via CliRunner
7 4. Stress — many accounts, repeated derivation
8 5. Data integrity — determinism, isolation between accounts
9 6. Performance — keygen completes within budget
10 7. Security — negative accounts rejected, symlink guard, no mnemonic in output
11 8. Docstrings — all public callables are documented
12 """
13
14 from __future__ import annotations
15
16 import base64
17 import hashlib
18 import json
19 import pathlib
20 import time
21 from typing import Any
22
23 import pytest
24 from tests.cli_test_helper import CliRunner
25
26 cli = None # argparse migration — CliRunner ignores this arg
27 runner = CliRunner()
28
29 # ---------------------------------------------------------------------------
30 # Constants — fixed test mnemonic (never used in production)
31 # ---------------------------------------------------------------------------
32
33 _TEST_MNEMONIC = (
34 "abandon abandon abandon abandon abandon abandon abandon abandon "
35 "abandon abandon abandon about"
36 )
37 _TEST_HUB = "http://localhost:10003"
38 _TEST_HOSTNAME = "localhost:10003"
39
40
41 # ---------------------------------------------------------------------------
42 # Fixtures
43 # ---------------------------------------------------------------------------
44
45
46 @pytest.fixture()
47 def isolated_identity(
48 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
49 ) -> pathlib.Path:
50 """Redirect identity store to tmp_path so tests never touch ~/.muse/identity.toml."""
51 fake_dir = tmp_path / "dot_muse"
52 fake_dir.mkdir()
53 fake_file = fake_dir / "identity.toml"
54 keys_dir = fake_dir / "keys"
55 keys_dir.mkdir()
56
57 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir)
58 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_file)
59
60 return fake_dir
61
62
63 @pytest.fixture()
64 def isolated_slots(
65 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
66 ) -> pathlib.Path:
67 """Redirect agent-slots store to tmp_path."""
68 fake_dir = tmp_path / "dot_muse_slots"
69 fake_dir.mkdir()
70 fake_file = fake_dir / "agent-slots.toml"
71
72 monkeypatch.setattr("muse.core.agent_slots._SLOTS_DIR", fake_dir)
73 monkeypatch.setattr("muse.core.agent_slots._SLOTS_FILE", fake_file)
74
75 return fake_dir
76
77
78 @pytest.fixture()
79 def identity_with_mnemonic(
80 isolated_identity: pathlib.Path, monkeypatch: pytest.MonkeyPatch
81 ) -> None:
82 """Save a test identity entry backed by an in-memory keychain."""
83 from muse.core.identity import IdentityEntry, save_identity
84
85 # Patch keychain to an in-memory store so the mnemonic survives the
86 # save_identity → load_identity round-trip without touching the real OS keychain.
87 _kc: dict[str, str] = {}
88 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
89 monkeypatch.setattr("muse.core.keychain.store", lambda hub, m: _kc.__setitem__(hub, m))
90 monkeypatch.setattr("muse.core.keychain.load", lambda hub: _kc.get(hub))
91
92 entry: IdentityEntry = {
93 "type": "human",
94 "handle": "gabriel",
95 "hd_path": "m/1075233755'/0'/0'/0'/0'/0'",
96 }
97 save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC)
98
99
100 @pytest.fixture()
101 def repo_with_hub(
102 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
103 ) -> pathlib.Path:
104 """Minimal .muse/ repo with hub configured so --hub can be omitted."""
105 muse_dir = tmp_path / ".muse"
106 muse_dir.mkdir()
107 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
108 (muse_dir / "refs" / "heads").mkdir(parents=True)
109 (muse_dir / "objects").mkdir()
110 (muse_dir / "commits").mkdir()
111 (muse_dir / "snapshots").mkdir()
112 (muse_dir / "config.toml").write_text(
113 f'[hub]\nurl = "{_TEST_HUB}"\n', encoding="utf-8"
114 )
115 monkeypatch.chdir(tmp_path)
116 return tmp_path
117
118
119 # ---------------------------------------------------------------------------
120 # 1. Unit — pure helpers
121 # ---------------------------------------------------------------------------
122
123
124 class TestDeriveAgentSeed:
125 """Unit tests for _derive_agent_seed."""
126
127 def test_returns_64_bytes(self) -> None:
128 from muse.cli.commands.agent import _derive_agent_seed
129 result = _derive_agent_seed(_TEST_MNEMONIC, 0)
130 assert len(result) == 64
131
132 def test_is_bytes(self) -> None:
133 from muse.cli.commands.agent import _derive_agent_seed
134 result = _derive_agent_seed(_TEST_MNEMONIC, 1)
135 assert isinstance(result, bytes)
136
137 def test_different_accounts_produce_different_seeds(self) -> None:
138 from muse.cli.commands.agent import _derive_agent_seed
139 s0 = _derive_agent_seed(_TEST_MNEMONIC, 0)
140 s1 = _derive_agent_seed(_TEST_MNEMONIC, 1)
141 assert s0 != s1
142
143 def test_same_account_is_deterministic(self) -> None:
144 from muse.cli.commands.agent import _derive_agent_seed
145 s_a = _derive_agent_seed(_TEST_MNEMONIC, 5)
146 s_b = _derive_agent_seed(_TEST_MNEMONIC, 5)
147 assert s_a == s_b
148
149 def test_different_mnemonics_produce_different_seeds(self) -> None:
150 from muse.cli.commands.agent import _derive_agent_seed
151 mnemonic2 = (
152 "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
153 )
154 s1 = _derive_agent_seed(_TEST_MNEMONIC, 0)
155 s2 = _derive_agent_seed(mnemonic2, 0)
156 assert s1 != s2
157
158
159 class TestSubSeedToPublic:
160 """Unit tests for _sub_seed_to_public."""
161
162 def test_returns_32_bytes(self) -> None:
163 from muse.cli.commands.agent import _derive_agent_seed, _sub_seed_to_public
164 sub_seed = _derive_agent_seed(_TEST_MNEMONIC, 0)
165 pub = _sub_seed_to_public(sub_seed)
166 assert len(pub) == 32
167
168 def test_deterministic(self) -> None:
169 from muse.cli.commands.agent import _derive_agent_seed, _sub_seed_to_public
170 sub_seed = _derive_agent_seed(_TEST_MNEMONIC, 0)
171 assert _sub_seed_to_public(sub_seed) == _sub_seed_to_public(sub_seed)
172
173 def test_different_seeds_different_pubkeys(self) -> None:
174 from muse.cli.commands.agent import _derive_agent_seed, _sub_seed_to_public
175 s0 = _derive_agent_seed(_TEST_MNEMONIC, 0)
176 s1 = _derive_agent_seed(_TEST_MNEMONIC, 1)
177 assert _sub_seed_to_public(s0) != _sub_seed_to_public(s1)
178
179
180 class TestFingerprint:
181 """Unit tests for _fingerprint."""
182
183 def test_returns_hex_string(self) -> None:
184 from muse.cli.commands.agent import _fingerprint
185 fp = _fingerprint(b"\x00" * 32)
186 assert isinstance(fp, str)
187 assert all(c in "0123456789abcdef" for c in fp)
188
189 def test_sha256_of_input(self) -> None:
190 from muse.cli.commands.agent import _fingerprint
191 data = b"test public key bytes"
192 assert _fingerprint(data) == hashlib.sha256(data).hexdigest()
193
194 def test_length_is_64(self) -> None:
195 from muse.cli.commands.agent import _fingerprint
196 fp = _fingerprint(b"\xff" * 32)
197 assert len(fp) == 64
198
199
200 class TestRequireMnemonic:
201 """Unit tests for _require_mnemonic."""
202
203 def test_returns_mnemonic_from_identity(
204 self, identity_with_mnemonic: None
205 ) -> None:
206 from muse.cli.commands.agent import _require_mnemonic
207 result = _require_mnemonic(_TEST_HUB)
208 assert result == _TEST_MNEMONIC
209
210 def test_raises_when_no_identity(self, isolated_identity: pathlib.Path) -> None:
211 from muse.cli.commands.agent import _require_mnemonic
212 with pytest.raises(SystemExit) as exc_info:
213 _require_mnemonic(_TEST_HUB)
214 assert exc_info.value.code == 1
215
216 def test_raises_when_identity_has_no_mnemonic(
217 self, isolated_identity: pathlib.Path, monkeypatch: pytest.MonkeyPatch
218 ) -> None:
219 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
220 from muse.core.identity import IdentityEntry, save_identity
221 from muse.cli.commands.agent import _require_mnemonic
222 entry: IdentityEntry = {"type": "human", "handle": "gabriel"}
223 save_identity(_TEST_HUB, entry)
224 with pytest.raises(SystemExit) as exc_info:
225 _require_mnemonic(_TEST_HUB)
226 assert exc_info.value.code == 1
227
228
229 class TestResolveHubUrl:
230 """Unit tests for _resolve_hub_url."""
231
232 def test_returns_args_hub_when_provided(self) -> None:
233 from muse.cli.commands.agent import _resolve_hub_url
234 assert _resolve_hub_url("http://localhost:9999") == "http://localhost:9999"
235
236 def test_raises_when_no_hub(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
237 from muse.cli.commands.agent import _resolve_hub_url
238 monkeypatch.chdir(tmp_path)
239 with pytest.raises(SystemExit) as exc_info:
240 _resolve_hub_url(None)
241 assert exc_info.value.code == 1
242
243 def test_reads_from_repo_config(
244 self, repo_with_hub: pathlib.Path
245 ) -> None:
246 from muse.cli.commands.agent import _resolve_hub_url
247 url = _resolve_hub_url(None)
248 assert url == _TEST_HUB
249
250
251 # ---------------------------------------------------------------------------
252 # 2. Integration — run_keygen / run_list / run_register
253 # ---------------------------------------------------------------------------
254
255
256 class TestRunKeygen:
257 """Integration tests for run_keygen."""
258
259 def test_keygen_produces_valid_output(
260 self,
261 identity_with_mnemonic: None,
262 isolated_slots: pathlib.Path,
263 ) -> None:
264 import argparse
265 from muse.cli.commands.agent import run_keygen
266
267 args = argparse.Namespace(hub=_TEST_HUB, account=1, name=None, json=True)
268 import io, contextlib, sys
269 out = io.StringIO()
270 with contextlib.redirect_stdout(out):
271 run_keygen(args)
272
273 payload = json.loads(out.getvalue())
274 assert payload["status"] == "ok"
275 assert payload["account"] == 1
276 assert len(payload["fingerprint"]) == 64
277 # Sub-seed decodes to 64 bytes
278 seed_bytes = base64.urlsafe_b64decode(payload["hd_seed_b64"] + "==")
279 assert len(seed_bytes) == 64
280
281 def test_keygen_msign_path_format(
282 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
283 ) -> None:
284 import argparse
285 from muse.cli.commands.agent import run_keygen
286 import io, contextlib
287
288 args = argparse.Namespace(hub=_TEST_HUB, account=3, name=None, json=True)
289 out = io.StringIO()
290 with contextlib.redirect_stdout(out):
291 run_keygen(args)
292
293 payload = json.loads(out.getvalue())
294 # Path: m/purpose'/domain_identity'/entity_agent'/account'
295 assert payload["msign_path"].endswith("'/3'")
296 assert payload["msign_path"].startswith("m/")
297
298 def test_keygen_negative_account_rejected(
299 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
300 ) -> None:
301 import argparse
302 from muse.cli.commands.agent import run_keygen
303
304 args = argparse.Namespace(hub=_TEST_HUB, account=-1, name=None, json=True)
305 with pytest.raises(SystemExit) as exc_info:
306 run_keygen(args)
307 assert exc_info.value.code == 1
308
309
310 class TestRunList:
311 """Integration tests for run_list."""
312
313 def test_list_empty(
314 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
315 ) -> None:
316 import argparse
317 from muse.cli.commands.agent import run_list
318 import io, contextlib
319
320 args = argparse.Namespace(hub=_TEST_HUB, json=True)
321 out = io.StringIO()
322 with contextlib.redirect_stdout(out):
323 run_list(args)
324
325 result = json.loads(out.getvalue())
326 assert result == []
327
328 def test_list_shows_registered_slots(
329 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
330 ) -> None:
331 import argparse
332 from muse.core.agent_slots import register_slot
333 from muse.cli.commands.agent import run_list
334 import io, contextlib
335
336 register_slot(_TEST_HUB, "orchestra", 1)
337 register_slot(_TEST_HUB, "mixer", 2)
338
339 args = argparse.Namespace(hub=_TEST_HUB, json=True)
340 out = io.StringIO()
341 with contextlib.redirect_stdout(out):
342 run_list(args)
343
344 slots = json.loads(out.getvalue())
345 assert len(slots) == 2
346 names = {s["name"] for s in slots}
347 assert names == {"orchestra", "mixer"}
348
349 def test_list_sorted_by_account(
350 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
351 ) -> None:
352 import argparse
353 from muse.core.agent_slots import register_slot
354 from muse.cli.commands.agent import run_list
355 import io, contextlib
356
357 register_slot(_TEST_HUB, "b-agent", 5)
358 register_slot(_TEST_HUB, "a-agent", 2)
359
360 args = argparse.Namespace(hub=_TEST_HUB, json=True)
361 out = io.StringIO()
362 with contextlib.redirect_stdout(out):
363 run_list(args)
364
365 slots = json.loads(out.getvalue())
366 accounts = [s["account"] for s in slots]
367 assert accounts == sorted(accounts)
368
369
370 class TestRunRegister:
371 """Integration tests for run_register."""
372
373 def test_register_creates_slot(
374 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
375 ) -> None:
376 import argparse
377 from muse.cli.commands.agent import run_register, run_list
378 import io, contextlib
379
380 args = argparse.Namespace(hub=_TEST_HUB, account=1, name="orchestra", json=True)
381 out = io.StringIO()
382 with contextlib.redirect_stdout(out):
383 run_register(args)
384
385 payload = json.loads(out.getvalue())
386 assert payload["status"] == "ok"
387 assert payload["name"] == "orchestra"
388 assert payload["account"] == 1
389
390 def test_register_persists_across_calls(
391 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
392 ) -> None:
393 import argparse
394 from muse.cli.commands.agent import run_register, run_list
395 import io, contextlib
396
397 reg_args = argparse.Namespace(hub=_TEST_HUB, account=7, name="test-agent", json=False)
398 with contextlib.redirect_stdout(io.StringIO()):
399 run_register(reg_args)
400
401 list_args = argparse.Namespace(hub=_TEST_HUB, json=True)
402 out = io.StringIO()
403 with contextlib.redirect_stdout(out):
404 run_list(list_args)
405
406 slots = json.loads(out.getvalue())
407 assert any(s["name"] == "test-agent" and s["account"] == 7 for s in slots)
408
409 def test_register_negative_account_rejected(
410 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
411 ) -> None:
412 import argparse
413 from muse.cli.commands.agent import run_register
414
415 args = argparse.Namespace(hub=_TEST_HUB, account=-5, name="bad", json=False)
416 with pytest.raises(SystemExit) as exc_info:
417 run_register(args)
418 assert exc_info.value.code == 1
419
420
421 # ---------------------------------------------------------------------------
422 # 3. E2E — full CLI via CliRunner
423 # ---------------------------------------------------------------------------
424
425
426 class TestAgentKeygenE2E:
427 """End-to-end tests: muse agent keygen via CliRunner."""
428
429 def test_keygen_json_exit_0(
430 self,
431 identity_with_mnemonic: None,
432 isolated_slots: pathlib.Path,
433 ) -> None:
434 result = runner.invoke(
435 cli, ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1", "--json"]
436 )
437 assert result.exit_code == 0
438 payload = json.loads(result.stdout.split("\n")[0])
439 assert payload["status"] == "ok"
440 assert payload["account"] == 1
441
442 def test_keygen_human_readable(
443 self,
444 identity_with_mnemonic: None,
445 isolated_slots: pathlib.Path,
446 ) -> None:
447 result = runner.invoke(
448 cli, ["agent", "keygen", "--hub", _TEST_HUB, "--account", "2"]
449 )
450 assert result.exit_code == 0
451 assert "MUSE_AGENT_HD_SEED=" in result.output
452
453 def test_keygen_no_hub_and_no_config_fails(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
454 monkeypatch.chdir(tmp_path)
455 result = runner.invoke(
456 cli, ["agent", "keygen", "--account", "1", "--json"]
457 )
458 assert result.exit_code != 0
459
460 def test_keygen_no_identity_fails(
461 self,
462 isolated_identity: pathlib.Path,
463 isolated_slots: pathlib.Path,
464 ) -> None:
465 result = runner.invoke(
466 cli, ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1", "--json"]
467 )
468 assert result.exit_code != 0
469
470 def test_keygen_requires_account(
471 self,
472 identity_with_mnemonic: None,
473 isolated_slots: pathlib.Path,
474 ) -> None:
475 result = runner.invoke(
476 cli, ["agent", "keygen", "--hub", _TEST_HUB, "--json"]
477 )
478 assert result.exit_code != 0
479
480 def test_keygen_with_name_includes_name_in_json(
481 self,
482 identity_with_mnemonic: None,
483 isolated_slots: pathlib.Path,
484 ) -> None:
485 result = runner.invoke(
486 cli,
487 ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1",
488 "--name", "orchestra", "--json"],
489 )
490 assert result.exit_code == 0
491 payload = json.loads(result.stdout.split("\n")[0])
492 assert payload["name"] == "orchestra"
493
494
495 class TestAgentListE2E:
496 """End-to-end tests: muse agent list via CliRunner."""
497
498 def test_list_empty_json(
499 self,
500 identity_with_mnemonic: None,
501 isolated_slots: pathlib.Path,
502 ) -> None:
503 result = runner.invoke(
504 cli, ["agent", "list", "--hub", _TEST_HUB, "--json"]
505 )
506 assert result.exit_code == 0
507 assert json.loads(result.stdout.split("\n")[0]) == []
508
509 def test_list_after_register(
510 self,
511 identity_with_mnemonic: None,
512 isolated_slots: pathlib.Path,
513 ) -> None:
514 runner.invoke(
515 cli,
516 ["agent", "register", "--hub", _TEST_HUB,
517 "--account", "3", "--name", "my-bot", "--json"],
518 )
519 result = runner.invoke(
520 cli, ["agent", "list", "--hub", _TEST_HUB, "--json"]
521 )
522 assert result.exit_code == 0
523 slots = json.loads(result.stdout.split("\n")[0])
524 assert any(s["name"] == "my-bot" for s in slots)
525
526 def test_list_human_readable_empty(
527 self,
528 identity_with_mnemonic: None,
529 isolated_slots: pathlib.Path,
530 ) -> None:
531 result = runner.invoke(cli, ["agent", "list", "--hub", _TEST_HUB])
532 assert result.exit_code == 0
533 assert "No registered" in result.output
534
535
536 class TestAgentRegisterE2E:
537 """End-to-end tests: muse agent register via CliRunner."""
538
539 def test_register_json_ok(
540 self,
541 identity_with_mnemonic: None,
542 isolated_slots: pathlib.Path,
543 ) -> None:
544 result = runner.invoke(
545 cli,
546 ["agent", "register", "--hub", _TEST_HUB,
547 "--account", "4", "--name", "test", "--json"],
548 )
549 assert result.exit_code == 0
550 payload = json.loads(result.stdout.split("\n")[0])
551 assert payload["status"] == "ok"
552 assert payload["account"] == 4
553
554 def test_register_requires_account(
555 self,
556 identity_with_mnemonic: None,
557 isolated_slots: pathlib.Path,
558 ) -> None:
559 result = runner.invoke(
560 cli,
561 ["agent", "register", "--hub", _TEST_HUB, "--name", "test", "--json"],
562 )
563 assert result.exit_code != 0
564
565 def test_register_requires_name(
566 self,
567 identity_with_mnemonic: None,
568 isolated_slots: pathlib.Path,
569 ) -> None:
570 result = runner.invoke(
571 cli,
572 ["agent", "register", "--hub", _TEST_HUB, "--account", "1", "--json"],
573 )
574 assert result.exit_code != 0
575
576
577 # ---------------------------------------------------------------------------
578 # 4. Stress — many accounts, repeated operations
579 # ---------------------------------------------------------------------------
580
581
582 class TestStress:
583 """Stress tests — many accounts, repeated derivations."""
584
585 def test_100_different_accounts_all_unique(self) -> None:
586 from muse.cli.commands.agent import _derive_agent_seed
587 seeds = [_derive_agent_seed(_TEST_MNEMONIC, i) for i in range(100)]
588 assert len(set(seeds)) == 100
589
590 def test_repeated_derivation_consistent(self) -> None:
591 from muse.cli.commands.agent import _derive_agent_seed
592 for _ in range(50):
593 s = _derive_agent_seed(_TEST_MNEMONIC, 42)
594 assert len(s) == 64
595
596 def test_register_and_list_100_slots(
597 self,
598 identity_with_mnemonic: None,
599 isolated_slots: pathlib.Path,
600 ) -> None:
601 from muse.core.agent_slots import register_slot, list_slots
602
603 for i in range(1, 101):
604 register_slot(_TEST_HUB, f"agent-{i}", i)
605
606 slots = list_slots(_TEST_HUB)
607 assert len(slots) == 100
608 accounts = [s["account"] for s in slots]
609 assert accounts == sorted(accounts)
610
611
612 # ---------------------------------------------------------------------------
613 # 5. Data integrity — determinism and isolation
614 # ---------------------------------------------------------------------------
615
616
617 class TestDataIntegrity:
618 """Data integrity tests."""
619
620 def test_keygen_account_0_and_1_produce_different_seeds(self) -> None:
621 from muse.cli.commands.agent import _derive_agent_seed, _sub_seed_to_public
622 s0 = _derive_agent_seed(_TEST_MNEMONIC, 0)
623 s1 = _derive_agent_seed(_TEST_MNEMONIC, 1)
624 p0 = _sub_seed_to_public(s0)
625 p1 = _sub_seed_to_public(s1)
626 assert p0 != p1
627
628 def test_hd_seed_b64_decodes_to_64_bytes(
629 self,
630 identity_with_mnemonic: None,
631 isolated_slots: pathlib.Path,
632 ) -> None:
633 result = runner.invoke(
634 cli, ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1", "--json"]
635 )
636 assert result.exit_code == 0
637 payload = json.loads(result.stdout.split("\n")[0])
638 raw = base64.urlsafe_b64decode(payload["hd_seed_b64"] + "==")
639 assert len(raw) == 64
640
641 def test_fingerprint_matches_sha256_of_public_key(
642 self,
643 identity_with_mnemonic: None,
644 isolated_slots: pathlib.Path,
645 ) -> None:
646 result = runner.invoke(
647 cli, ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1", "--json"]
648 )
649 assert result.exit_code == 0
650 payload = json.loads(result.stdout.split("\n")[0])
651 pub_bytes = base64.urlsafe_b64decode(payload["public_key_b64"] + "==")
652 expected_fp = hashlib.sha256(pub_bytes).hexdigest()
653 assert payload["fingerprint"] == expected_fp
654
655 def test_same_account_produces_same_output_in_separate_invocations(
656 self,
657 identity_with_mnemonic: None,
658 isolated_slots: pathlib.Path,
659 ) -> None:
660 r1 = runner.invoke(
661 cli, ["agent", "keygen", "--hub", _TEST_HUB, "--account", "7", "--json"]
662 )
663 r2 = runner.invoke(
664 cli, ["agent", "keygen", "--hub", _TEST_HUB, "--account", "7", "--json"]
665 )
666 p1 = json.loads(r1.stdout.split("\n")[0])
667 p2 = json.loads(r2.stdout.split("\n")[0])
668 assert p1["fingerprint"] == p2["fingerprint"]
669 assert p1["hd_seed_b64"] == p2["hd_seed_b64"]
670
671 def test_slot_overwrite_updates_account(
672 self,
673 identity_with_mnemonic: None,
674 isolated_slots: pathlib.Path,
675 ) -> None:
676 from muse.core.agent_slots import register_slot, list_slots
677 register_slot(_TEST_HUB, "shared-name", 1)
678 register_slot(_TEST_HUB, "shared-name", 2)
679 slots = list_slots(_TEST_HUB)
680 matched = [s for s in slots if s["name"] == "shared-name"]
681 assert len(matched) == 1
682 assert matched[0]["account"] == 2
683
684 def test_msign_path_contains_account_index(
685 self,
686 identity_with_mnemonic: None,
687 isolated_slots: pathlib.Path,
688 ) -> None:
689 result = runner.invoke(
690 cli, ["agent", "keygen", "--hub", _TEST_HUB, "--account", "9", "--json"]
691 )
692 assert result.exit_code == 0
693 payload = json.loads(result.stdout.split("\n")[0])
694 assert "9'" in payload["msign_path"]
695
696
697 # ---------------------------------------------------------------------------
698 # 6. Performance — keygen completes within budget
699 # ---------------------------------------------------------------------------
700
701
702 class TestPerformance:
703 """Performance tests — keygen latency budget."""
704
705 def test_keygen_under_2_seconds(
706 self,
707 identity_with_mnemonic: None,
708 isolated_slots: pathlib.Path,
709 ) -> None:
710 from muse.cli.commands.agent import _derive_agent_seed, _sub_seed_to_public
711 start = time.monotonic()
712 sub_seed = _derive_agent_seed(_TEST_MNEMONIC, 1)
713 _sub_seed_to_public(sub_seed)
714 elapsed = time.monotonic() - start
715 assert elapsed < 2.0, f"Keygen took {elapsed:.3f}s — expected < 2s"
716
717 def test_10_sequential_keygens_under_5_seconds(
718 self,
719 identity_with_mnemonic: None,
720 isolated_slots: pathlib.Path,
721 ) -> None:
722 from muse.cli.commands.agent import _derive_agent_seed, _sub_seed_to_public
723 start = time.monotonic()
724 for i in range(10):
725 sub = _derive_agent_seed(_TEST_MNEMONIC, i)
726 _sub_seed_to_public(sub)
727 elapsed = time.monotonic() - start
728 assert elapsed < 5.0, f"10 keygens took {elapsed:.3f}s — expected < 5s"
729
730
731 # ---------------------------------------------------------------------------
732 # 7. Security
733 # ---------------------------------------------------------------------------
734
735
736 class TestSecurity:
737 """Security tests."""
738
739 def test_negative_account_rejected_in_keygen(
740 self,
741 identity_with_mnemonic: None,
742 isolated_slots: pathlib.Path,
743 ) -> None:
744 result = runner.invoke(
745 cli,
746 ["agent", "keygen", "--hub", _TEST_HUB, "--account", "-1", "--json"],
747 )
748 assert result.exit_code != 0
749
750 def test_negative_account_rejected_in_register(
751 self,
752 identity_with_mnemonic: None,
753 isolated_slots: pathlib.Path,
754 ) -> None:
755 result = runner.invoke(
756 cli,
757 ["agent", "register", "--hub", _TEST_HUB,
758 "--account", "-3", "--name", "bad", "--json"],
759 )
760 assert result.exit_code != 0
761
762 def test_mnemonic_not_in_keygen_json_output(
763 self,
764 identity_with_mnemonic: None,
765 isolated_slots: pathlib.Path,
766 ) -> None:
767 result = runner.invoke(
768 cli, ["agent", "keygen", "--hub", _TEST_HUB, "--account", "1", "--json"]
769 )
770 assert result.exit_code == 0
771 assert "abandon" not in result.output # mnemonic word not leaked
772
773 def test_mnemonic_not_in_list_output(
774 self,
775 identity_with_mnemonic: None,
776 isolated_slots: pathlib.Path,
777 ) -> None:
778 from muse.core.agent_slots import register_slot
779 register_slot(_TEST_HUB, "safe", 1)
780 result = runner.invoke(
781 cli, ["agent", "list", "--hub", _TEST_HUB, "--json"]
782 )
783 assert result.exit_code == 0
784 assert "abandon" not in result.output
785
786 def test_slots_file_symlink_guard(
787 self,
788 identity_with_mnemonic: None,
789 isolated_slots: pathlib.Path,
790 ) -> None:
791 """agent-slots.toml cannot be a symlink — _save raises OSError."""
792 from muse.core.agent_slots import _SLOTS_FILE, _SLOTS_DIR
793 # Create a decoy file, then replace agent-slots.toml with a symlink to it
794 decoy = isolated_slots / "decoy.toml"
795 decoy.write_text("", encoding="utf-8")
796 slots_file = isolated_slots / "agent-slots.toml"
797 slots_file.symlink_to(decoy)
798
799 from muse.core.agent_slots import register_slot
800 with pytest.raises(OSError, match="symlink"):
801 register_slot(_TEST_HUB, "evil", 1)
802
803 def test_slots_dir_not_world_readable(
804 self, identity_with_mnemonic: None, isolated_slots: pathlib.Path
805 ) -> None:
806 """After writing, the slots file should have mode 0o600."""
807 from muse.core.agent_slots import register_slot
808 import stat as stat_mod
809 register_slot(_TEST_HUB, "check-perms", 1)
810 slots_file = isolated_slots / "agent-slots.toml"
811 mode = stat_mod.S_IMODE(slots_file.stat().st_mode)
812 assert mode == 0o600, f"Expected 0o600 but got {oct(mode)}"
813
814
815 # ---------------------------------------------------------------------------
816 # 8. Docstrings — every public callable is documented
817 # ---------------------------------------------------------------------------
818
819
820 class TestDocstrings:
821 """Verify every public function/class in agent.py has a docstring."""
822
823 def _public_names(self) -> list[Any]:
824 import inspect
825 import muse.cli.commands.agent as mod
826 names = []
827 for name, obj in inspect.getmembers(mod):
828 if name.startswith("_"):
829 continue
830 if inspect.isfunction(obj) or inspect.isclass(obj):
831 if obj.__module__ == mod.__name__:
832 names.append((name, obj))
833 return names
834
835 def test_all_public_functions_have_docstrings(self) -> None:
836 for name, obj in self._public_names():
837 assert obj.__doc__, f"muse.cli.commands.agent.{name} is missing a docstring"
838
839 def test_module_has_docstring(self) -> None:
840 import muse.cli.commands.agent as mod
841 assert mod.__doc__, "muse.cli.commands.agent module is missing a docstring"
842
843 def test_typed_dicts_have_docstrings(self) -> None:
844 from muse.cli.commands.agent import _KeygenJson, _RegisterJson
845 assert _KeygenJson.__doc__
846 assert _RegisterJson.__doc__
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago