"""Behavioral coverage for checkpoint path resolution across terminal backends.""" import json import os import threading from types import SimpleNamespace import pytest from agent.tool_executor import _ToolCallRef, _begin_tool_execution, _ensure_file_checkpoint from agent.turn_explainers import TurnExplainersMixin from tools.checkpoint_manager import CheckpointManager from tools.terminal_tool import _active_environments, _env_lock @pytest.fixture def manager(tmp_path, monkeypatch): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", tmp_path / "checkpoints") return CheckpointManager(enabled=True) @pytest.fixture def container_task_id(monkeypatch): class FakeDockerEnvironment: # class-name hint is how file_tools_paths classifies live envs pass task_id = "container-checkpoint-task" with _env_lock: monkeypatch.setitem(_active_environments, task_id, FakeDockerEnvironment()) yield task_id with _env_lock: _active_environments.pop(task_id, None) def test_relative_file_checkpoint_uses_task_workspace(tmp_path, monkeypatch): """Checkpoint lookup must use the same cwd as a relative file mutation.""" process_cwd = tmp_path / "opt" / "hermes" workspace_cwd = tmp_path / "opt" / "data" / "workspace" process_cwd.mkdir(parents=True) workspace_cwd.mkdir(parents=True) # Both directories contain content so checkpointing the wrong one would # still succeed and remain observable as the regression did in Docker. (process_cwd / "pyproject.toml").write_text("[project]\nname = 'hermes'\n") (workspace_cwd / "pyproject.toml").write_text("[project]\nname = 'workspace'\n") (workspace_cwd / "existing.txt").write_text("before\n") monkeypatch.chdir(process_cwd) monkeypatch.setenv("TERMINAL_CWD", str(workspace_cwd)) monkeypatch.setattr( "tools.checkpoint_manager.CHECKPOINT_BASE", tmp_path / "checkpoints", ) manager = CheckpointManager(enabled=True) agent = SimpleNamespace(_checkpoint_mgr=manager) _ensure_file_checkpoint( agent, "write_file", {"path": "test_permissions2.txt"}, "gateway-session", ) assert manager.list_checkpoints(str(workspace_cwd)) assert manager.list_checkpoints(str(process_cwd)) == [] def test_container_backend_task_leaves_host_store_untouched(tmp_path, manager, container_task_id): """A container-backed task's paths are container paths: no host snapshot from the file or destructive-terminal hooks and no host ledger entry, even when an unrelated host tree shares the spelling. A local task on the same manager still gets its checkpoint (control).""" host_file = tmp_path / "workspace" / "project" / "a.txt" host_file.parent.mkdir(parents=True) host_file.write_text("host content\n", encoding="utf-8") path = str(host_file) agent = SimpleNamespace( _checkpoint_mgr=manager, _turn_failed_file_mutations={}, _turn_file_mutation_paths=set(), quiet_mode=True, tool_progress_callback=None, tool_start_callback=None, _touch_activity=lambda *_: None, ) _ensure_file_checkpoint(agent, "write_file", {"path": path}, container_task_id) ref = _ToolCallRef("terminal", {"command": f"rm -rf {host_file.parent}"}, container_task_id, "call-1", []) _begin_tool_execution(agent, ref, None) TurnExplainersMixin._record_file_mutation_result( agent, "write_file", {"path": path, "content": "x"}, json.dumps({"bytes_written": 1, "resolved_path": path}), False, task_id=container_task_id, ) assert manager.list_checkpoints(str(host_file.parent)) == [] assert not (tmp_path / "checkpoints").exists() _ensure_file_checkpoint(agent, "write_file", {"path": path}, "local-task") assert len(manager.list_checkpoints(str(host_file.parent))) == 1 @pytest.mark.asyncio async def test_container_session_refuses_host_rollback_on_every_surface(tmp_path, monkeypatch, manager, capsys): """A host checkpoint left by an earlier local session is never restored or diffed from a container-backed session: CLI /rollback + /diff session, gateway /rollback + /diff session, and the rollback.restore RPC all answer with the backend reason instead.""" from gateway import run as gateway_run from gateway.config import Platform from gateway.platforms.event import MessageEvent from gateway.session import SessionSource from hermes_cli.cli_commands_mixin import CLICommandsMixin from tui_gateway import server host_dir = tmp_path / "workspace" / "project" host_dir.mkdir(parents=True) (host_dir / "a.txt").write_text("before\n", encoding="utf-8") manager.ensure_checkpoint(str(host_dir), "earlier local session") monkeypatch.setenv("TERMINAL_ENV", "docker") # the configured backend, as the product bridges it monkeypatch.setenv("TERMINAL_CWD", str(host_dir)) def refuse(*_args, **_kwargs): raise AssertionError("host checkpoint operation reached from a container-backed session") monkeypatch.setattr(manager, "restore", refuse) monkeypatch.setattr(manager, "diff", refuse) monkeypatch.setattr(manager, "session_diff", refuse) cli = SimpleNamespace( _checkpoint_manager=lambda _lines: manager, _resolve_checkpoint_ref=lambda ref, cps: cps[int(ref) - 1]["hash"], _rollback_restore=refuse, _rollback_diff=refuse, ) for command in ("/rollback 1 --all", "/rollback diff 1"): CLICommandsMixin._handle_rollback_command(cli, command) assert "docker" in "".join(capsys.readouterr()) CLICommandsMixin._print_session_diff(cli, str(host_dir), False) assert "docker" in "".join(capsys.readouterr()) runner = object.__new__(gateway_run.GatewayRunner) runner._checkpoint_manager = lambda: manager source = SessionSource(platform=Platform.TELEGRAM, user_id="u", chat_id="c", user_name="t", chat_type="dm") for text in ("/rollback 1", "/rollback 1 --all"): assert "docker" in await runner._handle_rollback_command(MessageEvent(text=text, source=source)) assert "docker" in await runner._handle_diff_command(MessageEvent(text="/diff session", source=source)) listing = await runner._handle_rollback_command(MessageEvent(text="/rollback", source=source)) assert "docker" in listing and "earlier local session" in listing # listing stays visible session = { "agent": SimpleNamespace(_checkpoint_mgr=manager), "cwd": str(host_dir), "running": False, "session_key": "container-key", "history": [], "history_lock": threading.Lock(), "history_version": 0, } monkeypatch.setitem(server._sessions, "container-sid", session) resp = server.handle_request( {"id": "1", "method": "rollback.restore", "params": {"session_id": "container-sid", "hash": "1"}} ) assert resp["result"]["success"] is False and "docker" in resp["result"]["error"] monkeypatch.setenv("TERMINAL_ENV", "local") # control: a local session still dispatches monkeypatch.setattr(manager, "restore", lambda *a, **k: {"success": True, "restored_to": "x", "reason": "r"}) resp = server.handle_request( {"id": "2", "method": "rollback.restore", "params": {"session_id": "container-sid", "hash": "1"}} ) assert resp["result"]["success"] is True assert (host_dir / "a.txt").read_text(encoding="utf-8") == "before\n" assert os.environ["TERMINAL_ENV"] == "local"