"""Comprehensive hardening tests for ``muse config``. Coverage -------- Unit — muse/cli/config.py internals - _escape: backslash, quote, newline, carriage-return, null-byte escaping - _validate_toml_key: unsafe characters rejected, safe keys pass - _dump_toml: limits section round-trips, domain key injection blocked, remote name injection blocked - set_config_value: limits namespace writes correctly, TOML key injection blocked, unknown limits key rejected, non-integer limits rejected, invalid shard_prefix_length rejected - get_config_value: limits keys read back correctly after write - config_as_dict: limits section included in output Integration — CLI commands via CliRunner - run_show: TOML text and JSON outputs, limits displayed in both formats, sanitize_display applied in text mode, --format json alias - run_get: bare value to stdout, --json schema, not-set exits nonzero, key sanitized in stderr message - run_set: success to stderr, --json schema, blocked namespace rejected, TOML injection rejected, limits set and readable, non-integer limits rejected - run_edit: no-repo exits, missing config exits, bad editor exits Security - TOML key injection: newline, bracket, equals, quote in domain key blocked - ANSI in key/value sanitized in run_show text mode - ANSI in exception message sanitized in run_set stderr - run_set success message goes to stderr (stdout clean for scripting) - run_get error goes to stderr E2E (full round-trip via CLI) - set then get is consistent - set limits then show --json contains limits - set domain then show TOML is valid TOML - limits fall-through to domain is fixed (writes to [limits] not [domain]) Stress - 8 concurrent set_config_value calls to isolated repos: no corruption """ from __future__ import annotations import json import pathlib import threading import tomllib from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest from tests.cli_test_helper import CliRunner, InvokeResult if TYPE_CHECKING: pass from muse.cli.commands.config_cmd import _GetJson, _SetJson from muse.core.store import JsonValue type _ShowJson = dict[str, JsonValue] from muse.core._types import MsgpackDict cli = None runner = CliRunner() # ── fixtures ────────────────────────────────────────────────────────────────── @pytest.fixture def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Minimal .muse/ repo with an empty config.toml.""" from muse._version import __version__ muse_dir = tmp_path / ".muse" for sub in ("refs/heads", "objects", "commits", "snapshots"): (muse_dir / sub).mkdir(parents=True, exist_ok=True) (muse_dir / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) ) (muse_dir / "HEAD").write_text("ref: refs/heads/main\n") (muse_dir / "refs" / "heads" / "main").write_text("") (muse_dir / "config.toml").write_text("") monkeypatch.chdir(tmp_path) return tmp_path def _json_get(result: InvokeResult) -> _GetJson: for line in result.output.splitlines(): stripped = line.strip() if stripped.startswith("{"): d: _GetJson = json.loads(stripped) return d raise ValueError(f"No JSON in output:\n{result.output!r}") def _json_set(result: InvokeResult) -> _SetJson: for line in result.output.splitlines(): stripped = line.strip() if stripped.startswith("{"): d: _SetJson = json.loads(stripped) return d raise ValueError(f"No JSON in output:\n{result.output!r}") def _json_show(result: InvokeResult) -> _ShowJson: """Extract the first complete JSON object from potentially multi-line output.""" lines = result.output.splitlines() start = None for i, line in enumerate(lines): if line.strip().startswith("{"): start = i break if start is None: raise ValueError(f"No JSON in output:\n{result.output!r}") # Collect lines until braces balance depth = 0 collected: list[str] = [] for line in lines[start:]: collected.append(line) depth += line.count("{") - line.count("}") if depth <= 0: break d: _ShowJson = json.loads("\n".join(collected)) return d # ── Unit: _escape ───────────────────────────────────────────────────────────── class TestEscape: def test_backslash_escaped(self) -> None: from muse.cli.config import _escape assert _escape("back\\slash") == "back\\\\slash" def test_double_quote_escaped(self) -> None: from muse.cli.config import _escape assert _escape('say "hi"') == 'say \\"hi\\"' def test_newline_escaped(self) -> None: from muse.cli.config import _escape assert _escape("line1\nline2") == "line1\\nline2" def test_carriage_return_escaped(self) -> None: from muse.cli.config import _escape assert _escape("text\rmore") == "text\\rmore" def test_null_byte_removed(self) -> None: from muse.cli.config import _escape assert "\0" not in _escape("bad\0byte") def test_clean_string_passthrough(self) -> None: from muse.cli.config import _escape assert _escape("hello world") == "hello world" # ── Unit: _validate_toml_key ────────────────────────────────────────────────── class TestValidateTomlKey: def test_newline_in_key_rejected(self) -> None: from muse.cli.config import _validate_toml_key with pytest.raises(ValueError, match="TOML keys"): _validate_toml_key("bad\nkey") def test_carriage_return_rejected(self) -> None: from muse.cli.config import _validate_toml_key with pytest.raises(ValueError, match="TOML keys"): _validate_toml_key("bad\rkey") def test_closing_bracket_rejected(self) -> None: from muse.cli.config import _validate_toml_key with pytest.raises(ValueError, match="TOML keys"): _validate_toml_key("x]injection") def test_opening_bracket_rejected(self) -> None: from muse.cli.config import _validate_toml_key with pytest.raises(ValueError, match="TOML keys"): _validate_toml_key("[evil") def test_equals_rejected(self) -> None: from muse.cli.config import _validate_toml_key with pytest.raises(ValueError, match="TOML keys"): _validate_toml_key("k=v") def test_double_quote_rejected(self) -> None: from muse.cli.config import _validate_toml_key with pytest.raises(ValueError, match="TOML keys"): _validate_toml_key('key"val') def test_null_byte_rejected(self) -> None: from muse.cli.config import _validate_toml_key with pytest.raises(ValueError, match="TOML keys"): _validate_toml_key("bad\0key") def test_safe_key_passes(self) -> None: from muse.cli.config import _validate_toml_key _validate_toml_key("ticks_per_beat") _validate_toml_key("my-key.123") _validate_toml_key("CamelCase") # ── Unit: _dump_toml ────────────────────────────────────────────────────────── class TestDumpToml: def test_limits_section_round_trips(self) -> None: from muse.cli.config import LimitsConfig, MuseConfig, _dump_toml cfg: MuseConfig = {"limits": LimitsConfig(max_walk_commits=99, max_ancestors=500)} toml_text = _dump_toml(cfg) parsed = tomllib.loads(toml_text) assert parsed["limits"]["max_walk_commits"] == 99 assert parsed["limits"]["max_ancestors"] == 500 def test_limits_shard_prefix_length_written(self) -> None: from muse.cli.config import LimitsConfig, MuseConfig, _dump_toml cfg: MuseConfig = {"limits": LimitsConfig(shard_prefix_length=4)} toml_text = _dump_toml(cfg) parsed = tomllib.loads(toml_text) assert parsed["limits"]["shard_prefix_length"] == 4 def test_domain_key_injection_blocked_in_dump(self) -> None: from muse.cli.config import MuseConfig, _dump_toml cfg: MuseConfig = {"domain": {"evil\nkey": "val"}} with pytest.raises(ValueError, match="TOML keys"): _dump_toml(cfg) def test_remote_name_injection_blocked(self) -> None: from muse.cli.config import MuseConfig, RemoteEntry, _dump_toml cfg: MuseConfig = {"remotes": {"evil\nname": RemoteEntry(url="http://localhost")}} with pytest.raises(ValueError, match="TOML keys"): _dump_toml(cfg) def test_value_with_newline_escaped_not_injected(self) -> None: from muse.cli.config import MuseConfig, _dump_toml cfg: MuseConfig = {"domain": {"key": "line1\nline2"}} toml_text = _dump_toml(cfg) parsed = tomllib.loads(toml_text) # The value line should contain \\n (escaped), not a literal newline value_line = next(l for l in toml_text.splitlines() if l.startswith("key")) assert "\n" not in value_line assert "\\n" in value_line assert "line1" in parsed["domain"]["key"] def test_section_order(self) -> None: from muse.cli.config import HubConfig, MuseConfig, UserConfig, _dump_toml cfg: MuseConfig = { "user": UserConfig(name="alice"), "hub": HubConfig(url="https://musehub.ai"), "domain": {"k": "v"}, } toml_text = _dump_toml(cfg) user_pos = toml_text.index("[user]") hub_pos = toml_text.index("[hub]") domain_pos = toml_text.index("[domain]") assert user_pos < hub_pos < domain_pos # ── Unit: set_config_value + get_config_value ───────────────────────────────── class TestSetGetConfigValue: def _make_repo(self, tmp_path: pathlib.Path) -> pathlib.Path: muse_dir = tmp_path / ".muse" muse_dir.mkdir() (muse_dir / "config.toml").write_text("") return tmp_path def test_limits_max_walk_commits_writes_to_limits_section( self, tmp_path: pathlib.Path ) -> None: root = self._make_repo(tmp_path) from muse.cli.config import set_config_value set_config_value("limits.max_walk_commits", "5000", root) raw = (root / ".muse" / "config.toml").read_text() parsed = tomllib.loads(raw) assert "limits" in parsed assert parsed["limits"]["max_walk_commits"] == 5000 assert "domain" not in parsed def test_limits_max_walk_commits_NOT_written_to_domain( self, tmp_path: pathlib.Path ) -> None: """Regression: previously limits fell through to domain code path.""" root = self._make_repo(tmp_path) from muse.cli.config import set_config_value set_config_value("limits.max_walk_commits", "1000", root) raw = (root / ".muse" / "config.toml").read_text() parsed = tomllib.loads(raw) assert "domain" not in parsed def test_limits_shard_prefix_length_valid(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) from muse.cli.config import get_config_value, set_config_value set_config_value("limits.shard_prefix_length", "4", root) assert get_config_value("limits.shard_prefix_length", root) == "4" def test_limits_shard_prefix_length_invalid_rejected( self, tmp_path: pathlib.Path ) -> None: root = self._make_repo(tmp_path) from muse.cli.config import set_config_value with pytest.raises(ValueError, match="shard_prefix_length must be 2 or 4"): set_config_value("limits.shard_prefix_length", "3", root) def test_limits_non_integer_rejected(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) from muse.cli.config import set_config_value with pytest.raises(ValueError, match="integer"): set_config_value("limits.max_walk_commits", "notanint", root) def test_limits_zero_rejected(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) from muse.cli.config import set_config_value with pytest.raises(ValueError, match="positive"): set_config_value("limits.max_walk_commits", "0", root) def test_limits_negative_rejected(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) from muse.cli.config import set_config_value with pytest.raises(ValueError, match="positive"): set_config_value("limits.max_walk_commits", "-1", root) def test_limits_unknown_key_rejected(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) from muse.cli.config import set_config_value with pytest.raises(ValueError, match="Unknown \\[limits\\]"): set_config_value("limits.unknown_key", "5", root) def test_domain_key_injection_rejected(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) from muse.cli.config import set_config_value with pytest.raises(ValueError, match="TOML keys"): set_config_value("domain.evil\nkey", "bad", root) def test_domain_key_with_bracket_injection_rejected( self, tmp_path: pathlib.Path ) -> None: root = self._make_repo(tmp_path) from muse.cli.config import set_config_value with pytest.raises(ValueError, match="TOML keys"): set_config_value("domain.x][evil", "bad", root) def test_domain_safe_key_written(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) from muse.cli.config import get_config_value, set_config_value set_config_value("domain.ticks_per_beat", "480", root) assert get_config_value("domain.ticks_per_beat", root) == "480" def test_get_config_value_limits_after_write(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) from muse.cli.config import get_config_value, set_config_value set_config_value("limits.max_ancestors", "25000", root) assert get_config_value("limits.max_ancestors", root) == "25000" # ── Unit: config_as_dict ───────────────────────────────────────────────────── class TestConfigAsDict: def _make_repo(self, tmp_path: pathlib.Path) -> pathlib.Path: muse_dir = tmp_path / ".muse" muse_dir.mkdir() return tmp_path def test_limits_included_in_output(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) (root / ".muse" / "config.toml").write_text( "[limits]\nmax_walk_commits = 5000\n" ) from muse.cli.config import config_as_dict d = config_as_dict(root) assert "limits" in d assert d["limits"]["max_walk_commits"] == "5000" def test_limits_absent_when_not_set(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) (root / ".muse" / "config.toml").write_text("[user]\nname = \"alice\"\n") from muse.cli.config import config_as_dict d = config_as_dict(root) assert "limits" not in d def test_empty_config_returns_empty_dict(self, tmp_path: pathlib.Path) -> None: root = self._make_repo(tmp_path) (root / ".muse" / "config.toml").write_text("") from muse.cli.config import config_as_dict assert config_as_dict(root) == {} # ── Integration: run_show ───────────────────────────────────────────────────── class TestRunShow: def test_show_json_includes_limits(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "7777"]) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 data = _json_show(result) assert "limits" in data limits = data["limits"] assert isinstance(limits, dict) assert limits["max_walk_commits"] == "7777" def test_show_json_schema_user(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Alice"]) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 data = _json_show(result) user = data.get("user") assert isinstance(user, dict) assert user.get("name") == "Alice" def test_show_format_json_alias(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "show", "--format", "json"]) assert result.exit_code == 0 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")] assert len(json_lines) >= 1 def test_show_format_invalid_exits(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "show", "--format", "xml"]) assert result.exit_code != 0 def test_show_text_mode_ansi_sanitized( self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str] ) -> None: # Write a config with ANSI in value (directly to file to bypass validation) (repo / ".muse" / "config.toml").write_text( '[user]\nname = "\\x1b[31mevil\\x1b[0m"\n' ) result = runner.invoke(cli, ["config", "show"]) assert "\x1b[" not in result.output def test_show_text_empty_config(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "show"]) assert result.exit_code == 0 assert "No configuration set" in result.output def test_show_text_limits_section_displayed(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "limits.max_ancestors", "30000"]) result = runner.invoke(cli, ["config", "show"]) assert result.exit_code == 0 assert "[limits]" in result.output assert "max_ancestors" in result.output def test_show_json_stdout_clean(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Bob"]) result = runner.invoke(cli, ["config", "show", "--json"]) json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")] assert len(json_lines) >= 1 def test_show_no_repo_still_works( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: # show gracefully shows empty config outside a repo (uses cwd) monkeypatch.chdir(tmp_path) result = runner.invoke(cli, ["config", "show"]) assert result.exit_code == 0 # ── Integration: run_get ───────────────────────────────────────────────────── class TestRunGet: def test_get_existing_key_raw_value(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Alice"]) result = runner.invoke(cli, ["config", "get", "user.name"]) assert result.exit_code == 0 assert "Alice" in result.output def test_get_json_schema(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.type", "agent"]) result = runner.invoke(cli, ["config", "get", "user.type", "--json"]) assert result.exit_code == 0 data = _json_get(result) assert data["key"] == "user.type" assert data["value"] == "agent" def test_get_missing_key_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "get", "user.name"]) assert result.exit_code != 0 def test_get_missing_key_error_to_stderr( self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str] ) -> None: result = runner.invoke(cli, ["config", "get", "user.name"]) assert result.exit_code != 0 assert "not set" in result.output def test_get_limits_key_after_set(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "12345"]) result = runner.invoke(cli, ["config", "get", "limits.max_walk_commits"]) assert result.exit_code == 0 assert "12345" in result.output def test_get_json_stdout_clean(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.email", "a@b.com"]) result = runner.invoke(cli, ["config", "get", "user.email", "--json"]) json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")] assert len(json_lines) >= 1 # ── Integration: run_set ───────────────────────────────────────────────────── class TestRunSet: def test_set_success_json_schema(self, repo: pathlib.Path) -> None: result = runner.invoke( cli, ["config", "set", "user.name", "Bob", "--json"] ) assert result.exit_code == 0 data = _json_set(result) assert data["status"] == "ok" assert data["key"] == "user.name" assert data["value"] == "Bob" def test_set_success_stderr_message( self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str] ) -> None: result = runner.invoke(cli, ["config", "set", "user.name", "Carol"]) assert result.exit_code == 0 assert "Carol" in result.output def test_set_json_stdout_clean(self, repo: pathlib.Path) -> None: result = runner.invoke( cli, ["config", "set", "user.type", "agent", "--json"] ) json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")] assert len(json_lines) >= 1 def test_set_blocked_namespace_exits(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "auth.token", "secret"]) assert result.exit_code != 0 def test_set_blocked_remotes_namespace_exits(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "remotes.origin", "url"]) assert result.exit_code != 0 def test_set_domain_newline_injection_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "domain.evil\nkey", "bad"]) assert result.exit_code != 0 def test_set_domain_bracket_injection_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "domain.x][evil", "bad"]) assert result.exit_code != 0 def test_set_limits_max_walk_commits(self, repo: pathlib.Path) -> None: result = runner.invoke( cli, ["config", "set", "limits.max_walk_commits", "20000"] ) assert result.exit_code == 0 get_result = runner.invoke(cli, ["config", "get", "limits.max_walk_commits"]) assert "20000" in get_result.output def test_set_limits_non_integer_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke( cli, ["config", "set", "limits.max_walk_commits", "abc"] ) assert result.exit_code != 0 def test_set_limits_zero_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke( cli, ["config", "set", "limits.max_walk_commits", "0"] ) assert result.exit_code != 0 def test_set_limits_shard_prefix_valid(self, repo: pathlib.Path) -> None: result = runner.invoke( cli, ["config", "set", "limits.shard_prefix_length", "4"] ) assert result.exit_code == 0 def test_set_limits_shard_prefix_invalid_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke( cli, ["config", "set", "limits.shard_prefix_length", "3"] ) assert result.exit_code != 0 def test_set_error_ansi_sanitized_in_output( self, repo: pathlib.Path, capsys: pytest.CaptureFixture[str] ) -> None: # Use a key with ANSI that would appear in the error message ansi_key = "domain.\x1b[31mevil\x1b[0m\nkey" result = runner.invoke(cli, ["config", "set", ansi_key, "val"]) assert result.exit_code != 0 assert "\x1b[" not in result.output def test_set_limits_writes_to_limits_not_domain(self, repo: pathlib.Path) -> None: """Regression: limits namespace must not fall through to domain code.""" runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "9999"]) raw = (repo / ".muse" / "config.toml").read_text() parsed = tomllib.loads(raw) assert "domain" not in parsed assert parsed["limits"]["max_walk_commits"] == 9999 # ── Integration: run_edit ───────────────────────────────────────────────────── class TestRunEdit: def test_edit_no_repo_exits( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) result = runner.invoke(cli, ["config", "edit"]) assert result.exit_code != 0 def test_edit_missing_config_file_autocreated( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Missing config.toml must be auto-created before the editor opens.""" (repo / ".muse" / "config.toml").unlink() monkeypatch.setenv("EDITOR", "true") monkeypatch.delenv("VISUAL", raising=False) result = runner.invoke(cli, ["config", "edit"]) assert result.exit_code == 0 assert (repo / ".muse" / "config.toml").exists() def test_edit_bad_editor_exits( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("EDITOR", "nonexistent-editor-xyz") monkeypatch.delenv("VISUAL", raising=False) result = runner.invoke(cli, ["config", "edit"]) assert result.exit_code != 0 def test_edit_invokes_editor( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("EDITOR", "true") monkeypatch.delenv("VISUAL", raising=False) result = runner.invoke(cli, ["config", "edit"]) assert result.exit_code == 0 # ── Security ────────────────────────────────────────────────────────────────── class TestConfigSecurity: def test_toml_key_injection_blocked_end_to_end(self, repo: pathlib.Path) -> None: """Setting domain key with newline must not corrupt config.toml.""" result = runner.invoke( cli, ["config", "set", "domain.evil\nkey", "bad"] ) assert result.exit_code != 0 raw = (repo / ".muse" / "config.toml").read_text() assert "\nkey" not in raw def test_bracket_injection_blocked(self, repo: pathlib.Path) -> None: result = runner.invoke( cli, ["config", "set", "domain.x][evil", "val"] ) assert result.exit_code != 0 raw = (repo / ".muse" / "config.toml").read_text() assert "[evil]" not in raw def test_equals_injection_blocked(self, repo: pathlib.Path) -> None: result = runner.invoke( cli, ["config", "set", "domain.x=y", "val"] ) assert result.exit_code != 0 def test_ansi_in_show_text_stripped(self, repo: pathlib.Path) -> None: (repo / ".muse" / "config.toml").write_text( '[domain]\nticks = "\\x1b[31mred\\x1b[0m"\n' ) result = runner.invoke(cli, ["config", "show"]) assert "\x1b[" not in result.output def test_auth_namespace_always_blocked(self, repo: pathlib.Path) -> None: for key in ("auth.token", "auth.password", "auth.secret"): result = runner.invoke(cli, ["config", "set", key, "val"]) assert result.exit_code != 0, f"Expected {key!r} to be blocked" def test_credentials_not_in_json_output(self, repo: pathlib.Path) -> None: (repo / ".muse" / "config.toml").write_text( '[hub]\nurl = "http://localhost:10003"\n' '[auth]\ntoken = "secret-token"\n' ) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 assert "secret-token" not in result.output def test_value_newline_escaped_in_toml(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Alice\nBob"]) raw = (repo / ".muse" / "config.toml").read_text() # The literal newline must not appear in the value field name_line = [l for l in raw.splitlines() if "name" in l][0] assert "\n" not in name_line def test_error_message_to_stderr_not_stdout(self, repo: pathlib.Path) -> None: result = runner.invoke( cli, ["config", "set", "domain.evil\nkey", "val"] ) assert result.exit_code != 0 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")] assert len(json_lines) == 0 # ── E2E round-trips ─────────────────────────────────────────────────────────── class TestE2ERoundTrips: def test_set_then_get_user(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "DeepBlue"]) result = runner.invoke(cli, ["config", "get", "user.name"]) assert result.exit_code == 0 assert "DeepBlue" in result.output def test_set_limits_then_show_json(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "3333"]) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 data = _json_show(result) limits = data.get("limits") assert isinstance(limits, dict) assert limits.get("max_walk_commits") == "3333" def test_set_domain_then_show_valid_toml(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "960"]) result = runner.invoke(cli, ["config", "show"]) assert result.exit_code == 0 assert "960" in result.output def test_limits_written_to_limits_section_not_domain( self, repo: pathlib.Path ) -> None: runner.invoke(cli, ["config", "set", "limits.max_graph_commits", "8888"]) raw = (repo / ".muse" / "config.toml").read_text() parsed = tomllib.loads(raw) assert "domain" not in parsed assert parsed["limits"]["max_graph_commits"] == 8888 def test_multiple_writes_preserve_all_sections(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Alice"]) runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"]) runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "2000"]) raw = (repo / ".muse" / "config.toml").read_text() parsed = tomllib.loads(raw) assert parsed["user"]["name"] == "Alice" assert parsed["domain"]["ticks_per_beat"] == "480" assert parsed["limits"]["max_walk_commits"] == 2000 def test_set_json_get_json_consistent(self, repo: pathlib.Path) -> None: set_result = runner.invoke( cli, ["config", "set", "user.type", "agent", "--json"] ) get_result = runner.invoke( cli, ["config", "get", "user.type", "--json"] ) set_data = _json_set(set_result) get_data = _json_get(get_result) assert set_data["value"] == get_data["value"] == "agent" # ── Stress ──────────────────────────────────────────────────────────────────── class TestStress: def test_8_concurrent_set_to_isolated_repos( self, tmp_path: pathlib.Path ) -> None: """8 threads writing to independent repos must not corrupt each other.""" from muse._version import __version__ from muse.cli.config import get_config_value, set_config_value errors: list[str] = [] def _do(idx: int) -> None: try: repo_dir = tmp_path / f"repo_{idx}" muse_dir = repo_dir / ".muse" muse_dir.mkdir(parents=True) (muse_dir / "config.toml").write_text("") (muse_dir / "repo.json").write_text( json.dumps({ "repo_id": f"repo-{idx}", "schema_version": __version__, "domain": "code", }) ) value = f"user_{idx}" set_config_value("user.name", value, repo_dir) result = get_config_value("user.name", repo_dir) assert result == value, f"Expected {value!r}, got {result!r}" except Exception as exc: errors.append(f"Thread {idx}: {exc}") threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)] for t in threads: t.start() for t in threads: t.join() assert errors == [], "Concurrent config write failures:\n" + "\n".join(errors) def test_8_concurrent_set_limits_isolated( self, tmp_path: pathlib.Path ) -> None: """8 threads writing limits to isolated repos must write to [limits] not [domain].""" from muse._version import __version__ from muse.cli.config import set_config_value errors: list[str] = [] def _do(idx: int) -> None: try: repo_dir = tmp_path / f"limits_repo_{idx}" muse_dir = repo_dir / ".muse" muse_dir.mkdir(parents=True) (muse_dir / "config.toml").write_text("") (muse_dir / "repo.json").write_text( json.dumps({ "repo_id": f"repo-{idx}", "schema_version": __version__, "domain": "code", }) ) set_config_value("limits.max_walk_commits", str(1000 + idx), repo_dir) raw = (muse_dir / "config.toml").read_text() parsed = tomllib.loads(raw) assert "limits" in parsed, "limits section missing" assert "domain" not in parsed, "limits fell through to domain" assert parsed["limits"]["max_walk_commits"] == 1000 + idx except Exception as exc: errors.append(f"Thread {idx}: {exc}") threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)] for t in threads: t.start() for t in threads: t.join() assert errors == [], "Concurrent limits write failures:\n" + "\n".join(errors) # ============================================================================= # muse config show — extended hardening # ============================================================================= class TestRunShowExtended: """Additional coverage for ``muse config show`` gaps.""" # ── flag aliases ────────────────────────────────────────────────────────── def test_j_short_flag_emits_json(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Alice"]) result = runner.invoke(cli, ["config", "show", "-j"]) assert result.exit_code == 0 data = _json_show(result) assert isinstance(data, dict) def test_j_flag_same_as_json_flag(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Bob"]) r1 = runner.invoke(cli, ["config", "show", "--json"]) r2 = runner.invoke(cli, ["config", "show", "-j"]) assert r1.exit_code == 0 assert r2.exit_code == 0 assert _json_show(r1) == _json_show(r2) def test_f_short_flag_json_alias(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Carol"]) result = runner.invoke(cli, ["config", "show", "-f", "json"]) assert result.exit_code == 0 data = _json_show(result) assert isinstance(data, dict) def test_format_text_explicit(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Dave"]) result = runner.invoke(cli, ["config", "show", "--format", "text"]) assert result.exit_code == 0 assert "[user]" in result.output assert "{" not in result.output # no JSON def test_json_and_format_json_together_no_double_output( self, repo: pathlib.Path ) -> None: """--json and --format json together must not emit duplicate output.""" runner.invoke(cli, ["config", "set", "user.name", "Eve"]) result = runner.invoke(cli, ["config", "show", "--json", "--format", "json"]) assert result.exit_code == 0 # Must be parseable as a single JSON object, not two concatenated objects data = _json_show(result) assert isinstance(data, dict) # ── JSON structure ──────────────────────────────────────────────────────── def test_json_is_pretty_printed(self, repo: pathlib.Path) -> None: """JSON output must use indent=2 — agents need readable diffs.""" runner.invoke(cli, ["config", "set", "user.name", "Alice"]) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 # Pretty-printed JSON has multiple lines lines = [l for l in result.output.splitlines() if l.strip()] assert len(lines) > 1 def test_json_hub_section_present(self, repo: pathlib.Path) -> None: from muse.cli.config import set_hub_url set_hub_url("https://musehub.ai", repo) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 data = _json_show(result) assert "hub" in data hub = data["hub"] assert isinstance(hub, dict) assert hub["url"] == "https://musehub.ai" def test_json_remotes_section_present(self, repo: pathlib.Path) -> None: from muse.cli.config import set_remote set_remote("origin", "https://hub.example.com/owner/repo", repo) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 data = _json_show(result) assert "remotes" in data remotes = data["remotes"] assert isinstance(remotes, dict) assert "origin" in remotes def test_json_domain_section_present(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "960"]) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 data = _json_show(result) assert "domain" in data domain = data["domain"] assert isinstance(domain, dict) assert domain["ticks_per_beat"] == "960" def test_json_all_sections_together(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Alice"]) runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"]) runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "5000"]) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 data = _json_show(result) assert "user" in data assert "domain" in data assert "limits" in data def test_json_empty_config_is_empty_object(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 data = _json_show(result) assert data == {} def test_json_no_credentials(self, repo: pathlib.Path) -> None: """Credentials (auth, token) must never appear in JSON output.""" # Write a config with a fake [auth] section directly (repo / ".muse" / "config.toml").write_text( '[user]\nname = "Alice"\n\n[auth]\ntoken = "secret"\n' ) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 assert "secret" not in result.output assert "token" not in result.output # ── text mode structure ─────────────────────────────────────────────────── def test_text_output_to_stdout(self, repo: pathlib.Path) -> None: """Text mode config content goes to stdout, not only stderr.""" runner.invoke(cli, ["config", "set", "user.name", "Alice"]) result = runner.invoke(cli, ["config", "show"]) assert result.exit_code == 0 assert "[user]" in result.output def test_text_user_section_header(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Alice"]) result = runner.invoke(cli, ["config", "show"]) assert "[user]" in result.output def test_text_hub_section_header(self, repo: pathlib.Path) -> None: from muse.cli.config import set_hub_url set_hub_url("https://musehub.ai", repo) result = runner.invoke(cli, ["config", "show"]) assert "[hub]" in result.output def test_text_remotes_section_header(self, repo: pathlib.Path) -> None: from muse.cli.config import set_remote set_remote("origin", "https://hub.example.com/owner/repo", repo) result = runner.invoke(cli, ["config", "show"]) assert "[remotes." in result.output def test_text_domain_section_header(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"]) result = runner.invoke(cli, ["config", "show"]) assert "[domain]" in result.output def test_text_key_value_format(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Alice"]) result = runner.invoke(cli, ["config", "show"]) # TOML key = "value" format assert 'name = "Alice"' in result.output def test_text_limits_no_quotes_on_values(self, repo: pathlib.Path) -> None: """Limits values are integers in TOML — no quotes.""" runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "8000"]) result = runner.invoke(cli, ["config", "show"]) assert "max_walk_commits = 8000" in result.output def test_show_help_contains_quickstart(self) -> None: result = runner.invoke(cli, ["config", "show", "--help"]) assert result.exit_code == 0 assert "quickstart" in result.output.lower() or "jq" in result.output def test_show_help_contains_exit_codes(self) -> None: result = runner.invoke(cli, ["config", "show", "--help"]) assert result.exit_code == 0 assert "Exit codes" in result.output or "exit" in result.output.lower() class TestRunShowStress: """Stress tests for ``muse config show``.""" def test_concurrent_show_reads(self, repo: pathlib.Path) -> None: """Concurrent config_as_dict reads of the same config must all succeed. Uses config_as_dict directly — CliRunner shares a buffer across threads so full CLI invocations cannot be called concurrently from different threads. """ import threading from muse.cli.config import config_as_dict, set_config_value set_config_value("user.name", "Alice", repo) set_config_value("limits.max_walk_commits", "1000", repo) errors: list[str] = [] def _do(idx: int) -> None: try: data = config_as_dict(repo) assert "user" in data assert data["user"]["name"] == "Alice" except Exception as exc: errors.append(f"Thread {idx}: {exc}") threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)] for t in threads: t.start() for t in threads: t.join() assert errors == [], "\n".join(errors) def test_show_large_domain_config(self, repo: pathlib.Path) -> None: """Show handles a config with many domain keys without truncation.""" for i in range(20): runner.invoke(cli, ["config", "set", f"domain.key_{i}", str(i * 10)]) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 data = _json_show(result) domain = data.get("domain") assert isinstance(domain, dict) assert len(domain) == 20 def test_show_json_output_is_valid_json(self, repo: pathlib.Path) -> None: """JSON output must always be parseable regardless of config contents.""" runner.invoke(cli, ["config", "set", "user.name", "Alice"]) runner.invoke(cli, ["config", "set", "user.email", "alice@example.com"]) runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"]) runner.invoke(cli, ["config", "set", "limits.max_ancestors", "50000"]) result = runner.invoke(cli, ["config", "show", "--json"]) assert result.exit_code == 0 # json.loads raises on invalid JSON parsed = json.loads(result.output) assert isinstance(parsed, dict) # ============================================================================= # muse config get — extended hardening # ============================================================================= class TestRunGetExtended: """Additional coverage for ``muse config get`` gaps.""" # ── flag aliases ────────────────────────────────────────────────────────── def test_j_short_flag_emits_json(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Alice"]) result = runner.invoke(cli, ["config", "get", "user.name", "-j"]) assert result.exit_code == 0 data = _json_get(result) assert data["value"] == "Alice" def test_j_same_as_json_flag(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Bob"]) r1 = runner.invoke(cli, ["config", "get", "user.name", "--json"]) r2 = runner.invoke(cli, ["config", "get", "user.name", "-j"]) assert r1.exit_code == 0 and r2.exit_code == 0 assert _json_get(r1) == _json_get(r2) # ── key format validation ───────────────────────────────────────────────── def test_key_without_dot_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "get", "username"]) assert result.exit_code != 0 def test_key_without_dot_shows_format_hint(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "get", "username"]) assert "namespace.subkey" in result.output or "user.name" in result.output def test_key_without_dot_does_not_say_not_set( self, repo: pathlib.Path ) -> None: """Malformed key must not produce the misleading 'is not set' message.""" result = runner.invoke(cli, ["config", "get", "badkey"]) assert "is not set" not in result.output def test_empty_key_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "get", ""]) assert result.exit_code != 0 def test_unknown_namespace_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "get", "unknown.key"]) assert result.exit_code != 0 # ── all supported key namespaces ────────────────────────────────────────── def test_get_user_email(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.email", "alice@example.com"]) result = runner.invoke(cli, ["config", "get", "user.email"]) assert result.exit_code == 0 assert "alice@example.com" in result.output def test_get_user_type(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.type", "agent"]) result = runner.invoke(cli, ["config", "get", "user.type"]) assert result.exit_code == 0 assert "agent" in result.output def test_get_hub_url(self, repo: pathlib.Path) -> None: from muse.cli.config import set_hub_url set_hub_url("https://musehub.ai", repo) result = runner.invoke(cli, ["config", "get", "hub.url"]) assert result.exit_code == 0 assert "musehub.ai" in result.output def test_get_domain_key(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "960"]) result = runner.invoke(cli, ["config", "get", "domain.ticks_per_beat"]) assert result.exit_code == 0 assert "960" in result.output def test_get_limits_max_ancestors(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "limits.max_ancestors", "20000"]) result = runner.invoke(cli, ["config", "get", "limits.max_ancestors"]) assert result.exit_code == 0 assert "20000" in result.output def test_get_limits_max_graph_commits(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "limits.max_graph_commits", "500"]) result = runner.invoke(cli, ["config", "get", "limits.max_graph_commits"]) assert result.exit_code == 0 assert "500" in result.output def test_get_limits_shard_prefix_length(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "limits.shard_prefix_length", "4"]) result = runner.invoke(cli, ["config", "get", "limits.shard_prefix_length"]) assert result.exit_code == 0 assert "4" in result.output # ── JSON structure ──────────────────────────────────────────────────────── def test_json_key_field_matches_input(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Alice"]) result = runner.invoke(cli, ["config", "get", "user.name", "--json"]) assert result.exit_code == 0 data = _json_get(result) assert data["key"] == "user.name" def test_json_value_matches_text_output(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Charlie"]) r_text = runner.invoke(cli, ["config", "get", "user.name"]) r_json = runner.invoke(cli, ["config", "get", "user.name", "--json"]) assert r_text.exit_code == 0 and r_json.exit_code == 0 json_value = _json_get(r_json)["value"] assert json_value in r_text.output def test_json_missing_key_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "get", "user.name", "--json"]) assert result.exit_code != 0 def test_json_stdout_clean_on_success(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Dave"]) result = runner.invoke(cli, ["config", "get", "user.name", "--json"]) assert result.exit_code == 0 # Output must be valid JSON parsed = json.loads(result.output) assert "key" in parsed and "value" in parsed # ── text mode output ────────────────────────────────────────────────────── def test_text_value_printed_to_stdout(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "user.name", "Eve"]) result = runner.invoke(cli, ["config", "get", "user.name"]) assert result.exit_code == 0 assert "Eve" in result.output def test_text_error_goes_to_stderr_marker(self, repo: pathlib.Path) -> None: """Not-set error must not appear in stdout (goes to stderr).""" result = runner.invoke(cli, ["config", "get", "user.name"]) # CliRunner merges stderr — ensure exit is nonzero and message present assert result.exit_code != 0 assert "not set" in result.output or "not" in result.output.lower() def test_get_help_contains_key_reference(self) -> None: result = runner.invoke(cli, ["config", "get", "--help"]) assert result.exit_code == 0 assert "user.name" in result.output or "hub.url" in result.output def test_get_help_contains_exit_codes(self) -> None: result = runner.invoke(cli, ["config", "get", "--help"]) assert result.exit_code == 0 assert "Exit codes" in result.output or "exit" in result.output.lower() # ── outside repo ───────────────────────────────────────────────────────── def test_get_outside_repo_key_not_set( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Outside a repo, all keys return not-set (gracefully).""" monkeypatch.chdir(tmp_path) result = runner.invoke(cli, ["config", "get", "user.name"]) assert result.exit_code != 0 class TestRunGetStress: """Stress tests for ``muse config get``.""" def test_concurrent_get_reads(self, repo: pathlib.Path) -> None: """Concurrent config_as_dict reads are thread-safe.""" import threading from muse.cli.config import get_config_value, set_config_value set_config_value("user.name", "Stress", repo) errors: list[str] = [] def _do(idx: int) -> None: try: value = get_config_value("user.name", repo) assert value == "Stress" except Exception as exc: errors.append(f"Thread {idx}: {exc}") threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)] for t in threads: t.start() for t in threads: t.join() assert errors == [], "\n".join(errors) def test_all_limits_keys_readable(self, repo: pathlib.Path) -> None: """All four limits keys must be individually gettable after set.""" pairs = [ ("limits.max_walk_commits", "9000"), ("limits.max_ancestors", "40000"), ("limits.max_graph_commits", "250"), ("limits.shard_prefix_length", "4"), ] for key, val in pairs: runner.invoke(cli, ["config", "set", key, val]) for key, expected in pairs: result = runner.invoke(cli, ["config", "get", key]) assert result.exit_code == 0, f"Failed for {key}" assert expected in result.output, f"Expected {expected!r} for {key}" def test_get_many_different_keys_sequentially( self, repo: pathlib.Path ) -> None: """Reading many keys in sequence must not corrupt state.""" runner.invoke(cli, ["config", "set", "user.name", "Alice"]) runner.invoke(cli, ["config", "set", "user.email", "alice@example.com"]) runner.invoke(cli, ["config", "set", "user.type", "human"]) runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"]) runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "1000"]) for _ in range(20): for key, expected in [ ("user.name", "Alice"), ("user.email", "alice@example.com"), ("user.type", "human"), ("domain.ticks_per_beat", "480"), ("limits.max_walk_commits", "1000"), ]: result = runner.invoke(cli, ["config", "get", key]) assert result.exit_code == 0 assert expected in result.output # ── Extended: run_set ───────────────────────────────────────────────────────── class TestRunSetExtended: """Extended hardening tests for ``muse config set``.""" def test_j_alias_works(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "user.name", "Alice", "-j"]) assert result.exit_code == 0 data = _json_set(result) assert data["status"] == "ok" assert data["key"] == "user.name" assert data["value"] == "Alice" def test_key_without_dot_exits_with_format_error(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "username", "Alice"]) assert result.exit_code != 0 assert "namespace.subkey" in result.output def test_key_without_dot_mentions_example(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "badkey", "val"]) assert result.exit_code != 0 assert "user.name" in result.output or "hub.url" in result.output def test_unknown_namespace_exits_nonzero(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "mystery.key", "val"]) assert result.exit_code != 0 def test_unknown_namespace_error_message_helpful(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "mystery.key", "val"]) assert "mystery" in result.output or "Unknown" in result.output def test_user_name_settable(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "user.name", "Bob"]) assert result.exit_code == 0 def test_user_email_settable(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "user.email", "bob@example.com"]) assert result.exit_code == 0 def test_user_type_human_settable(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "user.type", "human"]) assert result.exit_code == 0 def test_user_type_agent_settable(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "user.type", "agent"]) assert result.exit_code == 0 def test_hub_url_https_settable(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "hub.url", "https://musehub.ai"]) assert result.exit_code == 0 def test_hub_url_http_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "hub.url", "http://musehub.ai"]) assert result.exit_code != 0 def test_hub_unknown_subkey_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "hub.secret", "val"]) assert result.exit_code != 0 def test_domain_key_settable(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "domain.ticks_per_beat", "480"]) assert result.exit_code == 0 def test_limits_max_ancestors_settable(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "limits.max_ancestors", "25000"]) assert result.exit_code == 0 get_result = runner.invoke(cli, ["config", "get", "limits.max_ancestors"]) assert "25000" in get_result.output def test_limits_max_graph_commits_settable(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "limits.max_graph_commits", "5000"]) assert result.exit_code == 0 get_result = runner.invoke(cli, ["config", "get", "limits.max_graph_commits"]) assert "5000" in get_result.output def test_limits_shard_prefix_2_valid(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "limits.shard_prefix_length", "2"]) assert result.exit_code == 0 def test_limits_shard_prefix_4_valid(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "limits.shard_prefix_length", "4"]) assert result.exit_code == 0 def test_limits_shard_prefix_1_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "limits.shard_prefix_length", "1"]) assert result.exit_code != 0 def test_limits_shard_prefix_3_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "limits.shard_prefix_length", "3"]) assert result.exit_code != 0 def test_limits_negative_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "limits.max_walk_commits", "-5"]) assert result.exit_code != 0 def test_blocked_auth_shows_redirect(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "auth.token", "secret"]) assert result.exit_code != 0 assert "auth" in result.output.lower() or "login" in result.output.lower() def test_blocked_remotes_shows_redirect(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "remotes.origin", "url"]) assert result.exit_code != 0 assert "remote" in result.output.lower() def test_success_json_status_ok(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "user.name", "X", "--json"]) data = _json_set(result) assert data["status"] == "ok" def test_success_json_stdout_only_has_json(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "user.name", "Y", "--json"]) assert result.exit_code == 0 json_lines = [l for l in result.output.splitlines() if l.strip().startswith("{")] assert len(json_lines) >= 1 def test_success_text_goes_to_output(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "user.name", "Carol"]) assert result.exit_code == 0 assert "Carol" in result.output def test_help_contains_settable_namespaces(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "--help"]) assert "user.name" in result.output assert "hub.url" in result.output assert "domain" in result.output assert "limits" in result.output def test_help_contains_blocked_namespaces(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "--help"]) assert "auth" in result.output assert "remotes" in result.output def test_help_mentions_exit_codes(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "--help"]) assert "Exit" in result.output or "exit" in result.output # ── Security: run_set ───────────────────────────────────────────────────────── class TestRunSetSecurity: """Security-focused tests for ``muse config set``.""" def test_ansi_in_key_sanitized_in_error(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "domain.\x1b[31mevil\x1b[0m\nkey", "val"]) assert result.exit_code != 0 assert "\x1b[" not in result.output def test_ansi_in_value_sanitized_in_text_success(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "user.name", "\x1b[31mBob\x1b[0m"]) assert result.exit_code == 0 assert "\x1b[" not in result.output def test_null_byte_in_domain_key_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "domain.evil\x00key", "val"]) assert result.exit_code != 0 def test_bracket_in_domain_key_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "domain.x][evil", "val"]) assert result.exit_code != 0 def test_equals_in_domain_key_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "domain.key=evil", "val"]) assert result.exit_code != 0 def test_newline_in_domain_key_rejected(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "set", "domain.evil\nkey", "val"]) assert result.exit_code != 0 def test_key_without_dot_shows_format_hint_not_generic_error(self, repo: pathlib.Path) -> None: """Ensures format error, not a generic 'unknown namespace' message.""" result = runner.invoke(cli, ["config", "set", "nodot", "val"]) assert result.exit_code != 0 assert "namespace.subkey" in result.output def test_hub_http_blocked_not_stored(self, repo: pathlib.Path) -> None: runner.invoke(cli, ["config", "set", "hub.url", "http://evil.example.com"]) result = runner.invoke(cli, ["config", "get", "hub.url"]) assert result.exit_code != 0 or "evil.example.com" not in result.output # ── Stress: run_set ─────────────────────────────────────────────────────────── class TestRunSetStress: """Concurrency and volume tests for ``muse config set``.""" def test_concurrent_set_to_different_repos_no_corruption( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """8 threads writing to separate repos must each produce correct values.""" import threading from muse._version import __version__ from muse.cli.config import get_config_value, set_config_value errors: list[str] = [] def _worker(idx: int) -> None: repo_path = tmp_path / f"repo_{idx}" muse_dir = repo_path / ".muse" for sub in ("refs/heads", "objects", "commits", "snapshots"): (muse_dir / sub).mkdir(parents=True, exist_ok=True) (muse_dir / "repo.json").write_text( json.dumps({"repo_id": f"repo-{idx}", "schema_version": __version__, "domain": "code"}) ) (muse_dir / "HEAD").write_text("ref: refs/heads/main\n") (muse_dir / "config.toml").write_text("") expected = f"user_{idx}" set_config_value("user.name", expected, repo_path) got = get_config_value("user.name", repo_path) if got != expected: errors.append(f"repo_{idx}: expected {expected!r}, got {got!r}") threads = [threading.Thread(target=_worker, args=(i,)) for i in range(8)] for t in threads: t.start() for t in threads: t.join() assert errors == [], "\n".join(errors) def test_all_four_limits_keys_set_round_trip(self, repo: pathlib.Path) -> None: """All four limits keys written then read back.""" from muse.cli.config import get_config_value, set_config_value pairs = [ ("limits.max_walk_commits", "10000"), ("limits.max_ancestors", "5000"), ("limits.max_graph_commits", "2500"), ("limits.shard_prefix_length", "4"), ] for key, val in pairs: set_config_value(key, val, repo) for key, expected in pairs: got = get_config_value(key, repo) assert got == expected, f"{key}: expected {expected!r}, got {got!r}" def test_20_sequential_sets_no_state_corruption(self, repo: pathlib.Path) -> None: """Writing 20 different user.name values sequentially — last write wins.""" from muse.cli.config import get_config_value, set_config_value for i in range(20): set_config_value("user.name", f"user_{i}", repo) got = get_config_value("user.name", repo) assert got == "user_19" # ── Extended: run_edit ──────────────────────────────────────────────────────── class TestRunEditExtended: """Extended hardening tests for ``muse config edit``.""" def test_visual_takes_precedence_over_editor( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """$VISUAL must be used when both $VISUAL and $EDITOR are set.""" calls: list[list[str]] = [] import subprocess as _sp real_run = _sp.run def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]: calls.append(cmd) return real_run(["true"], **{k: v for k, v in kwargs.items()}) monkeypatch.setattr(_sp, "run", _fake_run) monkeypatch.setenv("VISUAL", "visual-editor") monkeypatch.setenv("EDITOR", "editor-fallback") runner.invoke(cli, ["config", "edit"]) assert calls and calls[0][0] == "visual-editor" def test_editor_used_when_visual_unset( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: calls: list[list[str]] = [] import subprocess as _sp real_run = _sp.run def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]: calls.append(cmd) return real_run(["true"], **{k: v for k, v in kwargs.items()}) monkeypatch.setattr(_sp, "run", _fake_run) monkeypatch.delenv("VISUAL", raising=False) monkeypatch.setenv("EDITOR", "my-editor") runner.invoke(cli, ["config", "edit"]) assert calls and calls[0][0] == "my-editor" def test_vi_fallback_when_both_unset( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: calls: list[list[str]] = [] import subprocess as _sp real_run = _sp.run def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]: calls.append(cmd) return real_run(["true"], **{k: v for k, v in kwargs.items()}) monkeypatch.setattr(_sp, "run", _fake_run) monkeypatch.delenv("VISUAL", raising=False) monkeypatch.delenv("EDITOR", raising=False) runner.invoke(cli, ["config", "edit"]) assert calls and calls[0][0] == "vi" def test_multiword_editor_split_correctly( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """EDITOR='code --wait' must be split to ['code', '--wait', path].""" calls: list[list[str]] = [] import subprocess as _sp real_run = _sp.run def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]: calls.append(cmd) return real_run(["true"], **{k: v for k, v in kwargs.items()}) monkeypatch.setattr(_sp, "run", _fake_run) monkeypatch.setenv("EDITOR", "code --wait") monkeypatch.delenv("VISUAL", raising=False) runner.invoke(cli, ["config", "edit"]) assert calls and calls[0][:2] == ["code", "--wait"] def test_multiword_editor_passes_config_path( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Multi-word editor: config path must be the final argument.""" calls: list[list[str]] = [] import subprocess as _sp real_run = _sp.run def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]: calls.append(cmd) return real_run(["true"], **{k: v for k, v in kwargs.items()}) monkeypatch.setattr(_sp, "run", _fake_run) monkeypatch.setenv("EDITOR", "emacs -nw") monkeypatch.delenv("VISUAL", raising=False) runner.invoke(cli, ["config", "edit"]) assert calls and "config.toml" in calls[0][-1] def test_auto_create_config_when_missing( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: (repo / ".muse" / "config.toml").unlink() monkeypatch.setenv("EDITOR", "true") monkeypatch.delenv("VISUAL", raising=False) assert not (repo / ".muse" / "config.toml").exists() result = runner.invoke(cli, ["config", "edit"]) assert result.exit_code == 0 assert (repo / ".muse" / "config.toml").exists() def test_auto_create_info_message_on_stderr( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: (repo / ".muse" / "config.toml").unlink() monkeypatch.setenv("EDITOR", "true") monkeypatch.delenv("VISUAL", raising=False) result = runner.invoke(cli, ["config", "edit"]) assert "Created" in result.output or "config.toml" in result.output def test_editor_invoked_as_list_not_shell( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """subprocess.run must receive a list, never shell=True.""" captured: MsgpackDict = {} import subprocess as _sp def _fake_run(cmd: list[str] | str, **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]: captured["cmd"] = cmd captured["shell"] = kwargs.get("shell", False) from subprocess import CompletedProcess return CompletedProcess([], 0) monkeypatch.setattr(_sp, "run", _fake_run) monkeypatch.setenv("EDITOR", "true") monkeypatch.delenv("VISUAL", raising=False) runner.invoke(cli, ["config", "edit"]) assert isinstance(captured.get("cmd"), list) assert not captured.get("shell") def test_editor_nonzero_exit_exits_nonzero( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("EDITOR", "false") # /bin/false always exits 1 monkeypatch.delenv("VISUAL", raising=False) result = runner.invoke(cli, ["config", "edit"]) assert result.exit_code != 0 def test_editor_nonzero_exit_message_contains_code( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("EDITOR", "false") monkeypatch.delenv("VISUAL", raising=False) result = runner.invoke(cli, ["config", "edit"]) assert result.exit_code != 0 # Message should mention the exit code number assert any(ch.isdigit() for ch in result.output) def test_outside_repo_exits_nonzero( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("MUSE_REPO_ROOT", raising=False) result = runner.invoke(cli, ["config", "edit"]) assert result.exit_code != 0 def test_outside_repo_error_message( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) monkeypatch.delenv("MUSE_REPO_ROOT", raising=False) result = runner.invoke(cli, ["config", "edit"]) assert "repository" in result.output.lower() or "repo" in result.output.lower() def test_help_mentions_visual(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "edit", "--help"]) assert "VISUAL" in result.output def test_help_mentions_editor(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "edit", "--help"]) assert "EDITOR" in result.output def test_help_mentions_agent_alternative(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "edit", "--help"]) assert "muse config set" in result.output or "agent" in result.output def test_help_mentions_exit_codes(self, repo: pathlib.Path) -> None: result = runner.invoke(cli, ["config", "edit", "--help"]) assert "Exit" in result.output or "exit" in result.output def test_config_path_passed_to_editor( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Editor must receive the config.toml path as its argument.""" calls: list[list[str]] = [] import subprocess as _sp real_run = _sp.run def _fake_run(cmd: list[str], **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]: calls.append(cmd) return real_run(["true"], **{k: v for k, v in kwargs.items()}) monkeypatch.setattr(_sp, "run", _fake_run) monkeypatch.setenv("EDITOR", "my-editor") monkeypatch.delenv("VISUAL", raising=False) runner.invoke(cli, ["config", "edit"]) assert calls assert str(repo / ".muse" / "config.toml") in calls[0] # ── Security: run_edit ──────────────────────────────────────────────────────── class TestRunEditSecurity: """Security-focused tests for ``muse config edit``.""" def test_ansi_in_editor_env_sanitized_in_error( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("EDITOR", "\x1b[31mevil\x1b[0m-editor") monkeypatch.delenv("VISUAL", raising=False) result = runner.invoke(cli, ["config", "edit"]) assert result.exit_code != 0 assert "\x1b[" not in result.output def test_editor_invoked_without_shell( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Verify shell=True is never passed to subprocess.run.""" captured: MsgpackDict = {} import subprocess as _sp def _fake_run(cmd: list[str] | str, **kwargs: str | bool | int | None) -> _sp.CompletedProcess[bytes]: captured["shell"] = kwargs.get("shell", False) from subprocess import CompletedProcess return CompletedProcess([], 0) monkeypatch.setattr(_sp, "run", _fake_run) monkeypatch.setenv("EDITOR", "true") monkeypatch.delenv("VISUAL", raising=False) runner.invoke(cli, ["config", "edit"]) assert not captured.get("shell") def test_malformed_editor_command_exits_gracefully( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """An unparseable $EDITOR value must exit cleanly, not crash.""" # shlex.split raises ValueError on unmatched quotes monkeypatch.setenv("EDITOR", "editor 'unclosed quote") monkeypatch.delenv("VISUAL", raising=False) result = runner.invoke(cli, ["config", "edit"]) assert result.exit_code != 0 def test_shell_metacharacters_in_editor_not_shell_executed( self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """$EDITOR with shell metacharacters must not trigger shell execution. shlex.split turns 'true; echo injected' into ['true;', 'echo', 'injected']. subprocess.run receives a list (never shell=True), so it tries to exec a binary literally named 'true;' — which doesn't exist. FileNotFoundError fires; no shell command is ever evaluated. """ monkeypatch.setenv("EDITOR", "true; echo injected") monkeypatch.delenv("VISUAL", raising=False) result = runner.invoke(cli, ["config", "edit"]) # Binary 'true;' doesn't exist → non-zero exit; no shell evaluation. assert result.exit_code != 0 # Error message quotes the editor string, not the result of shell execution. assert "Editor not found" in result.output