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