gabriel / muse public

test_cmd_agent.py file-level

at sha256:9 · View file ↗ · Intel ↗

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