gabriel / muse public
test_cmd_agent.py python
912 lines 31.7 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 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 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 # ---------------------------------------------------------------------------
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 / "muse_dir"
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 / "muse_dir_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 dot_muse = muse_dir(tmp_path)
107 dot_muse.mkdir()
108 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
109 (dot_muse / "refs" / "heads").mkdir(parents=True)
110 (dot_muse / "objects").mkdir()
111 (dot_muse / "commits").mkdir()
112 (dot_muse / "snapshots").mkdir()
113 (dot_muse / "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 = b64url_decode(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 = b64url_decode(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 = b64url_decode(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, "malicious", 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) -> list[str]:
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 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago