Widen the container-backend refusal salvaged from #113530 to the sibling surfaces that render the same host checkpoints: the messaging gateway's /rollback (restore refused, bare listing prefixed with the reason) and /diff session, and the CLI's /diff session. The gateway arm follows the CLI's "default" classification, i.e. the configured terminal backend. Drop the thin _checkpoint_container_backend wrapper in favour of the container_backend_for_task predicate it wrapped, trim the salvaged suite to two invariant tests (one per class: no host store touched by a container task; every surface refuses a host restore/diff from a container session, with a local control), and update the docs. Co-authored-by: fangliquan <fangliquan@qq.com>
160 lines
7.3 KiB
Python
160 lines
7.3 KiB
Python
"""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"
|