gabriel / muse public
test_cmd_config_hardening.py python
1,975 lines 87.0 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Comprehensive hardening tests for ``muse config``.
2
3 Coverage
4 --------
5 Unit — muse/cli/config.py internals
6 - _escape: backslash, quote, newline, carriage-return, null-byte escaping
7 - _validate_toml_key: unsafe characters rejected, safe keys pass
8 - _dump_toml: limits section round-trips, domain key injection blocked,
9 remote name injection blocked
10 - set_config_value: limits namespace writes correctly, TOML key injection
11 blocked, unknown limits key rejected, non-integer limits rejected,
12 invalid shard_prefix_length rejected
13 - get_config_value: limits keys read back correctly after write
14 - config_as_dict: limits section included in output
15
16 Integration — CLI commands via CliRunner
17 - run_show: TOML text and JSON outputs, limits displayed in both formats,
18 sanitize_display applied in text mode, --format json alias
19 - run_get: bare value to stdout, --json schema, not-set exits nonzero,
20 key sanitized in stderr message
21 - run_set: success to stderr, --json schema, blocked namespace rejected,
22 TOML injection rejected, limits set and readable, non-integer limits rejected
23 - run_edit: no-repo exits, missing config exits, bad editor exits
24
25 Security
26 - TOML key injection: newline, bracket, equals, quote in domain key blocked
27 - ANSI in key/value sanitized in run_show text mode
28 - ANSI in exception message sanitized in run_set stderr
29 - run_set success message goes to stderr (stdout clean for scripting)
30 - run_get error goes to stderr
31
32 E2E (full round-trip via CLI)
33 - set then get is consistent
34 - set limits then show --json contains limits
35 - set domain then show TOML is valid TOML
36 - limits fall-through to domain is fixed (writes to [limits] not [domain])
37
38 Stress
39 - 8 concurrent set_config_value calls to isolated repos: no corruption
40 """
41
42 from __future__ import annotations
43
44 import json
45 import pathlib
46 import threading
47 import tomllib
48 from typing import TYPE_CHECKING
49 from unittest.mock import MagicMock, patch
50
51 import pytest
52
53 from tests.cli_test_helper import CliRunner, InvokeResult
54
55 if TYPE_CHECKING:
56 pass
57
58 from muse.cli.commands.config_cmd import _GetJson, _SetJson
59 from muse.core.store import JsonValue
60
61 type _ReadJson = dict[str, JsonValue]
62 from muse.core._types import MsgpackDict
63
64 cli = None
65 runner = CliRunner()
66
67 # ── fixtures ──────────────────────────────────────────────────────────────────
68
69
70 @pytest.fixture
71 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
72 """Minimal .muse/ repo with an empty config.toml."""
73 from muse._version import __version__
74
75 muse_dir = tmp_path / ".muse"
76 for sub in ("refs/heads", "objects", "commits", "snapshots"):
77 (muse_dir / sub).mkdir(parents=True, exist_ok=True)
78 (muse_dir / "repo.json").write_text(
79 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
80 )
81 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
82 (muse_dir / "refs" / "heads" / "main").write_text("")
83 (muse_dir / "config.toml").write_text("")
84 monkeypatch.chdir(tmp_path)
85 return tmp_path
86
87
88 def _json_get(result: InvokeResult) -> _GetJson:
89 for line in result.output.splitlines():
90 stripped = line.strip()
91 if stripped.startswith("{"):
92 d: _GetJson = json.loads(stripped)
93 return d
94 raise ValueError(f"No JSON in output:\n{result.output!r}")
95
96
97 def _json_set(result: InvokeResult) -> _SetJson:
98 for line in result.output.splitlines():
99 stripped = line.strip()
100 if stripped.startswith("{"):
101 d: _SetJson = json.loads(stripped)
102 return d
103 raise ValueError(f"No JSON in output:\n{result.output!r}")
104
105
106 def _json_show(result: InvokeResult) -> _ReadJson:
107 """Extract the config payload from ``muse config read --json`` output.
108
109 The envelope wraps config sections under a ``config`` key; return that
110 inner dict so callers can access ``data["hub"]`` / ``data["user"]`` etc.
111 directly without caring about the envelope structure.
112 """
113 lines = result.output.splitlines()
114 start = None
115 for i, line in enumerate(lines):
116 if line.strip().startswith("{"):
117 start = i
118 break
119 if start is None:
120 raise ValueError(f"No JSON in output:\n{result.output!r}")
121 depth = 0
122 collected: list[str] = []
123 for line in lines[start:]:
124 collected.append(line)
125 depth += line.count("{") - line.count("}")
126 if depth <= 0:
127 break
128 envelope = json.loads("\n".join(collected))
129 # Unwrap envelope: return the inner config dict when present so assertions
130 # like data["hub"] work without knowing the envelope structure.
131 return envelope.get("config", envelope) # type: ignore[return-value]
132
133
134 # ── Unit: _escape ─────────────────────────────────────────────────────────────
135
136
137 class TestEscape:
138 def test_backslash_escaped(self) -> None:
139 from muse.cli.config import _escape
140 assert _escape("back\\slash") == "back\\\\slash"
141
142 def test_double_quote_escaped(self) -> None:
143 from muse.cli.config import _escape
144 assert _escape('say "hi"') == 'say \\"hi\\"'
145
146 def test_newline_escaped(self) -> None:
147 from muse.cli.config import _escape
148 assert _escape("line1\nline2") == "line1\\nline2"
149
150 def test_carriage_return_escaped(self) -> None:
151 from muse.cli.config import _escape
152 assert _escape("text\rmore") == "text\\rmore"
153
154 def test_null_byte_removed(self) -> None:
155 from muse.cli.config import _escape
156 assert "\0" not in _escape("bad\0byte")
157
158 def test_clean_string_passthrough(self) -> None:
159 from muse.cli.config import _escape
160 assert _escape("hello world") == "hello world"
161
162
163 # ── Unit: _validate_toml_key ──────────────────────────────────────────────────
164
165
166 class TestValidateTomlKey:
167 def test_newline_in_key_rejected(self) -> None:
168 from muse.cli.config import _validate_toml_key
169 with pytest.raises(ValueError, match="TOML keys"):
170 _validate_toml_key("bad\nkey")
171
172 def test_carriage_return_rejected(self) -> None:
173 from muse.cli.config import _validate_toml_key
174 with pytest.raises(ValueError, match="TOML keys"):
175 _validate_toml_key("bad\rkey")
176
177 def test_closing_bracket_rejected(self) -> None:
178 from muse.cli.config import _validate_toml_key
179 with pytest.raises(ValueError, match="TOML keys"):
180 _validate_toml_key("x]injection")
181
182 def test_opening_bracket_rejected(self) -> None:
183 from muse.cli.config import _validate_toml_key
184 with pytest.raises(ValueError, match="TOML keys"):
185 _validate_toml_key("[evil")
186
187 def test_equals_rejected(self) -> None:
188 from muse.cli.config import _validate_toml_key
189 with pytest.raises(ValueError, match="TOML keys"):
190 _validate_toml_key("k=v")
191
192 def test_double_quote_rejected(self) -> None:
193 from muse.cli.config import _validate_toml_key
194 with pytest.raises(ValueError, match="TOML keys"):
195 _validate_toml_key('key"val')
196
197 def test_null_byte_rejected(self) -> None:
198 from muse.cli.config import _validate_toml_key
199 with pytest.raises(ValueError, match="TOML keys"):
200 _validate_toml_key("bad\0key")
201
202 def test_safe_key_passes(self) -> None:
203 from muse.cli.config import _validate_toml_key
204 _validate_toml_key("ticks_per_beat")
205 _validate_toml_key("my-key.123")
206 _validate_toml_key("CamelCase")
207
208
209 # ── Unit: _dump_toml ──────────────────────────────────────────────────────────
210
211
212 class TestDumpToml:
213 def test_limits_section_round_trips(self) -> None:
214 from muse.cli.config import LimitsConfig, MuseConfig, _dump_toml
215 cfg: MuseConfig = {"limits": LimitsConfig(max_walk_commits=99, max_ancestors=500)}
216 toml_text = _dump_toml(cfg)
217 parsed = tomllib.loads(toml_text)
218 assert parsed["limits"]["max_walk_commits"] == 99
219 assert parsed["limits"]["max_ancestors"] == 500
220
221 def test_limits_shard_prefix_length_written(self) -> None:
222 from muse.cli.config import LimitsConfig, MuseConfig, _dump_toml
223 cfg: MuseConfig = {"limits": LimitsConfig(shard_prefix_length=4)}
224 toml_text = _dump_toml(cfg)
225 parsed = tomllib.loads(toml_text)
226 assert parsed["limits"]["shard_prefix_length"] == 4
227
228 def test_domain_key_injection_blocked_in_dump(self) -> None:
229 from muse.cli.config import MuseConfig, _dump_toml
230 cfg: MuseConfig = {"domain": {"evil\nkey": "val"}}
231 with pytest.raises(ValueError, match="TOML keys"):
232 _dump_toml(cfg)
233
234 def test_remote_name_injection_blocked(self) -> None:
235 from muse.cli.config import MuseConfig, RemoteEntry, _dump_toml
236 cfg: MuseConfig = {"remotes": {"evil\nname": RemoteEntry(url="http://localhost")}}
237 with pytest.raises(ValueError, match="TOML keys"):
238 _dump_toml(cfg)
239
240 def test_value_with_newline_escaped_not_injected(self) -> None:
241 from muse.cli.config import MuseConfig, _dump_toml
242 cfg: MuseConfig = {"domain": {"key": "line1\nline2"}}
243 toml_text = _dump_toml(cfg)
244 parsed = tomllib.loads(toml_text)
245 # The value line should contain \\n (escaped), not a literal newline
246 value_line = next(l for l in toml_text.splitlines() if l.startswith("key"))
247 assert "\n" not in value_line
248 assert "\\n" in value_line
249 assert "line1" in parsed["domain"]["key"]
250
251 def test_section_order(self) -> None:
252 from muse.cli.config import HubConfig, MuseConfig, UserConfig, _dump_toml
253 cfg: MuseConfig = {
254 "user": UserConfig(name="alice"),
255 "hub": HubConfig(url="https://musehub.ai"),
256 "domain": {"k": "v"},
257 }
258 toml_text = _dump_toml(cfg)
259 user_pos = toml_text.index("[user]")
260 hub_pos = toml_text.index("[hub]")
261 domain_pos = toml_text.index("[domain]")
262 assert user_pos < hub_pos < domain_pos
263
264
265 # ── Unit: set_config_value + get_config_value ─────────────────────────────────
266
267
268 class TestSetGetConfigValue:
269 def _make_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
270 muse_dir = tmp_path / ".muse"
271 muse_dir.mkdir()
272 (muse_dir / "config.toml").write_text("")
273 return tmp_path
274
275 def test_limits_max_walk_commits_writes_to_limits_section(
276 self, tmp_path: pathlib.Path
277 ) -> None:
278 root = self._make_repo(tmp_path)
279 from muse.cli.config import set_config_value
280 set_config_value("limits.max_walk_commits", "5000", root)
281 raw = (root / ".muse" / "config.toml").read_text()
282 parsed = tomllib.loads(raw)
283 assert "limits" in parsed
284 assert parsed["limits"]["max_walk_commits"] == 5000
285 assert "domain" not in parsed
286
287 def test_limits_max_walk_commits_NOT_written_to_domain(
288 self, tmp_path: pathlib.Path
289 ) -> None:
290 """Regression: previously limits fell through to domain code path."""
291 root = self._make_repo(tmp_path)
292 from muse.cli.config import set_config_value
293 set_config_value("limits.max_walk_commits", "1000", root)
294 raw = (root / ".muse" / "config.toml").read_text()
295 parsed = tomllib.loads(raw)
296 assert "domain" not in parsed
297
298 def test_limits_shard_prefix_length_valid(self, tmp_path: pathlib.Path) -> None:
299 root = self._make_repo(tmp_path)
300 from muse.cli.config import get_config_value, set_config_value
301 set_config_value("limits.shard_prefix_length", "4", root)
302 assert get_config_value("limits.shard_prefix_length", root) == "4"
303
304 def test_limits_shard_prefix_length_invalid_rejected(
305 self, tmp_path: pathlib.Path
306 ) -> None:
307 root = self._make_repo(tmp_path)
308 from muse.cli.config import set_config_value
309 with pytest.raises(ValueError, match="shard_prefix_length must be 2 or 4"):
310 set_config_value("limits.shard_prefix_length", "3", root)
311
312 def test_limits_non_integer_rejected(self, tmp_path: pathlib.Path) -> None:
313 root = self._make_repo(tmp_path)
314 from muse.cli.config import set_config_value
315 with pytest.raises(ValueError, match="integer"):
316 set_config_value("limits.max_walk_commits", "notanint", root)
317
318 def test_limits_zero_rejected(self, tmp_path: pathlib.Path) -> None:
319 root = self._make_repo(tmp_path)
320 from muse.cli.config import set_config_value
321 with pytest.raises(ValueError, match="positive"):
322 set_config_value("limits.max_walk_commits", "0", root)
323
324 def test_limits_negative_rejected(self, tmp_path: pathlib.Path) -> None:
325 root = self._make_repo(tmp_path)
326 from muse.cli.config import set_config_value
327 with pytest.raises(ValueError, match="positive"):
328 set_config_value("limits.max_walk_commits", "-1", root)
329
330 def test_limits_unknown_key_rejected(self, tmp_path: pathlib.Path) -> None:
331 root = self._make_repo(tmp_path)
332 from muse.cli.config import set_config_value
333 with pytest.raises(ValueError, match="Unknown \\[limits\\]"):
334 set_config_value("limits.unknown_key", "5", root)
335
336 def test_domain_key_injection_rejected(self, tmp_path: pathlib.Path) -> None:
337 root = self._make_repo(tmp_path)
338 from muse.cli.config import set_config_value
339 with pytest.raises(ValueError, match="TOML keys"):
340 set_config_value("domain.evil\nkey", "bad", root)
341
342 def test_domain_key_with_bracket_injection_rejected(
343 self, tmp_path: pathlib.Path
344 ) -> None:
345 root = self._make_repo(tmp_path)
346 from muse.cli.config import set_config_value
347 with pytest.raises(ValueError, match="TOML keys"):
348 set_config_value("domain.x][evil", "bad", root)
349
350 def test_domain_safe_key_written(self, tmp_path: pathlib.Path) -> None:
351 root = self._make_repo(tmp_path)
352 from muse.cli.config import get_config_value, set_config_value
353 set_config_value("domain.ticks_per_beat", "480", root)
354 assert get_config_value("domain.ticks_per_beat", root) == "480"
355
356 def test_get_config_value_limits_after_write(self, tmp_path: pathlib.Path) -> None:
357 root = self._make_repo(tmp_path)
358 from muse.cli.config import get_config_value, set_config_value
359 set_config_value("limits.max_ancestors", "25000", root)
360 assert get_config_value("limits.max_ancestors", root) == "25000"
361
362
363 # ── Unit: config_as_dict ─────────────────────────────────────────────────────
364
365
366 class TestConfigAsDict:
367 def _make_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
368 muse_dir = tmp_path / ".muse"
369 muse_dir.mkdir()
370 return tmp_path
371
372 def test_limits_included_in_output(self, tmp_path: pathlib.Path) -> None:
373 root = self._make_repo(tmp_path)
374 (root / ".muse" / "config.toml").write_text(
375 "[limits]\nmax_walk_commits = 5000\n"
376 )
377 from muse.cli.config import config_as_dict
378 d = config_as_dict(root)
379 assert "limits" in d
380 assert d["limits"]["max_walk_commits"] == "5000"
381
382 def test_limits_absent_when_not_set(self, tmp_path: pathlib.Path) -> None:
383 root = self._make_repo(tmp_path)
384 (root / ".muse" / "config.toml").write_text("[user]\nname = \"alice\"\n")
385 from muse.cli.config import config_as_dict
386 d = config_as_dict(root)
387 assert "limits" not in d
388
389 def test_empty_config_returns_empty_dict(self, tmp_path: pathlib.Path) -> None:
390 root = self._make_repo(tmp_path)
391 (root / ".muse" / "config.toml").write_text("")
392 from muse.cli.config import config_as_dict
393 assert config_as_dict(root) == {}
394
395
396 # ── Integration: run_show ─────────────────────────────────────────────────────
397
398
399 class TestRunRead:
400 def test_read_json_includes_limits(self, repo: pathlib.Path) -> None:
401 runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "7777"])
402 result = runner.invoke(cli, ["config", "read", "--json"])
403 assert result.exit_code == 0
404 data = _json_show(result)
405 assert "limits" in data
406 limits = data["limits"]
407 assert isinstance(limits, dict)
408 assert limits["max_walk_commits"] == "7777"
409
410 def test_read_json_schema_user(self, repo: pathlib.Path) -> None:
411 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
412 result = runner.invoke(cli, ["config", "read", "--json"])
413 assert result.exit_code == 0
414 data = _json_show(result)
415 user = data.get("user")
416 assert isinstance(user, dict)
417 assert user.get("handle") == "Alice"
418
419 def test_read_json_flag_emits_json(self, repo: pathlib.Path) -> None:
420 result = runner.invoke(cli, ["config", "read", "--json"])
421 assert result.exit_code == 0
422 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
423 assert len(json_lines) >= 1
424
425 def test_read_format_invalid_exits(self, repo: pathlib.Path) -> None:
426 result = runner.invoke(cli, ["config", "read", "--format", "xml"])
427 assert result.exit_code != 0
428
429 def test_read_text_mode_ansi_sanitized(
430 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
431 ) -> None:
432 # Write a config with ANSI in value (directly to file to bypass validation)
433 (repo / ".muse" / "config.toml").write_text(
434 '[user]\nname = "\\x1b[31mevil\\x1b[0m"\n'
435 )
436 result = runner.invoke(cli, ["config", "read"])
437 assert "\x1b[" not in result.output
438
439 def test_read_text_empty_config(self, repo: pathlib.Path) -> None:
440 result = runner.invoke(cli, ["config", "read"])
441 assert result.exit_code == 0
442 assert "No configuration set" in result.output
443
444 def test_read_text_limits_section_displayed(self, repo: pathlib.Path) -> None:
445 runner.invoke(cli, ["config", "set", "limits.max_ancestors", "30000"])
446 result = runner.invoke(cli, ["config", "read"])
447 assert result.exit_code == 0
448 assert "[limits]" in result.output
449 assert "max_ancestors" in result.output
450
451 def test_read_json_stdout_clean(self, repo: pathlib.Path) -> None:
452 runner.invoke(cli, ["config", "set", "user.handle", "Bob"])
453 result = runner.invoke(cli, ["config", "read", "--json"])
454 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
455 assert len(json_lines) >= 1
456
457 def test_read_no_repo_still_works(
458 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
459 ) -> None:
460 # show gracefully shows empty config outside a repo (uses cwd)
461 monkeypatch.chdir(tmp_path)
462 result = runner.invoke(cli, ["config", "read"])
463 assert result.exit_code == 0
464
465
466 # ── Integration: run_get ─────────────────────────────────────────────────────
467
468
469 class TestRunGet:
470 def test_get_existing_key_raw_value(self, repo: pathlib.Path) -> None:
471 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
472 result = runner.invoke(cli, ["config", "get", "user.handle"])
473 assert result.exit_code == 0
474 assert "Alice" in result.output
475
476 def test_get_json_schema(self, repo: pathlib.Path) -> None:
477 runner.invoke(cli, ["config", "set", "user.type", "agent"])
478 result = runner.invoke(cli, ["config", "get", "user.type", "--json"])
479 assert result.exit_code == 0
480 data = _json_get(result)
481 assert data["key"] == "user.type"
482 assert data["value"] == "agent"
483
484 def test_get_missing_key_exits_nonzero(self, repo: pathlib.Path) -> None:
485 result = runner.invoke(cli, ["config", "get", "user.handle"])
486 assert result.exit_code != 0
487
488 def test_get_missing_key_error_to_stderr(
489 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
490 ) -> None:
491 result = runner.invoke(cli, ["config", "get", "user.handle"])
492 assert result.exit_code != 0
493 assert "not set" in result.output
494
495 def test_get_limits_key_after_set(self, repo: pathlib.Path) -> None:
496 runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "12345"])
497 result = runner.invoke(cli, ["config", "get", "limits.max_walk_commits"])
498 assert result.exit_code == 0
499 assert "12345" in result.output
500
501 def test_get_json_stdout_clean(self, repo: pathlib.Path) -> None:
502 runner.invoke(cli, ["config", "set", "user.email", "[email protected]"])
503 result = runner.invoke(cli, ["config", "get", "user.email", "--json"])
504 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
505 assert len(json_lines) >= 1
506
507
508 # ── Integration: run_set ─────────────────────────────────────────────────────
509
510
511 class TestRunSet:
512 def test_set_success_json_schema(self, repo: pathlib.Path) -> None:
513 result = runner.invoke(
514 cli, ["config", "set", "user.handle", "Bob", "--json"]
515 )
516 assert result.exit_code == 0
517 data = _json_set(result)
518 assert data["status"] == "ok"
519 assert data["key"] == "user.handle"
520 assert data["value"] == "Bob"
521
522 def test_set_success_stderr_message(
523 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
524 ) -> None:
525 result = runner.invoke(cli, ["config", "set", "user.handle", "Carol"])
526 assert result.exit_code == 0
527 assert "Carol" in result.output
528
529 def test_set_json_stdout_clean(self, repo: pathlib.Path) -> None:
530 result = runner.invoke(
531 cli, ["config", "set", "user.type", "agent", "--json"]
532 )
533 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
534 assert len(json_lines) >= 1
535
536 def test_set_blocked_namespace_exits(self, repo: pathlib.Path) -> None:
537 result = runner.invoke(cli, ["config", "set", "auth.token", "secret"])
538 assert result.exit_code != 0
539
540 def test_set_blocked_remotes_namespace_exits(self, repo: pathlib.Path) -> None:
541 result = runner.invoke(cli, ["config", "set", "remotes.origin", "url"])
542 assert result.exit_code != 0
543
544 def test_set_domain_newline_injection_rejected(self, repo: pathlib.Path) -> None:
545 result = runner.invoke(cli, ["config", "set", "domain.evil\nkey", "bad"])
546 assert result.exit_code != 0
547
548 def test_set_domain_bracket_injection_rejected(self, repo: pathlib.Path) -> None:
549 result = runner.invoke(cli, ["config", "set", "domain.x][evil", "bad"])
550 assert result.exit_code != 0
551
552 def test_set_limits_max_walk_commits(self, repo: pathlib.Path) -> None:
553 result = runner.invoke(
554 cli, ["config", "set", "limits.max_walk_commits", "20000"]
555 )
556 assert result.exit_code == 0
557 get_result = runner.invoke(cli, ["config", "get", "limits.max_walk_commits"])
558 assert "20000" in get_result.output
559
560 def test_set_limits_non_integer_rejected(self, repo: pathlib.Path) -> None:
561 result = runner.invoke(
562 cli, ["config", "set", "limits.max_walk_commits", "abc"]
563 )
564 assert result.exit_code != 0
565
566 def test_set_limits_zero_rejected(self, repo: pathlib.Path) -> None:
567 result = runner.invoke(
568 cli, ["config", "set", "limits.max_walk_commits", "0"]
569 )
570 assert result.exit_code != 0
571
572 def test_set_limits_shard_prefix_valid(self, repo: pathlib.Path) -> None:
573 result = runner.invoke(
574 cli, ["config", "set", "limits.shard_prefix_length", "4"]
575 )
576 assert result.exit_code == 0
577
578 def test_set_limits_shard_prefix_invalid_rejected(self, repo: pathlib.Path) -> None:
579 result = runner.invoke(
580 cli, ["config", "set", "limits.shard_prefix_length", "3"]
581 )
582 assert result.exit_code != 0
583
584 def test_set_error_ansi_sanitized_in_output(
585 self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str]
586 ) -> None:
587 # Use a key with ANSI that would appear in the error message
588 ansi_key = "domain.\x1b[31mevil\x1b[0m\nkey"
589 result = runner.invoke(cli, ["config", "set", ansi_key, "val"])
590 assert result.exit_code != 0
591 assert "\x1b[" not in result.output
592
593 def test_set_limits_writes_to_limits_not_domain(self, repo: pathlib.Path) -> None:
594 """Regression: limits namespace must not fall through to domain code."""
595 runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "9999"])
596 raw = (repo / ".muse" / "config.toml").read_text()
597 parsed = tomllib.loads(raw)
598 assert "domain" not in parsed
599 assert parsed["limits"]["max_walk_commits"] == 9999
600
601
602 # ── Integration: run_edit ─────────────────────────────────────────────────────
603
604
605 class TestRunEdit:
606 def test_edit_no_repo_exits(
607 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
608 ) -> None:
609 monkeypatch.chdir(tmp_path)
610 result = runner.invoke(cli, ["config", "edit"])
611 assert result.exit_code != 0
612
613 def test_edit_missing_config_file_autocreated(
614 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
615 ) -> None:
616 """Missing config.toml must be auto-created before the editor opens."""
617 (repo / ".muse" / "config.toml").unlink()
618 monkeypatch.setenv("EDITOR", "true")
619 monkeypatch.delenv("VISUAL", raising=False)
620 result = runner.invoke(cli, ["config", "edit"])
621 assert result.exit_code == 0
622 assert (repo / ".muse" / "config.toml").exists()
623
624 def test_edit_bad_editor_exits(
625 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
626 ) -> None:
627 monkeypatch.setenv("EDITOR", "nonexistent-editor-xyz")
628 monkeypatch.delenv("VISUAL", raising=False)
629 result = runner.invoke(cli, ["config", "edit"])
630 assert result.exit_code != 0
631
632 def test_edit_invokes_editor(
633 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
634 ) -> None:
635 monkeypatch.setenv("EDITOR", "true")
636 monkeypatch.delenv("VISUAL", raising=False)
637 result = runner.invoke(cli, ["config", "edit"])
638 assert result.exit_code == 0
639
640
641 # ── Security ──────────────────────────────────────────────────────────────────
642
643
644 class TestConfigSecurity:
645 def test_toml_key_injection_blocked_end_to_end(self, repo: pathlib.Path) -> None:
646 """Setting domain key with newline must not corrupt config.toml."""
647 result = runner.invoke(
648 cli, ["config", "set", "domain.evil\nkey", "bad"]
649 )
650 assert result.exit_code != 0
651 raw = (repo / ".muse" / "config.toml").read_text()
652 assert "\nkey" not in raw
653
654 def test_bracket_injection_blocked(self, repo: pathlib.Path) -> None:
655 result = runner.invoke(
656 cli, ["config", "set", "domain.x][evil", "val"]
657 )
658 assert result.exit_code != 0
659 raw = (repo / ".muse" / "config.toml").read_text()
660 assert "[evil]" not in raw
661
662 def test_equals_injection_blocked(self, repo: pathlib.Path) -> None:
663 result = runner.invoke(
664 cli, ["config", "set", "domain.x=y", "val"]
665 )
666 assert result.exit_code != 0
667
668 def test_ansi_in_read_text_stripped(self, repo: pathlib.Path) -> None:
669 (repo / ".muse" / "config.toml").write_text(
670 '[domain]\nticks = "\\x1b[31mred\\x1b[0m"\n'
671 )
672 result = runner.invoke(cli, ["config", "read"])
673 assert "\x1b[" not in result.output
674
675 def test_auth_namespace_always_blocked(self, repo: pathlib.Path) -> None:
676 for key in ("auth.token", "auth.password", "auth.secret"):
677 result = runner.invoke(cli, ["config", "set", key, "val"])
678 assert result.exit_code != 0, f"Expected {key!r} to be blocked"
679
680 def test_credentials_not_in_json_output(self, repo: pathlib.Path) -> None:
681 (repo / ".muse" / "config.toml").write_text(
682 '[hub]\nurl = "https://localhost:1337"\n'
683 '[auth]\ntoken = "secret-token"\n'
684 )
685 result = runner.invoke(cli, ["config", "read", "--json"])
686 assert result.exit_code == 0
687 assert "secret-token" not in result.output
688
689 def test_value_newline_escaped_in_toml(self, repo: pathlib.Path) -> None:
690 runner.invoke(cli, ["config", "set", "user.handle", "Alice\nBob"])
691 raw = (repo / ".muse" / "config.toml").read_text()
692 # The literal newline must not appear in the value field
693 name_line = [l for l in raw.splitlines() if "handle" in l][0]
694 assert "\n" not in name_line
695
696 def test_error_message_to_stderr_not_stdout(self, repo: pathlib.Path) -> None:
697 result = runner.invoke(
698 cli, ["config", "set", "domain.evil\nkey", "val"]
699 )
700 assert result.exit_code != 0
701 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
702 assert len(json_lines) == 0
703
704
705 # ── E2E round-trips ───────────────────────────────────────────────────────────
706
707
708 class TestE2ERoundTrips:
709 def test_set_then_get_user(self, repo: pathlib.Path) -> None:
710 runner.invoke(cli, ["config", "set", "user.handle", "DeepBlue"])
711 result = runner.invoke(cli, ["config", "get", "user.handle"])
712 assert result.exit_code == 0
713 assert "DeepBlue" in result.output
714
715 def test_set_limits_then_read_json(self, repo: pathlib.Path) -> None:
716 runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "3333"])
717 result = runner.invoke(cli, ["config", "read", "--json"])
718 assert result.exit_code == 0
719 data = _json_show(result)
720 limits = data.get("limits")
721 assert isinstance(limits, dict)
722 assert limits.get("max_walk_commits") == "3333"
723
724 def test_set_domain_then_read_valid_toml(self, repo: pathlib.Path) -> None:
725 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "960"])
726 result = runner.invoke(cli, ["config", "read"])
727 assert result.exit_code == 0
728 assert "960" in result.output
729
730 def test_limits_written_to_limits_section_not_domain(
731 self, repo: pathlib.Path
732 ) -> None:
733 runner.invoke(cli, ["config", "set", "limits.max_graph_commits", "8888"])
734 raw = (repo / ".muse" / "config.toml").read_text()
735 parsed = tomllib.loads(raw)
736 assert "domain" not in parsed
737 assert parsed["limits"]["max_graph_commits"] == 8888
738
739 def test_multiple_writes_preserve_all_sections(self, repo: pathlib.Path) -> None:
740 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
741 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"])
742 runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "2000"])
743 raw = (repo / ".muse" / "config.toml").read_text()
744 parsed = tomllib.loads(raw)
745 assert parsed["user"]["handle"] == "Alice"
746 assert parsed["domain"]["ticks_per_beat"] == "480"
747 assert parsed["limits"]["max_walk_commits"] == 2000
748
749 def test_set_json_get_json_consistent(self, repo: pathlib.Path) -> None:
750 set_result = runner.invoke(
751 cli, ["config", "set", "user.type", "agent", "--json"]
752 )
753 get_result = runner.invoke(
754 cli, ["config", "get", "user.type", "--json"]
755 )
756 set_data = _json_set(set_result)
757 get_data = _json_get(get_result)
758 assert set_data["value"] == get_data["value"] == "agent"
759
760
761 # ── Stress ────────────────────────────────────────────────────────────────────
762
763
764 class TestStress:
765 def test_8_concurrent_set_to_isolated_repos(
766 self, tmp_path: pathlib.Path
767 ) -> None:
768 """8 threads writing to independent repos must not corrupt each other."""
769 from muse._version import __version__
770 from muse.cli.config import get_config_value, set_config_value
771
772 errors: list[str] = []
773
774 def _do(idx: int) -> None:
775 try:
776 repo_dir = tmp_path / f"repo_{idx}"
777 muse_dir = repo_dir / ".muse"
778 muse_dir.mkdir(parents=True)
779 (muse_dir / "config.toml").write_text("")
780 (muse_dir / "repo.json").write_text(
781 json.dumps({
782 "repo_id": f"repo-{idx}",
783 "schema_version": __version__,
784 "domain": "code",
785 })
786 )
787 value = f"user_{idx}"
788 set_config_value("user.handle", value, repo_dir)
789 result = get_config_value("user.handle", repo_dir)
790 assert result == value, f"Expected {value!r}, got {result!r}"
791 except Exception as exc:
792 errors.append(f"Thread {idx}: {exc}")
793
794 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
795 for t in threads:
796 t.start()
797 for t in threads:
798 t.join()
799 assert errors == [], "Concurrent config write failures:\n" + "\n".join(errors)
800
801 def test_8_concurrent_set_limits_isolated(
802 self, tmp_path: pathlib.Path
803 ) -> None:
804 """8 threads writing limits to isolated repos must write to [limits] not [domain]."""
805 from muse._version import __version__
806 from muse.cli.config import set_config_value
807
808 errors: list[str] = []
809
810 def _do(idx: int) -> None:
811 try:
812 repo_dir = tmp_path / f"limits_repo_{idx}"
813 muse_dir = repo_dir / ".muse"
814 muse_dir.mkdir(parents=True)
815 (muse_dir / "config.toml").write_text("")
816 (muse_dir / "repo.json").write_text(
817 json.dumps({
818 "repo_id": f"repo-{idx}",
819 "schema_version": __version__,
820 "domain": "code",
821 })
822 )
823 set_config_value("limits.max_walk_commits", str(1000 + idx), repo_dir)
824 raw = (muse_dir / "config.toml").read_text()
825 parsed = tomllib.loads(raw)
826 assert "limits" in parsed, "limits section missing"
827 assert "domain" not in parsed, "limits fell through to domain"
828 assert parsed["limits"]["max_walk_commits"] == 1000 + idx
829 except Exception as exc:
830 errors.append(f"Thread {idx}: {exc}")
831
832 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
833 for t in threads:
834 t.start()
835 for t in threads:
836 t.join()
837 assert errors == [], "Concurrent limits write failures:\n" + "\n".join(errors)
838
839
840 # =============================================================================
841 # muse config read — extended hardening
842 # =============================================================================
843
844
845 class TestRunReadExtended:
846 """Additional coverage for ``muse config read`` gaps."""
847
848 # ── flag aliases ──────────────────────────────────────────────────────────
849
850 def test_j_short_flag_emits_json(self, repo: pathlib.Path) -> None:
851 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
852 result = runner.invoke(cli, ["config", "read", "-j"])
853 assert result.exit_code == 0
854 data = _json_show(result)
855 assert isinstance(data, dict)
856
857 def test_j_flag_same_as_json_flag(self, repo: pathlib.Path) -> None:
858 runner.invoke(cli, ["config", "set", "user.handle", "Bob"])
859 r1 = runner.invoke(cli, ["config", "read", "--json"])
860 r2 = runner.invoke(cli, ["config", "read", "-j"])
861 assert r1.exit_code == 0
862 assert r2.exit_code == 0
863 # Compare only config sections, not timing fields which naturally differ
864 d1 = {k: v for k, v in _json_show(r1).items() if k not in ("duration_ms", "exit_code")}
865 d2 = {k: v for k, v in _json_show(r2).items() if k not in ("duration_ms", "exit_code")}
866 assert d1 == d2
867
868 def test_j_shorthand_emits_json(self, repo: pathlib.Path) -> None:
869 runner.invoke(cli, ["config", "set", "user.handle", "Carol"])
870 result = runner.invoke(cli, ["config", "read", "-j"])
871 assert result.exit_code == 0
872 data = _json_show(result)
873 assert isinstance(data, dict)
874
875 def test_default_is_text_output(self, repo: pathlib.Path) -> None:
876 runner.invoke(cli, ["config", "set", "user.handle", "Dave"])
877 result = runner.invoke(cli, ["config", "read"])
878 assert result.exit_code == 0
879 assert "[user]" in result.output
880 assert "{" not in result.output # no JSON
881
882 def test_json_flag_emits_single_object(
883 self, repo: pathlib.Path
884 ) -> None:
885 """--json must emit exactly one JSON object, not duplicate output."""
886 runner.invoke(cli, ["config", "set", "user.handle", "Eve"])
887 result = runner.invoke(cli, ["config", "read", "--json"])
888 assert result.exit_code == 0
889 # Must be parseable as a single JSON object
890 data = _json_show(result)
891 assert isinstance(data, dict)
892
893 # ── JSON structure ────────────────────────────────────────────────────────
894
895 def test_json_is_compact(self, repo: pathlib.Path) -> None:
896 """JSON output must be compact — agents parse it, not humans."""
897 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
898 result = runner.invoke(cli, ["config", "read", "--json"])
899 assert result.exit_code == 0
900 # Compact JSON is a single line
901 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
902 assert len(json_lines) == 1
903 json.loads(json_lines[0]) # must be valid JSON
904
905 def test_json_hub_section_present(self, repo: pathlib.Path) -> None:
906 from muse.cli.config import set_hub_url
907 set_hub_url("https://musehub.ai", repo)
908 result = runner.invoke(cli, ["config", "read", "--json"])
909 assert result.exit_code == 0
910 data = _json_show(result)
911 assert "hub" in data
912 hub = data["hub"]
913 assert isinstance(hub, dict)
914 assert hub["url"] == "https://musehub.ai"
915
916 def test_json_remotes_section_present(self, repo: pathlib.Path) -> None:
917 from muse.cli.config import set_remote
918 set_remote("origin", "https://hub.example.com/owner/repo", repo)
919 result = runner.invoke(cli, ["config", "read", "--json"])
920 assert result.exit_code == 0
921 data = _json_show(result)
922 assert "remotes" in data
923 remotes = data["remotes"]
924 assert isinstance(remotes, dict)
925 assert "origin" in remotes
926
927 def test_json_domain_section_present(self, repo: pathlib.Path) -> None:
928 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "960"])
929 result = runner.invoke(cli, ["config", "read", "--json"])
930 assert result.exit_code == 0
931 data = _json_show(result)
932 assert "domain" in data
933 domain = data["domain"]
934 assert isinstance(domain, dict)
935 assert domain["ticks_per_beat"] == "960"
936
937 def test_json_all_sections_together(self, repo: pathlib.Path) -> None:
938 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
939 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"])
940 runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "5000"])
941 result = runner.invoke(cli, ["config", "read", "--json"])
942 assert result.exit_code == 0
943 data = _json_show(result)
944 assert "user" in data
945 assert "domain" in data
946 assert "limits" in data
947
948 def test_json_empty_config_has_no_sections(self, repo: pathlib.Path) -> None:
949 result = runner.invoke(cli, ["config", "read", "--json"])
950 assert result.exit_code == 0
951 data = _json_show(result)
952 # Config sections absent; only timing/status keys present
953 for section in ("user", "hub", "remotes", "domain", "limits"):
954 assert section not in data
955
956 def test_json_no_credentials(self, repo: pathlib.Path) -> None:
957 """Credentials (auth, token) must never appear in JSON output."""
958 # Write a config with a fake [auth] section directly
959 (repo / ".muse" / "config.toml").write_text(
960 '[user]\nname = "Alice"\n\n[auth]\ntoken = "secret"\n'
961 )
962 result = runner.invoke(cli, ["config", "read", "--json"])
963 assert result.exit_code == 0
964 assert "secret" not in result.output
965 assert "token" not in result.output
966
967 # ── text mode structure ───────────────────────────────────────────────────
968
969 def test_text_output_to_stdout(self, repo: pathlib.Path) -> None:
970 """Text mode config content goes to stdout, not only stderr."""
971 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
972 result = runner.invoke(cli, ["config", "read"])
973 assert result.exit_code == 0
974 assert "[user]" in result.output
975
976 def test_text_user_section_header(self, repo: pathlib.Path) -> None:
977 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
978 result = runner.invoke(cli, ["config", "read"])
979 assert "[user]" in result.output
980
981 def test_text_hub_section_header(self, repo: pathlib.Path) -> None:
982 from muse.cli.config import set_hub_url
983 set_hub_url("https://musehub.ai", repo)
984 result = runner.invoke(cli, ["config", "read"])
985 assert "[hub]" in result.output
986
987 def test_text_remotes_section_header(self, repo: pathlib.Path) -> None:
988 from muse.cli.config import set_remote
989 set_remote("origin", "https://hub.example.com/owner/repo", repo)
990 result = runner.invoke(cli, ["config", "read"])
991 assert "[remotes." in result.output
992
993 def test_text_domain_section_header(self, repo: pathlib.Path) -> None:
994 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"])
995 result = runner.invoke(cli, ["config", "read"])
996 assert "[domain]" in result.output
997
998 def test_text_key_value_format(self, repo: pathlib.Path) -> None:
999 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1000 result = runner.invoke(cli, ["config", "read"])
1001 # TOML key = "value" format
1002 assert 'handle = "Alice"' in result.output
1003
1004 def test_text_limits_no_quotes_on_values(self, repo: pathlib.Path) -> None:
1005 """Limits values are integers in TOML — no quotes."""
1006 runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "8000"])
1007 result = runner.invoke(cli, ["config", "read"])
1008 assert "max_walk_commits = 8000" in result.output
1009
1010 def test_read_help_contains_quickstart(self) -> None:
1011 result = runner.invoke(cli, ["config", "read", "--help"])
1012 assert result.exit_code == 0
1013 assert "quickstart" in result.output.lower() or "jq" in result.output
1014
1015 def test_read_help_contains_exit_codes(self) -> None:
1016 result = runner.invoke(cli, ["config", "read", "--help"])
1017 assert result.exit_code == 0
1018 assert "Exit codes" in result.output or "exit" in result.output.lower()
1019
1020
1021 class TestRunReadStress:
1022 """Stress tests for ``muse config read``."""
1023
1024 def test_concurrent_read_calls(self, repo: pathlib.Path) -> None:
1025 """Concurrent config_as_dict reads of the same config must all succeed.
1026
1027 Uses config_as_dict directly — CliRunner shares a buffer across threads
1028 so full CLI invocations cannot be called concurrently from different threads.
1029 """
1030 import threading
1031 from muse.cli.config import config_as_dict, set_config_value
1032 set_config_value("user.handle", "Alice", repo)
1033 set_config_value("limits.max_walk_commits", "1000", repo)
1034 errors: list[str] = []
1035
1036 def _do(idx: int) -> None:
1037 try:
1038 data = config_as_dict(repo)
1039 assert "user" in data
1040 assert data["user"]["handle"] == "Alice"
1041 except Exception as exc:
1042 errors.append(f"Thread {idx}: {exc}")
1043
1044 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
1045 for t in threads:
1046 t.start()
1047 for t in threads:
1048 t.join()
1049 assert errors == [], "\n".join(errors)
1050
1051 def test_read_large_domain_config(self, repo: pathlib.Path) -> None:
1052 """Read handles a config with many domain keys without truncation."""
1053 for i in range(20):
1054 runner.invoke(cli, ["config", "set", f"domain.key_{i}", str(i * 10)])
1055 result = runner.invoke(cli, ["config", "read", "--json"])
1056 assert result.exit_code == 0
1057 data = _json_show(result)
1058 domain = data.get("domain")
1059 assert isinstance(domain, dict)
1060 assert len(domain) == 20
1061
1062 def test_read_json_output_is_valid_json(self, repo: pathlib.Path) -> None:
1063 """JSON output must always be parseable regardless of config contents."""
1064 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1065 runner.invoke(cli, ["config", "set", "user.email", "[email protected]"])
1066 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"])
1067 runner.invoke(cli, ["config", "set", "limits.max_ancestors", "50000"])
1068 result = runner.invoke(cli, ["config", "read", "--json"])
1069 assert result.exit_code == 0
1070 # json.loads raises on invalid JSON
1071 parsed = json.loads(result.output)
1072 assert isinstance(parsed, dict)
1073
1074
1075 # =============================================================================
1076 # muse config get — extended hardening
1077 # =============================================================================
1078
1079
1080 class TestRunGetExtended:
1081 """Additional coverage for ``muse config get`` gaps."""
1082
1083 # ── flag aliases ──────────────────────────────────────────────────────────
1084
1085 def test_j_short_flag_emits_json(self, repo: pathlib.Path) -> None:
1086 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1087 result = runner.invoke(cli, ["config", "get", "user.handle", "-j"])
1088 assert result.exit_code == 0
1089 data = _json_get(result)
1090 assert data["value"] == "Alice"
1091
1092 def test_j_same_as_json_flag(self, repo: pathlib.Path) -> None:
1093 runner.invoke(cli, ["config", "set", "user.handle", "Bob"])
1094 r1 = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1095 r2 = runner.invoke(cli, ["config", "get", "user.handle", "-j"])
1096 assert r1.exit_code == 0 and r2.exit_code == 0
1097 # Compare only stable fields — timing fields naturally differ between calls
1098 skip = {"duration_ms", "timestamp"}
1099 d1 = {k: v for k, v in _json_get(r1).items() if k not in skip}
1100 d2 = {k: v for k, v in _json_get(r2).items() if k not in skip}
1101 assert d1 == d2
1102
1103 # ── key format validation ─────────────────────────────────────────────────
1104
1105 def test_key_without_dot_exits_nonzero(self, repo: pathlib.Path) -> None:
1106 result = runner.invoke(cli, ["config", "get", "username"])
1107 assert result.exit_code != 0
1108
1109 def test_key_without_dot_shows_format_hint(self, repo: pathlib.Path) -> None:
1110 result = runner.invoke(cli, ["config", "get", "username"])
1111 assert "namespace.subkey" in result.output or "user.handle" in result.output
1112
1113 def test_key_without_dot_does_not_say_not_set(
1114 self, repo: pathlib.Path
1115 ) -> None:
1116 """Malformed key must not produce the misleading 'is not set' message."""
1117 result = runner.invoke(cli, ["config", "get", "badkey"])
1118 assert "is not set" not in result.output
1119
1120 def test_empty_key_exits_nonzero(self, repo: pathlib.Path) -> None:
1121 result = runner.invoke(cli, ["config", "get", ""])
1122 assert result.exit_code != 0
1123
1124 def test_unknown_namespace_exits_nonzero(self, repo: pathlib.Path) -> None:
1125 result = runner.invoke(cli, ["config", "get", "unknown.key"])
1126 assert result.exit_code != 0
1127
1128 # ── all supported key namespaces ──────────────────────────────────────────
1129
1130 def test_get_user_email(self, repo: pathlib.Path) -> None:
1131 runner.invoke(cli, ["config", "set", "user.email", "[email protected]"])
1132 result = runner.invoke(cli, ["config", "get", "user.email"])
1133 assert result.exit_code == 0
1134 assert "[email protected]" in result.output
1135
1136 def test_get_user_type(self, repo: pathlib.Path) -> None:
1137 runner.invoke(cli, ["config", "set", "user.type", "agent"])
1138 result = runner.invoke(cli, ["config", "get", "user.type"])
1139 assert result.exit_code == 0
1140 assert "agent" in result.output
1141
1142 def test_get_hub_url(self, repo: pathlib.Path) -> None:
1143 from muse.cli.config import set_hub_url
1144 set_hub_url("https://musehub.ai", repo)
1145 result = runner.invoke(cli, ["config", "get", "hub.url"])
1146 assert result.exit_code == 0
1147 assert "musehub.ai" in result.output
1148
1149 def test_get_domain_key(self, repo: pathlib.Path) -> None:
1150 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "960"])
1151 result = runner.invoke(cli, ["config", "get", "domain.ticks_per_beat"])
1152 assert result.exit_code == 0
1153 assert "960" in result.output
1154
1155 def test_get_limits_max_ancestors(self, repo: pathlib.Path) -> None:
1156 runner.invoke(cli, ["config", "set", "limits.max_ancestors", "20000"])
1157 result = runner.invoke(cli, ["config", "get", "limits.max_ancestors"])
1158 assert result.exit_code == 0
1159 assert "20000" in result.output
1160
1161 def test_get_limits_max_graph_commits(self, repo: pathlib.Path) -> None:
1162 runner.invoke(cli, ["config", "set", "limits.max_graph_commits", "500"])
1163 result = runner.invoke(cli, ["config", "get", "limits.max_graph_commits"])
1164 assert result.exit_code == 0
1165 assert "500" in result.output
1166
1167 def test_get_limits_shard_prefix_length(self, repo: pathlib.Path) -> None:
1168 runner.invoke(cli, ["config", "set", "limits.shard_prefix_length", "4"])
1169 result = runner.invoke(cli, ["config", "get", "limits.shard_prefix_length"])
1170 assert result.exit_code == 0
1171 assert "4" in result.output
1172
1173 # ── JSON structure ────────────────────────────────────────────────────────
1174
1175 def test_json_key_field_matches_input(self, repo: pathlib.Path) -> None:
1176 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1177 result = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1178 assert result.exit_code == 0
1179 data = _json_get(result)
1180 assert data["key"] == "user.handle"
1181
1182 def test_json_value_matches_text_output(self, repo: pathlib.Path) -> None:
1183 runner.invoke(cli, ["config", "set", "user.handle", "Charlie"])
1184 r_text = runner.invoke(cli, ["config", "get", "user.handle"])
1185 r_json = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1186 assert r_text.exit_code == 0 and r_json.exit_code == 0
1187 json_value = _json_get(r_json)["value"]
1188 assert json_value in r_text.output
1189
1190 def test_json_missing_key_exits_nonzero(self, repo: pathlib.Path) -> None:
1191 result = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1192 assert result.exit_code != 0
1193
1194 def test_json_stdout_clean_on_success(self, repo: pathlib.Path) -> None:
1195 runner.invoke(cli, ["config", "set", "user.handle", "Dave"])
1196 result = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1197 assert result.exit_code == 0
1198 # Output must be valid JSON
1199 parsed = json.loads(result.output)
1200 assert "key" in parsed and "value" in parsed
1201
1202 # ── text mode output ──────────────────────────────────────────────────────
1203
1204 def test_text_value_printed_to_stdout(self, repo: pathlib.Path) -> None:
1205 runner.invoke(cli, ["config", "set", "user.handle", "Eve"])
1206 result = runner.invoke(cli, ["config", "get", "user.handle"])
1207 assert result.exit_code == 0
1208 assert "Eve" in result.output
1209
1210 def test_text_error_goes_to_stderr_marker(self, repo: pathlib.Path) -> None:
1211 """Not-set error must not appear in stdout (goes to stderr)."""
1212 result = runner.invoke(cli, ["config", "get", "user.handle"])
1213 # CliRunner merges stderr — ensure exit is nonzero and message present
1214 assert result.exit_code != 0
1215 assert "not set" in result.output or "not" in result.output.lower()
1216
1217 def test_get_help_contains_key_reference(self) -> None:
1218 result = runner.invoke(cli, ["config", "get", "--help"])
1219 assert result.exit_code == 0
1220 assert "user.handle" in result.output or "hub.url" in result.output
1221
1222 def test_get_help_contains_exit_codes(self) -> None:
1223 result = runner.invoke(cli, ["config", "get", "--help"])
1224 assert result.exit_code == 0
1225 assert "Exit codes" in result.output or "exit" in result.output.lower()
1226
1227 # ── outside repo ─────────────────────────────────────────────────────────
1228
1229 def test_get_outside_repo_key_not_set(
1230 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1231 ) -> None:
1232 """Outside a repo, all keys return not-set (gracefully)."""
1233 monkeypatch.chdir(tmp_path)
1234 result = runner.invoke(cli, ["config", "get", "user.handle"])
1235 assert result.exit_code != 0
1236
1237
1238 class TestRunGetStress:
1239 """Stress tests for ``muse config get``."""
1240
1241 def test_concurrent_get_reads(self, repo: pathlib.Path) -> None:
1242 """Concurrent config_as_dict reads are thread-safe."""
1243 import threading
1244 from muse.cli.config import get_config_value, set_config_value
1245 set_config_value("user.handle", "Stress", repo)
1246 errors: list[str] = []
1247
1248 def _do(idx: int) -> None:
1249 try:
1250 value = get_config_value("user.handle", repo)
1251 assert value == "Stress"
1252 except Exception as exc:
1253 errors.append(f"Thread {idx}: {exc}")
1254
1255 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
1256 for t in threads:
1257 t.start()
1258 for t in threads:
1259 t.join()
1260 assert errors == [], "\n".join(errors)
1261
1262 def test_all_limits_keys_readable(self, repo: pathlib.Path) -> None:
1263 """All four limits keys must be individually gettable after set."""
1264 pairs = [
1265 ("limits.max_walk_commits", "9000"),
1266 ("limits.max_ancestors", "40000"),
1267 ("limits.max_graph_commits", "250"),
1268 ("limits.shard_prefix_length", "4"),
1269 ]
1270 for key, val in pairs:
1271 runner.invoke(cli, ["config", "set", key, val])
1272 for key, expected in pairs:
1273 result = runner.invoke(cli, ["config", "get", key])
1274 assert result.exit_code == 0, f"Failed for {key}"
1275 assert expected in result.output, f"Expected {expected!r} for {key}"
1276
1277 def test_get_many_different_keys_sequentially(
1278 self, repo: pathlib.Path
1279 ) -> None:
1280 """Reading many keys in sequence must not corrupt state."""
1281 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1282 runner.invoke(cli, ["config", "set", "user.email", "[email protected]"])
1283 runner.invoke(cli, ["config", "set", "user.type", "human"])
1284 runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"])
1285 runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "1000"])
1286 for _ in range(20):
1287 for key, expected in [
1288 ("user.handle", "Alice"),
1289 ("user.email", "[email protected]"),
1290 ("user.type", "human"),
1291 ("domain.ticks_per_beat", "480"),
1292 ("limits.max_walk_commits", "1000"),
1293 ]:
1294 result = runner.invoke(cli, ["config", "get", key])
1295 assert result.exit_code == 0
1296 assert expected in result.output
1297
1298
1299 # ── Extended: run_set ─────────────────────────────────────────────────────────
1300
1301
1302 class TestRunSetExtended:
1303 """Extended hardening tests for ``muse config set``."""
1304
1305 def test_j_alias_works(self, repo: pathlib.Path) -> None:
1306 result = runner.invoke(cli, ["config", "set", "user.handle", "Alice", "-j"])
1307 assert result.exit_code == 0
1308 data = _json_set(result)
1309 assert data["status"] == "ok"
1310 assert data["key"] == "user.handle"
1311 assert data["value"] == "Alice"
1312
1313 def test_key_without_dot_exits_with_format_error(self, repo: pathlib.Path) -> None:
1314 result = runner.invoke(cli, ["config", "set", "username", "Alice"])
1315 assert result.exit_code != 0
1316 assert "namespace.subkey" in result.output
1317
1318 def test_key_without_dot_mentions_example(self, repo: pathlib.Path) -> None:
1319 result = runner.invoke(cli, ["config", "set", "badkey", "val"])
1320 assert result.exit_code != 0
1321 assert "user.handle" in result.output or "hub.url" in result.output
1322
1323 def test_unknown_namespace_exits_nonzero(self, repo: pathlib.Path) -> None:
1324 result = runner.invoke(cli, ["config", "set", "mystery.key", "val"])
1325 assert result.exit_code != 0
1326
1327 def test_unknown_namespace_error_message_helpful(self, repo: pathlib.Path) -> None:
1328 result = runner.invoke(cli, ["config", "set", "mystery.key", "val"])
1329 assert "mystery" in result.output or "Unknown" in result.output
1330
1331 def test_user_name_settable(self, repo: pathlib.Path) -> None:
1332 result = runner.invoke(cli, ["config", "set", "user.handle", "Bob"])
1333 assert result.exit_code == 0
1334
1335 def test_user_email_settable(self, repo: pathlib.Path) -> None:
1336 result = runner.invoke(cli, ["config", "set", "user.email", "[email protected]"])
1337 assert result.exit_code == 0
1338
1339 def test_user_type_human_settable(self, repo: pathlib.Path) -> None:
1340 result = runner.invoke(cli, ["config", "set", "user.type", "human"])
1341 assert result.exit_code == 0
1342
1343 def test_user_type_agent_settable(self, repo: pathlib.Path) -> None:
1344 result = runner.invoke(cli, ["config", "set", "user.type", "agent"])
1345 assert result.exit_code == 0
1346
1347 def test_hub_url_https_settable(self, repo: pathlib.Path) -> None:
1348 result = runner.invoke(cli, ["config", "set", "hub.url", "https://musehub.ai"])
1349 assert result.exit_code == 0
1350
1351 def test_hub_url_http_rejected(self, repo: pathlib.Path) -> None:
1352 result = runner.invoke(cli, ["config", "set", "hub.url", "http://musehub.ai"])
1353 assert result.exit_code != 0
1354
1355 def test_hub_unknown_subkey_rejected(self, repo: pathlib.Path) -> None:
1356 result = runner.invoke(cli, ["config", "set", "hub.secret", "val"])
1357 assert result.exit_code != 0
1358
1359 def test_domain_key_settable(self, repo: pathlib.Path) -> None:
1360 result = runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"])
1361 assert result.exit_code == 0
1362
1363 def test_limits_max_ancestors_settable(self, repo: pathlib.Path) -> None:
1364 result = runner.invoke(cli, ["config", "set", "limits.max_ancestors", "25000"])
1365 assert result.exit_code == 0
1366 get_result = runner.invoke(cli, ["config", "get", "limits.max_ancestors"])
1367 assert "25000" in get_result.output
1368
1369 def test_limits_max_graph_commits_settable(self, repo: pathlib.Path) -> None:
1370 result = runner.invoke(cli, ["config", "set", "limits.max_graph_commits", "5000"])
1371 assert result.exit_code == 0
1372 get_result = runner.invoke(cli, ["config", "get", "limits.max_graph_commits"])
1373 assert "5000" in get_result.output
1374
1375 def test_limits_shard_prefix_2_valid(self, repo: pathlib.Path) -> None:
1376 result = runner.invoke(cli, ["config", "set", "limits.shard_prefix_length", "2"])
1377 assert result.exit_code == 0
1378
1379 def test_limits_shard_prefix_4_valid(self, repo: pathlib.Path) -> None:
1380 result = runner.invoke(cli, ["config", "set", "limits.shard_prefix_length", "4"])
1381 assert result.exit_code == 0
1382
1383 def test_limits_shard_prefix_1_rejected(self, repo: pathlib.Path) -> None:
1384 result = runner.invoke(cli, ["config", "set", "limits.shard_prefix_length", "1"])
1385 assert result.exit_code != 0
1386
1387 def test_limits_shard_prefix_3_rejected(self, repo: pathlib.Path) -> None:
1388 result = runner.invoke(cli, ["config", "set", "limits.shard_prefix_length", "3"])
1389 assert result.exit_code != 0
1390
1391 def test_limits_negative_rejected(self, repo: pathlib.Path) -> None:
1392 result = runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "-5"])
1393 assert result.exit_code != 0
1394
1395 def test_blocked_auth_shows_redirect(self, repo: pathlib.Path) -> None:
1396 result = runner.invoke(cli, ["config", "set", "auth.token", "secret"])
1397 assert result.exit_code != 0
1398 assert "auth" in result.output.lower() or "login" in result.output.lower()
1399
1400 def test_blocked_remotes_shows_redirect(self, repo: pathlib.Path) -> None:
1401 result = runner.invoke(cli, ["config", "set", "remotes.origin", "url"])
1402 assert result.exit_code != 0
1403 assert "remote" in result.output.lower()
1404
1405 def test_success_json_status_ok(self, repo: pathlib.Path) -> None:
1406 result = runner.invoke(cli, ["config", "set", "user.handle", "X", "--json"])
1407 data = _json_set(result)
1408 assert data["status"] == "ok"
1409
1410 def test_success_json_stdout_only_has_json(self, repo: pathlib.Path) -> None:
1411 result = runner.invoke(cli, ["config", "set", "user.handle", "Y", "--json"])
1412 assert result.exit_code == 0
1413 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
1414 assert len(json_lines) >= 1
1415
1416 def test_success_text_goes_to_output(self, repo: pathlib.Path) -> None:
1417 result = runner.invoke(cli, ["config", "set", "user.handle", "Carol"])
1418 assert result.exit_code == 0
1419 assert "Carol" in result.output
1420
1421 def test_help_contains_settable_namespaces(self, repo: pathlib.Path) -> None:
1422 result = runner.invoke(cli, ["config", "set", "--help"])
1423 assert "user.handle" in result.output
1424 assert "hub.url" in result.output
1425 assert "domain" in result.output
1426 assert "limits" in result.output
1427
1428 def test_help_contains_blocked_namespaces(self, repo: pathlib.Path) -> None:
1429 result = runner.invoke(cli, ["config", "set", "--help"])
1430 assert "auth" in result.output
1431 assert "remotes" in result.output
1432
1433 def test_help_mentions_exit_codes(self, repo: pathlib.Path) -> None:
1434 result = runner.invoke(cli, ["config", "set", "--help"])
1435 assert "Exit" in result.output or "exit" in result.output
1436
1437
1438 # ── Security: run_set ─────────────────────────────────────────────────────────
1439
1440
1441 class TestRunSetSecurity:
1442 """Security-focused tests for ``muse config set``."""
1443
1444 def test_ansi_in_key_sanitized_in_error(self, repo: pathlib.Path) -> None:
1445 result = runner.invoke(cli, ["config", "set", "domain.\x1b[31mevil\x1b[0m\nkey", "val"])
1446 assert result.exit_code != 0
1447 assert "\x1b[" not in result.output
1448
1449 def test_ansi_in_value_sanitized_in_text_success(self, repo: pathlib.Path) -> None:
1450 result = runner.invoke(cli, ["config", "set", "user.handle", "\x1b[31mBob\x1b[0m"])
1451 assert result.exit_code == 0
1452 assert "\x1b[" not in result.output
1453
1454 def test_null_byte_in_domain_key_rejected(self, repo: pathlib.Path) -> None:
1455 result = runner.invoke(cli, ["config", "set", "domain.evil\x00key", "val"])
1456 assert result.exit_code != 0
1457
1458 def test_bracket_in_domain_key_rejected(self, repo: pathlib.Path) -> None:
1459 result = runner.invoke(cli, ["config", "set", "domain.x][evil", "val"])
1460 assert result.exit_code != 0
1461
1462 def test_equals_in_domain_key_rejected(self, repo: pathlib.Path) -> None:
1463 result = runner.invoke(cli, ["config", "set", "domain.key=evil", "val"])
1464 assert result.exit_code != 0
1465
1466 def test_newline_in_domain_key_rejected(self, repo: pathlib.Path) -> None:
1467 result = runner.invoke(cli, ["config", "set", "domain.evil\nkey", "val"])
1468 assert result.exit_code != 0
1469
1470 def test_key_without_dot_shows_format_hint_not_generic_error(self, repo: pathlib.Path) -> None:
1471 """Ensures format error, not a generic 'unknown namespace' message."""
1472 result = runner.invoke(cli, ["config", "set", "nodot", "val"])
1473 assert result.exit_code != 0
1474 assert "namespace.subkey" in result.output
1475
1476 def test_hub_http_blocked_not_stored(self, repo: pathlib.Path) -> None:
1477 runner.invoke(cli, ["config", "set", "hub.url", "http://evil.example.com"])
1478 result = runner.invoke(cli, ["config", "get", "hub.url"])
1479 assert result.exit_code != 0 or "evil.example.com" not in result.output
1480
1481
1482 # ── Stress: run_set ───────────────────────────────────────────────────────────
1483
1484
1485 class TestRunSetStress:
1486 """Concurrency and volume tests for ``muse config set``."""
1487
1488 def test_concurrent_set_to_different_repos_no_corruption(
1489 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1490 ) -> None:
1491 """8 threads writing to separate repos must each produce correct values."""
1492 import threading
1493 from muse._version import __version__
1494 from muse.cli.config import get_config_value, set_config_value
1495
1496 errors: list[str] = []
1497
1498 def _worker(idx: int) -> None:
1499 repo_path = tmp_path / f"repo_{idx}"
1500 muse_dir = repo_path / ".muse"
1501 for sub in ("refs/heads", "objects", "commits", "snapshots"):
1502 (muse_dir / sub).mkdir(parents=True, exist_ok=True)
1503 (muse_dir / "repo.json").write_text(
1504 json.dumps({"repo_id": f"repo-{idx}", "schema_version": __version__, "domain": "code"})
1505 )
1506 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
1507 (muse_dir / "config.toml").write_text("")
1508 expected = f"user_{idx}"
1509 set_config_value("user.handle", expected, repo_path)
1510 got = get_config_value("user.handle", repo_path)
1511 if got != expected:
1512 errors.append(f"repo_{idx}: expected {expected!r}, got {got!r}")
1513
1514 threads = [threading.Thread(target=_worker, args=(i,)) for i in range(8)]
1515 for t in threads:
1516 t.start()
1517 for t in threads:
1518 t.join()
1519 assert errors == [], "\n".join(errors)
1520
1521 def test_all_four_limits_keys_set_round_trip(self, repo: pathlib.Path) -> None:
1522 """All four limits keys written then read back."""
1523 from muse.cli.config import get_config_value, set_config_value
1524
1525 pairs = [
1526 ("limits.max_walk_commits", "10000"),
1527 ("limits.max_ancestors", "5000"),
1528 ("limits.max_graph_commits", "2500"),
1529 ("limits.shard_prefix_length", "4"),
1530 ]
1531 for key, val in pairs:
1532 set_config_value(key, val, repo)
1533 for key, expected in pairs:
1534 got = get_config_value(key, repo)
1535 assert got == expected, f"{key}: expected {expected!r}, got {got!r}"
1536
1537 def test_20_sequential_sets_no_state_corruption(self, repo: pathlib.Path) -> None:
1538 """Writing 20 different user.handle values sequentially — last write wins."""
1539 from muse.cli.config import get_config_value, set_config_value
1540
1541 for i in range(20):
1542 set_config_value("user.handle", f"user_{i}", repo)
1543 got = get_config_value("user.handle", repo)
1544 assert got == "user_19"
1545
1546
1547 # ── Extended: run_edit ────────────────────────────────────────────────────────
1548
1549
1550 class TestRunEditExtended:
1551 """Extended hardening tests for ``muse config edit``."""
1552
1553 def test_visual_takes_precedence_over_editor(
1554 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1555 ) -> None:
1556 """$VISUAL must be used when both $VISUAL and $EDITOR are set."""
1557 calls: list[list[str]] = []
1558
1559 import subprocess as _sp
1560
1561 real_run = _sp.run
1562
1563 def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]:
1564 calls.append(cmd)
1565 return real_run(["true"], **{k: v for k, v in kwargs.items()})
1566
1567 monkeypatch.setattr(_sp, "run", _fake_run)
1568 monkeypatch.setenv("VISUAL", "visual-editor")
1569 monkeypatch.setenv("EDITOR", "editor-fallback")
1570 runner.invoke(cli, ["config", "edit"])
1571 assert calls and calls[0][0] == "visual-editor"
1572
1573 def test_editor_used_when_visual_unset(
1574 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1575 ) -> None:
1576 calls: list[list[str]] = []
1577
1578 import subprocess as _sp
1579
1580 real_run = _sp.run
1581
1582 def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]:
1583 calls.append(cmd)
1584 return real_run(["true"], **{k: v for k, v in kwargs.items()})
1585
1586 monkeypatch.setattr(_sp, "run", _fake_run)
1587 monkeypatch.delenv("VISUAL", raising=False)
1588 monkeypatch.setenv("EDITOR", "my-editor")
1589 runner.invoke(cli, ["config", "edit"])
1590 assert calls and calls[0][0] == "my-editor"
1591
1592 def test_vi_fallback_when_both_unset(
1593 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1594 ) -> None:
1595 calls: list[list[str]] = []
1596
1597 import subprocess as _sp
1598
1599 real_run = _sp.run
1600
1601 def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]:
1602 calls.append(cmd)
1603 return real_run(["true"], **{k: v for k, v in kwargs.items()})
1604
1605 monkeypatch.setattr(_sp, "run", _fake_run)
1606 monkeypatch.delenv("VISUAL", raising=False)
1607 monkeypatch.delenv("EDITOR", raising=False)
1608 runner.invoke(cli, ["config", "edit"])
1609 assert calls and calls[0][0] == "vi"
1610
1611 def test_multiword_editor_split_correctly(
1612 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1613 ) -> None:
1614 """EDITOR='code --wait' must be split to ['code', '--wait', path]."""
1615 calls: list[list[str]] = []
1616
1617 import subprocess as _sp
1618
1619 real_run = _sp.run
1620
1621 def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]:
1622 calls.append(cmd)
1623 return real_run(["true"], **{k: v for k, v in kwargs.items()})
1624
1625 monkeypatch.setattr(_sp, "run", _fake_run)
1626 monkeypatch.setenv("EDITOR", "code --wait")
1627 monkeypatch.delenv("VISUAL", raising=False)
1628 runner.invoke(cli, ["config", "edit"])
1629 assert calls and calls[0][:2] == ["code", "--wait"]
1630
1631 def test_multiword_editor_passes_config_path(
1632 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1633 ) -> None:
1634 """Multi-word editor: config path must be the final argument."""
1635 calls: list[list[str]] = []
1636
1637 import subprocess as _sp
1638
1639 real_run = _sp.run
1640
1641 def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]:
1642 calls.append(cmd)
1643 return real_run(["true"], **{k: v for k, v in kwargs.items()})
1644
1645 monkeypatch.setattr(_sp, "run", _fake_run)
1646 monkeypatch.setenv("EDITOR", "emacs -nw")
1647 monkeypatch.delenv("VISUAL", raising=False)
1648 runner.invoke(cli, ["config", "edit"])
1649 assert calls and "config.toml" in calls[0][-1]
1650
1651 def test_auto_create_config_when_missing(
1652 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1653 ) -> None:
1654 (repo / ".muse" / "config.toml").unlink()
1655 monkeypatch.setenv("EDITOR", "true")
1656 monkeypatch.delenv("VISUAL", raising=False)
1657 assert not (repo / ".muse" / "config.toml").exists()
1658 result = runner.invoke(cli, ["config", "edit"])
1659 assert result.exit_code == 0
1660 assert (repo / ".muse" / "config.toml").exists()
1661
1662 def test_auto_create_info_message_on_stderr(
1663 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1664 ) -> None:
1665 (repo / ".muse" / "config.toml").unlink()
1666 monkeypatch.setenv("EDITOR", "true")
1667 monkeypatch.delenv("VISUAL", raising=False)
1668 result = runner.invoke(cli, ["config", "edit"])
1669 assert "Created" in result.output or "config.toml" in result.output
1670
1671 def test_editor_invoked_as_list_not_shell(
1672 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1673 ) -> None:
1674 """subprocess.run must receive a list, never shell=True."""
1675 captured: MsgpackDict = {}
1676
1677 import subprocess as _sp
1678
1679 def _fake_run(cmd: list[str] | str, **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]:
1680 captured["cmd"] = cmd
1681 captured["shell"] = kwargs.get("shell", False)
1682 from subprocess import CompletedProcess
1683 return CompletedProcess([], 0)
1684
1685 monkeypatch.setattr(_sp, "run", _fake_run)
1686 monkeypatch.setenv("EDITOR", "true")
1687 monkeypatch.delenv("VISUAL", raising=False)
1688 runner.invoke(cli, ["config", "edit"])
1689 assert isinstance(captured.get("cmd"), list)
1690 assert not captured.get("shell")
1691
1692 def test_editor_nonzero_exit_exits_nonzero(
1693 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1694 ) -> None:
1695 monkeypatch.setenv("EDITOR", "false") # /bin/false always exits 1
1696 monkeypatch.delenv("VISUAL", raising=False)
1697 result = runner.invoke(cli, ["config", "edit"])
1698 assert result.exit_code != 0
1699
1700 def test_editor_nonzero_exit_message_contains_code(
1701 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1702 ) -> None:
1703 monkeypatch.setenv("EDITOR", "false")
1704 monkeypatch.delenv("VISUAL", raising=False)
1705 result = runner.invoke(cli, ["config", "edit"])
1706 assert result.exit_code != 0
1707 # Message should mention the exit code number
1708 assert any(ch.isdigit() for ch in result.output)
1709
1710 def test_outside_repo_exits_nonzero(
1711 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1712 ) -> None:
1713 monkeypatch.chdir(tmp_path)
1714 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1715 result = runner.invoke(cli, ["config", "edit"])
1716 assert result.exit_code != 0
1717
1718 def test_outside_repo_error_message(
1719 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1720 ) -> None:
1721 monkeypatch.chdir(tmp_path)
1722 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
1723 result = runner.invoke(cli, ["config", "edit"])
1724 assert "repository" in result.output.lower() or "repo" in result.output.lower()
1725
1726 def test_help_mentions_visual(self, repo: pathlib.Path) -> None:
1727 result = runner.invoke(cli, ["config", "edit", "--help"])
1728 assert "VISUAL" in result.output
1729
1730 def test_help_mentions_editor(self, repo: pathlib.Path) -> None:
1731 result = runner.invoke(cli, ["config", "edit", "--help"])
1732 assert "EDITOR" in result.output
1733
1734 def test_help_mentions_agent_alternative(self, repo: pathlib.Path) -> None:
1735 result = runner.invoke(cli, ["config", "edit", "--help"])
1736 assert "muse config set" in result.output or "agent" in result.output
1737
1738 def test_help_mentions_exit_codes(self, repo: pathlib.Path) -> None:
1739 result = runner.invoke(cli, ["config", "edit", "--help"])
1740 assert "Exit" in result.output or "exit" in result.output
1741
1742 def test_config_path_passed_to_editor(
1743 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1744 ) -> None:
1745 """Editor must receive the config.toml path as its argument."""
1746 calls: list[list[str]] = []
1747
1748 import subprocess as _sp
1749
1750 real_run = _sp.run
1751
1752 def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]:
1753 calls.append(cmd)
1754 return real_run(["true"], **{k: v for k, v in kwargs.items()})
1755
1756 monkeypatch.setattr(_sp, "run", _fake_run)
1757 monkeypatch.setenv("EDITOR", "my-editor")
1758 monkeypatch.delenv("VISUAL", raising=False)
1759 runner.invoke(cli, ["config", "edit"])
1760 assert calls
1761 assert str(repo / ".muse" / "config.toml") in calls[0]
1762
1763
1764 # ── Security: run_edit ────────────────────────────────────────────────────────
1765
1766
1767 class TestRunEditSecurity:
1768 """Security-focused tests for ``muse config edit``."""
1769
1770 def test_ansi_in_editor_env_sanitized_in_error(
1771 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1772 ) -> None:
1773 monkeypatch.setenv("EDITOR", "\x1b[31mevil\x1b[0m-editor")
1774 monkeypatch.delenv("VISUAL", raising=False)
1775 result = runner.invoke(cli, ["config", "edit"])
1776 assert result.exit_code != 0
1777 assert "\x1b[" not in result.output
1778
1779 def test_editor_invoked_without_shell(
1780 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1781 ) -> None:
1782 """Verify shell=True is never passed to subprocess.run."""
1783 captured: MsgpackDict = {}
1784
1785 import subprocess as _sp
1786
1787 def _fake_run(cmd: list[str] | str, **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]:
1788 captured["shell"] = kwargs.get("shell", False)
1789 from subprocess import CompletedProcess
1790 return CompletedProcess([], 0)
1791
1792 monkeypatch.setattr(_sp, "run", _fake_run)
1793 monkeypatch.setenv("EDITOR", "true")
1794 monkeypatch.delenv("VISUAL", raising=False)
1795 runner.invoke(cli, ["config", "edit"])
1796 assert not captured.get("shell")
1797
1798 def test_malformed_editor_command_exits_gracefully(
1799 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1800 ) -> None:
1801 """An unparseable $EDITOR value must exit cleanly, not crash."""
1802 # shlex.split raises ValueError on unmatched quotes
1803 monkeypatch.setenv("EDITOR", "editor 'unclosed quote")
1804 monkeypatch.delenv("VISUAL", raising=False)
1805 result = runner.invoke(cli, ["config", "edit"])
1806 assert result.exit_code != 0
1807
1808 def test_shell_metacharacters_in_editor_not_shell_executed(
1809 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1810 ) -> None:
1811 """$EDITOR with shell metacharacters must not trigger shell execution.
1812
1813 shlex.split turns 'true; echo injected' into ['true;', 'echo', 'injected'].
1814 subprocess.run receives a list (never shell=True), so it tries to exec a
1815 binary literally named 'true;' — which doesn't exist. FileNotFoundError
1816 fires; no shell command is ever evaluated.
1817 """
1818 monkeypatch.setenv("EDITOR", "true; echo injected")
1819 monkeypatch.delenv("VISUAL", raising=False)
1820 result = runner.invoke(cli, ["config", "edit"])
1821 # Binary 'true;' doesn't exist → non-zero exit; no shell evaluation.
1822 assert result.exit_code != 0
1823 # Error message quotes the editor string, not the result of shell execution.
1824 assert "Editor not found" in result.output
1825
1826
1827 # =============================================================================
1828 # Supercharge — duration_ms + exit_code in all JSON paths
1829 # =============================================================================
1830
1831 _GET_FULL_KEYS = frozenset({"key", "value", "duration_ms", "exit_code"})
1832 _SET_FULL_KEYS = frozenset({"status", "key", "value", "duration_ms", "exit_code"})
1833
1834
1835 class TestElapsedSeconds:
1836 """duration_ms must appear in every JSON output path."""
1837
1838 def test_read_json_has_elapsed(self, repo: pathlib.Path) -> None:
1839 result = runner.invoke(cli, ["config", "read", "--json"])
1840 assert result.exit_code == 0
1841 data = json.loads(result.output)
1842 assert "duration_ms" in data
1843
1844 def test_read_elapsed_is_float(self, repo: pathlib.Path) -> None:
1845 result = runner.invoke(cli, ["config", "read", "--json"])
1846 assert result.exit_code == 0
1847 data = json.loads(result.output)
1848 assert isinstance(data["duration_ms"], float)
1849
1850 def test_read_elapsed_non_negative(self, repo: pathlib.Path) -> None:
1851 result = runner.invoke(cli, ["config", "read", "--json"])
1852 assert result.exit_code == 0
1853 data = json.loads(result.output)
1854 assert data["duration_ms"] >= 0.0
1855
1856 def test_get_json_has_elapsed(self, repo: pathlib.Path) -> None:
1857 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1858 result = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1859 assert result.exit_code == 0
1860 data = json.loads(result.output)
1861 assert "duration_ms" in data
1862
1863 def test_get_elapsed_is_float(self, repo: pathlib.Path) -> None:
1864 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1865 result = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1866 assert result.exit_code == 0
1867 data = json.loads(result.output)
1868 assert isinstance(data["duration_ms"], float)
1869
1870 def test_get_elapsed_non_negative(self, repo: pathlib.Path) -> None:
1871 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1872 result = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1873 assert result.exit_code == 0
1874 data = json.loads(result.output)
1875 assert data["duration_ms"] >= 0.0
1876
1877 def test_set_json_has_elapsed(self, repo: pathlib.Path) -> None:
1878 result = runner.invoke(cli, ["config", "set", "user.handle", "Alice", "--json"])
1879 assert result.exit_code == 0
1880 data = json.loads(result.output)
1881 assert "duration_ms" in data
1882
1883 def test_set_elapsed_is_float(self, repo: pathlib.Path) -> None:
1884 result = runner.invoke(cli, ["config", "set", "user.handle", "Alice", "--json"])
1885 assert result.exit_code == 0
1886 data = json.loads(result.output)
1887 assert isinstance(data["duration_ms"], float)
1888
1889 def test_set_elapsed_non_negative(self, repo: pathlib.Path) -> None:
1890 result = runner.invoke(cli, ["config", "set", "user.handle", "Alice", "--json"])
1891 assert result.exit_code == 0
1892 data = json.loads(result.output)
1893 assert data["duration_ms"] >= 0.0
1894
1895
1896 class TestExitCode:
1897 """exit_code must appear in every JSON output path and mirror process exit."""
1898
1899 def test_read_json_has_exit_code(self, repo: pathlib.Path) -> None:
1900 result = runner.invoke(cli, ["config", "read", "--json"])
1901 assert result.exit_code == 0
1902 data = json.loads(result.output)
1903 assert "exit_code" in data
1904
1905 def test_read_exit_code_zero(self, repo: pathlib.Path) -> None:
1906 result = runner.invoke(cli, ["config", "read", "--json"])
1907 assert result.exit_code == 0
1908 data = json.loads(result.output)
1909 assert data["exit_code"] == 0
1910
1911 def test_get_json_has_exit_code(self, repo: pathlib.Path) -> None:
1912 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1913 result = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1914 assert result.exit_code == 0
1915 data = json.loads(result.output)
1916 assert "exit_code" in data
1917
1918 def test_get_exit_code_zero_on_success(self, repo: pathlib.Path) -> None:
1919 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1920 result = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1921 assert result.exit_code == 0
1922 data = json.loads(result.output)
1923 assert data["exit_code"] == 0
1924
1925 def test_set_json_has_exit_code(self, repo: pathlib.Path) -> None:
1926 result = runner.invoke(cli, ["config", "set", "user.handle", "Alice", "--json"])
1927 assert result.exit_code == 0
1928 data = json.loads(result.output)
1929 assert "exit_code" in data
1930
1931 def test_set_exit_code_zero_on_success(self, repo: pathlib.Path) -> None:
1932 result = runner.invoke(cli, ["config", "set", "user.handle", "Alice", "--json"])
1933 assert result.exit_code == 0
1934 data = json.loads(result.output)
1935 assert data["exit_code"] == 0
1936
1937
1938 class TestJsonSchemaComplete:
1939 """Every JSON output path must carry the full schema — no follow-up reads needed."""
1940
1941 def test_read_full_keys(self, repo: pathlib.Path) -> None:
1942 result = runner.invoke(cli, ["config", "read", "--json"])
1943 assert result.exit_code == 0
1944 data = json.loads(result.output)
1945 assert "duration_ms" in data and "exit_code" in data
1946
1947 def test_get_full_keys(self, repo: pathlib.Path) -> None:
1948 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1949 result = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1950 assert result.exit_code == 0
1951 data = json.loads(result.output)
1952 missing = _GET_FULL_KEYS - set(data.keys())
1953 assert not missing, f"Missing keys: {missing}"
1954
1955 def test_set_full_keys(self, repo: pathlib.Path) -> None:
1956 result = runner.invoke(cli, ["config", "set", "user.handle", "Alice", "--json"])
1957 assert result.exit_code == 0
1958 data = json.loads(result.output)
1959 missing = _SET_FULL_KEYS - set(data.keys())
1960 assert not missing, f"Missing keys: {missing}"
1961
1962 def test_get_output_is_single_line_json(self, repo: pathlib.Path) -> None:
1963 runner.invoke(cli, ["config", "set", "user.handle", "Alice"])
1964 result = runner.invoke(cli, ["config", "get", "user.handle", "--json"])
1965 assert result.exit_code == 0
1966 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
1967 assert len(json_lines) == 1
1968 json.loads(json_lines[0])
1969
1970 def test_set_output_is_single_line_json(self, repo: pathlib.Path) -> None:
1971 result = runner.invoke(cli, ["config", "set", "user.handle", "Alice", "--json"])
1972 assert result.exit_code == 0
1973 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")]
1974 assert len(json_lines) == 1
1975 json.loads(json_lines[0])
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago