"""Plan 04-05 -- iai-mcp daemon subcommand group tests (DAEMON-10 + DAEMON-12). Verifies dispatcher wiring, install/uninstall flow with consent banner, launchd / systemd template rendering with sys.executable substitution (Pitfall 5), version skew detection in `daemon status`, and C4 clean uninstall (removes plist/unit + all 3 state files). All subprocess calls (launchctl, systemctl, loginctl, tail, journalctl) are monkeypatched so the suite never touches the host's actual launchd/systemd. Socket-talking subcommands (status / force-rem / pause / logs) are exercised against the `_ThreadedFakeDaemon` helper (lifted from tests/test_core_bedtime_inject.py pattern -- a fake daemon that survives multiple asyncio.run() teardowns by running on a dedicated background loop). """ from __future__ import annotations import asyncio import io import json import os import platform import sys import tempfile import threading from contextlib import redirect_stdout, redirect_stderr from pathlib import Path from unittest.mock import patch import pytest from iai_mcp import cli as cli_mod # --------------------------------------------------------------------------- # Threaded fake daemon (survives multiple asyncio.run teardowns) # --------------------------------------------------------------------------- class _ThreadedFakeDaemon: """Fake daemon NDJSON server on a background loop. Each request line is captured. Each request gets `reply` written back (or a per-request reply via `reply_fn(req)` if provided). """ def __init__( self, path: Path, captured: list, reply: dict | None = None, reply_fn=None, ) -> None: self.path = path self.captured = captured self.reply = reply self.reply_fn = reply_fn self._loop: asyncio.AbstractEventLoop | None = None self._server: asyncio.AbstractServer | None = None self._thread: threading.Thread | None = None self._ready = threading.Event() def start(self) -> None: def _run() -> None: self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) async def _handle(reader, writer): try: line = await reader.readline() if line: req = json.loads(line.decode("utf-8")) self.captured.append(req) if self.reply_fn is not None: resp = self.reply_fn(req) else: resp = self.reply or {} writer.write((json.dumps(resp) + "\n").encode("utf-8")) await writer.drain() finally: try: writer.close() await writer.wait_closed() except Exception: pass async def _serve(): self.path.parent.mkdir(parents=True, exist_ok=True) self._server = await asyncio.start_unix_server( _handle, path=str(self.path), ) self._ready.set() async with self._server: await self._server.serve_forever() try: self._loop.run_until_complete(_serve()) except asyncio.CancelledError: pass finally: self._loop.close() self._thread = threading.Thread(target=_run, daemon=True) self._thread.start() assert self._ready.wait(timeout=5.0), "fake daemon failed to start" def stop(self) -> None: loop = self._loop if loop is None: return async def _shutdown(): if self._server is not None: self._server.close() await self._server.wait_closed() try: asyncio.run_coroutine_threadsafe(_shutdown(), loop).result(timeout=5.0) except Exception: pass loop.call_soon_threadsafe(loop.stop) if self._thread is not None: self._thread.join(timeout=5.0) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def short_socket(tmp_path: Path) -> Path: """Short unix-socket path (macOS ~104-byte limit).""" candidate = tmp_path / "d.sock" if len(str(candidate)) > 100: candidate = Path(tempfile.mkdtemp(prefix="iai-clitest-")) / "d.sock" return candidate @pytest.fixture def fake_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """Redirect ~/.iai-mcp + ~/Library/LaunchAgents + ~/.config/systemd/user to tmp_path-rooted equivalents, so install/uninstall never touches the real host filesystem.""" fake_home = tmp_path / "home" fake_home.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) # Re-resolve the constants after Path.home() is patched. monkeypatch.setattr( cli_mod, "LOCK_PATH", fake_home / ".iai-mcp" / ".lock", ) monkeypatch.setattr( cli_mod, "SOCKET_PATH", fake_home / ".iai-mcp" / ".daemon.sock", ) monkeypatch.setattr( cli_mod, "STATE_PATH", fake_home / ".iai-mcp" / ".daemon-state.json", ) monkeypatch.setattr( cli_mod, "LAUNCHD_TARGET", fake_home / "Library" / "LaunchAgents" / "com.iai-mcp.daemon.plist", ) monkeypatch.setattr( cli_mod, "SYSTEMD_TARGET", fake_home / ".config" / "systemd" / "user" / "iai-mcp-daemon.service", ) return fake_home # --------------------------------------------------------------------------- # Test 1: dry-run does NOT write any file # --------------------------------------------------------------------------- def test_install_dry_run_writes_no_file( fake_state_dir: Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") rc = cli_mod.main(["daemon", "install", "--dry-run", "--yes"]) assert rc == 0 assert not cli_mod.LAUNCHD_TARGET.exists() out = capsys.readouterr().out assert "Would install to" in out # sys.executable is substituted in dry-run output assert sys.executable in out # --------------------------------------------------------------------------- # Test 2: install on macOS writes plist with sys.executable + invokes launchctl # --------------------------------------------------------------------------- def test_install_macos_writes_plist_with_sys_executable( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") calls: list[list[str]] = [] def _fake_run(argv, **kwargs): calls.append(list(argv)) class _R: returncode = 0 stdout = "" stderr = "" return _R() monkeypatch.setattr(cli_mod.subprocess, "run", _fake_run) rc = cli_mod.main(["daemon", "install", "--yes"]) assert rc == 0 assert cli_mod.LAUNCHD_TARGET.exists() contents = cli_mod.LAUNCHD_TARGET.read_text() # Pitfall 5: absolute sys.executable substituted into plist assert sys.executable in contents # USERNAME placeholder substituted (not present literally) assert "{USERNAME}" not in contents # launchctl bootstrap + kickstart called assert any("bootstrap" in " ".join(c) for c in calls), calls assert any("kickstart" in " ".join(c) for c in calls), calls # --------------------------------------------------------------------------- # Test 3: install on Linux writes systemd unit + invokes systemctl + loginctl # --------------------------------------------------------------------------- def test_install_linux_writes_unit_and_invokes_systemctl( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Linux") monkeypatch.setenv("USER", "testuser") calls: list[list[str]] = [] def _fake_run(argv, **kwargs): calls.append(list(argv)) class _R: returncode = 0 # Simulate Linger=no on the first show-user, then Linger=yes after enable _show_count = [0] stdout = ( "Linger=no" if argv[:2] == ["loginctl", "show-user"] else "" ) stderr = "" return _R() monkeypatch.setattr(cli_mod.subprocess, "run", _fake_run) rc = cli_mod.main(["daemon", "install", "--yes"]) assert rc == 0 assert cli_mod.SYSTEMD_TARGET.exists() contents = cli_mod.SYSTEMD_TARGET.read_text() assert sys.executable in contents # loginctl invoked at least twice (show + enable + re-verify) loginctl_calls = [c for c in calls if c and c[0] == "loginctl"] assert len(loginctl_calls) >= 2, loginctl_calls # systemctl --user daemon-reload AND enable --now invoked cmd_strs = [" ".join(c) for c in calls] assert any("systemctl --user daemon-reload" in s for s in cmd_strs), cmd_strs assert any("systemctl --user enable --now iai-mcp-daemon.service" in s for s in cmd_strs), cmd_strs # --------------------------------------------------------------------------- # Test 4: consent banner blocks on stdin; non-`y` responses abort # --------------------------------------------------------------------------- def test_install_without_yes_prompts_consent_banner_aborts( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") # Don't actually call subprocess monkeypatch.setattr( cli_mod.subprocess, "run", lambda *a, **k: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(), ) # Strict gate: ONLY exact lowercase "y" (after .strip()) proceeds. # Everything else -- empty, "n", "N", "yes", "no", "true", numeric -- aborts. for response in ["", "n", "N", "yes", "no", "true", "1", "0", "yeah", "nope"]: monkeypatch.setattr( "builtins.input", lambda _prompt="", r=response: r, ) rc = cli_mod.main(["daemon", "install"]) assert rc == 1, f"non-strict-y response {response!r} should abort" # State file should not exist (install did not proceed) assert not cli_mod.LAUNCHD_TARGET.exists() err = capsys.readouterr().err # Banner must mention key phrases. # Banner phrasing was updated 2026-04-19 (Plan 05-08 bge-small-en pivot): # "rises to ~2 GB if the opt-in bge-m3 model is selected" — with space. assert "~2 GB" in err or "2 GB" in err assert "1%" in err assert "iai-mcp daemon uninstall" in err def test_install_with_lowercase_y_proceeds( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") monkeypatch.setattr("builtins.input", lambda _prompt="": "y") monkeypatch.setattr(cli_mod.subprocess, "run", lambda *a, **k: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()) rc = cli_mod.main(["daemon", "install"]) assert rc == 0 assert cli_mod.LAUNCHD_TARGET.exists() def test_install_consent_records_audit_trail( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """D-10 audit trail: explicit consent writes a timestamped JSON receipt under ~/.iai-mcp/.consent-*.json so a later forensic review can confirm the user actually consented (not bypassed via --yes).""" monkeypatch.setattr(platform, "system", lambda: "Darwin") monkeypatch.setattr("builtins.input", lambda _prompt="": "y") monkeypatch.setattr(cli_mod.subprocess, "run", lambda *a, **k: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()) rc = cli_mod.main(["daemon", "install"]) assert rc == 0 consent_files = list((fake_state_dir / ".iai-mcp").glob(".consent-*.json")) assert consent_files, "expected at least one .consent-.json audit receipt" payload = json.loads(consent_files[0].read_text()) assert payload.get("consent") is True assert "ts" in payload # --------------------------------------------------------------------------- # Test 5: macOS uninstall removes plist + all 3 state files # --------------------------------------------------------------------------- def test_uninstall_macos_removes_plist_and_all_state_files( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") monkeypatch.setattr(cli_mod.subprocess, "run", lambda *a, **k: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()) # Pre-seed the plist + 3 state files cli_mod.LAUNCHD_TARGET.parent.mkdir(parents=True, exist_ok=True) cli_mod.LAUNCHD_TARGET.write_text("") state_dir = fake_state_dir / ".iai-mcp" state_dir.mkdir(parents=True, exist_ok=True) cli_mod.LOCK_PATH.write_text("") cli_mod.SOCKET_PATH.write_text("") cli_mod.STATE_PATH.write_text("{}") rc = cli_mod.main(["daemon", "uninstall", "--yes"]) assert rc == 0 # C4 invariant: all 4 artefacts gone assert not cli_mod.LAUNCHD_TARGET.exists() assert not cli_mod.LOCK_PATH.exists() assert not cli_mod.SOCKET_PATH.exists() assert not cli_mod.STATE_PATH.exists() # --------------------------------------------------------------------------- # Test 6: Linux uninstall removes unit + all 3 state files # --------------------------------------------------------------------------- def test_uninstall_linux_removes_unit_and_all_state_files( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Linux") calls: list[list[str]] = [] monkeypatch.setattr( cli_mod.subprocess, "run", lambda argv, **k: (calls.append(list(argv)) or type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()), ) cli_mod.SYSTEMD_TARGET.parent.mkdir(parents=True, exist_ok=True) cli_mod.SYSTEMD_TARGET.write_text("[Service]") state_dir = fake_state_dir / ".iai-mcp" state_dir.mkdir(parents=True, exist_ok=True) cli_mod.LOCK_PATH.write_text("") cli_mod.SOCKET_PATH.write_text("") cli_mod.STATE_PATH.write_text("{}") rc = cli_mod.main(["daemon", "uninstall", "--yes"]) assert rc == 0 assert not cli_mod.SYSTEMD_TARGET.exists() assert not cli_mod.LOCK_PATH.exists() assert not cli_mod.SOCKET_PATH.exists() assert not cli_mod.STATE_PATH.exists() cmd_strs = [" ".join(c) for c in calls] assert any("systemctl --user disable --now iai-mcp-daemon.service" in s for s in cmd_strs), cmd_strs # --------------------------------------------------------------------------- # Test 7: status round-trip + daemon-down message # --------------------------------------------------------------------------- def test_status_socket_round_trip( short_socket: Path, fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, ) -> None: monkeypatch.setattr(cli_mod, "SOCKET_PATH", short_socket) captured: list[dict] = [] daemon = _ThreadedFakeDaemon( short_socket, captured, reply={ "ok": True, "state": "WAKE", "uptime_sec": 42.5, "version": "0.1.0", }, ) daemon.start() try: rc = cli_mod.main(["daemon", "status"]) assert rc == 0 finally: daemon.stop() out = capsys.readouterr().out assert "WAKE" in out assert "42" in out # request was sent assert captured == [{"type": "status"}] def test_status_daemon_down( short_socket: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, ) -> None: monkeypatch.setattr(cli_mod, "SOCKET_PATH", short_socket) assert not short_socket.exists() rc = cli_mod.main(["daemon", "status"]) assert rc == 1 out = capsys.readouterr().out assert "daemon not running" in out # --------------------------------------------------------------------------- # Test 8: status version skew warns when daemon != installed # --------------------------------------------------------------------------- def test_status_warns_on_version_skew( short_socket: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture, ) -> None: monkeypatch.setattr(cli_mod, "SOCKET_PATH", short_socket) captured: list[dict] = [] daemon = _ThreadedFakeDaemon( short_socket, captured, reply={ "ok": True, "state": "WAKE", "version": "0.0.1-OLD", }, ) daemon.start() try: rc = cli_mod.main(["daemon", "status"]) assert rc == 0 finally: daemon.stop() err = capsys.readouterr().err assert "version" in err.lower() assert "0.0.1-OLD" in err assert "restart" in err.lower() # --------------------------------------------------------------------------- # Test 9: configure subcommands persist to state file # --------------------------------------------------------------------------- def test_configure_set_budget_persists( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: # daemon_state.STATE_PATH must mirror our fake home for save_state to land # in the right place. We patch BOTH cli_mod.STATE_PATH AND the daemon_state # module's constant in one shot. from iai_mcp import daemon_state monkeypatch.setattr(daemon_state, "STATE_PATH", cli_mod.STATE_PATH) cli_mod.STATE_PATH.parent.mkdir(parents=True, exist_ok=True) rc = cli_mod.main(["daemon", "configure", "set-budget", "0.02"]) assert rc == 0 state = json.loads(cli_mod.STATE_PATH.read_text()) assert state["daily_quota_pct_override"] == pytest.approx(0.02) def test_configure_set_cycle_count_persists( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: from iai_mcp import daemon_state monkeypatch.setattr(daemon_state, "STATE_PATH", cli_mod.STATE_PATH) cli_mod.STATE_PATH.parent.mkdir(parents=True, exist_ok=True) rc = cli_mod.main(["daemon", "configure", "set-cycle-count", "5"]) assert rc == 0 state = json.loads(cli_mod.STATE_PATH.read_text()) assert state["cycle_count_override"] == 5 def test_configure_disable_host_persists( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: from iai_mcp import daemon_state monkeypatch.setattr(daemon_state, "STATE_PATH", cli_mod.STATE_PATH) cli_mod.STATE_PATH.parent.mkdir(parents=True, exist_ok=True) rc = cli_mod.main(["daemon", "configure", "disable-claude"]) assert rc == 0 state = json.loads(cli_mod.STATE_PATH.read_text()) assert state["claude_enabled"] is False # --------------------------------------------------------------------------- # Test 10: force-rem socket message # --------------------------------------------------------------------------- def test_force_rem_sends_correct_message( short_socket: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(cli_mod, "SOCKET_PATH", short_socket) captured: list[dict] = [] daemon = _ThreadedFakeDaemon( short_socket, captured, reply={"ok": True, "cycles_completed": 1}, ) daemon.start() try: rc = cli_mod.main(["daemon", "force-rem"]) assert rc == 0 finally: daemon.stop() assert captured == [{"type": "force_rem"}] # --------------------------------------------------------------------------- # Test 11: pause N # --------------------------------------------------------------------------- def test_pause_sends_seconds_arg( short_socket: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(cli_mod, "SOCKET_PATH", short_socket) captured: list[dict] = [] daemon = _ThreadedFakeDaemon(short_socket, captured, reply={"ok": True}) daemon.start() try: rc = cli_mod.main(["daemon", "pause", "300"]) assert rc == 0 finally: daemon.stop() assert captured == [{"type": "pause", "seconds": 300}] def test_resume_sends_resume_message( short_socket: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(cli_mod, "SOCKET_PATH", short_socket) captured: list[dict] = [] daemon = _ThreadedFakeDaemon(short_socket, captured, reply={"ok": True}) daemon.start() try: rc = cli_mod.main(["daemon", "resume"]) assert rc == 0 finally: daemon.stop() assert captured == [{"type": "resume"}] # --------------------------------------------------------------------------- # Test 12: start / stop dispatch correct argv on each platform # --------------------------------------------------------------------------- def test_start_macos_uses_launchctl_kickstart( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") calls: list[list[str]] = [] monkeypatch.setattr( cli_mod.subprocess, "run", lambda argv, **k: (calls.append(list(argv)) or type("R", (), {"returncode": 0})()), ) rc = cli_mod.main(["daemon", "start"]) assert rc == 0 cmd_strs = [" ".join(c) for c in calls] assert any("launchctl kickstart" in s for s in cmd_strs), cmd_strs def test_stop_macos_uses_launchctl_kill_sigterm( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") calls: list[list[str]] = [] monkeypatch.setattr( cli_mod.subprocess, "run", lambda argv, **k: (calls.append(list(argv)) or type("R", (), {"returncode": 0})()), ) rc = cli_mod.main(["daemon", "stop"]) assert rc == 0 cmd_strs = [" ".join(c) for c in calls] assert any("launchctl kill SIGTERM" in s for s in cmd_strs), cmd_strs def test_start_linux_uses_systemctl_start( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Linux") calls: list[list[str]] = [] monkeypatch.setattr( cli_mod.subprocess, "run", lambda argv, **k: (calls.append(list(argv)) or type("R", (), {"returncode": 0})()), ) rc = cli_mod.main(["daemon", "start"]) assert rc == 0 assert any(c[:4] == ["systemctl", "--user", "start", "iai-mcp-daemon.service"] for c in calls), calls def test_stop_linux_uses_systemctl_stop( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Linux") calls: list[list[str]] = [] monkeypatch.setattr( cli_mod.subprocess, "run", lambda argv, **k: (calls.append(list(argv)) or type("R", (), {"returncode": 0})()), ) rc = cli_mod.main(["daemon", "stop"]) assert rc == 0 assert any(c[:4] == ["systemctl", "--user", "stop", "iai-mcp-daemon.service"] for c in calls), calls # --------------------------------------------------------------------------- # Test 13: logs dispatches tail (macOS) or journalctl (Linux) # --------------------------------------------------------------------------- def test_logs_macos_invokes_tail( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") calls: list[list[str]] = [] monkeypatch.setattr( cli_mod.subprocess, "run", lambda argv, **k: (calls.append(list(argv)) or type("R", (), {"returncode": 0})()), ) rc = cli_mod.main(["daemon", "logs", "-n", "50"]) assert rc == 0 assert any(c and c[0] == "tail" for c in calls), calls def test_logs_linux_invokes_journalctl( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Linux") calls: list[list[str]] = [] monkeypatch.setattr( cli_mod.subprocess, "run", lambda argv, **k: (calls.append(list(argv)) or type("R", (), {"returncode": 0})()), ) rc = cli_mod.main(["daemon", "logs", "-n", "100"]) assert rc == 0 assert any( c[:5] == ["journalctl", "--user", "-u", "iai-mcp-daemon.service", "-n"] for c in calls ), calls # --------------------------------------------------------------------------- # Idempotency: install + install does not error # --------------------------------------------------------------------------- def test_install_twice_is_idempotent( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") monkeypatch.setattr(cli_mod.subprocess, "run", lambda *a, **k: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()) assert cli_mod.main(["daemon", "install", "--yes"]) == 0 assert cli_mod.main(["daemon", "install", "--yes"]) == 0 assert cli_mod.LAUNCHD_TARGET.exists() def test_uninstall_twice_is_idempotent( fake_state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") monkeypatch.setattr(cli_mod.subprocess, "run", lambda *a, **k: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()) assert cli_mod.main(["daemon", "uninstall", "--yes"]) == 0 assert cli_mod.main(["daemon", "uninstall", "--yes"]) == 0 # --------------------------------------------------------------------------- # Help output sanity # --------------------------------------------------------------------------- def test_daemon_help_lists_all_subcommands( capsys: pytest.CaptureFixture, ) -> None: with pytest.raises(SystemExit) as exc_info: cli_mod.main(["daemon", "--help"]) assert exc_info.value.code == 0 out = capsys.readouterr().out for sub in ( "install", "uninstall", "start", "stop", "status", "logs", "force-rem", "pause", "resume", "configure", ): assert sub in out, f"missing {sub} in daemon --help output"