"""Native Bash does not resurrect tmux; legacy cleanup requires identity.""" import asyncio from types import SimpleNamespace import pytest from src import containment, process_ownership, process_reaper, tool_execution from src.agent_tools import subprocess_tools from src.constants import DATA_DIR from tests.containment_helpers import capture_owned_spawn async def test_a_chat_session_always_uses_the_owned_runner(monkeypatch, tmp_path): captured = capture_owned_spawn(monkeypatch, tmp_path) monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path)) original = subprocess_tools.shutil.which monkeypatch.setattr(subprocess_tools.shutil, "which", lambda name: "/fake/tmux" if name == "tmux" else original(name)) async def forbidden(*args, **kwargs): pytest.fail("native Bash resurrected a persistent tmux shell") monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_shell", forbidden) from tests.process_resource_helpers import authorized_handler result = await authorized_handler(subprocess_tools.BashTool().execute, tmp_path)("printf ok", {"session_id": "same-chat"}) assert result["output"] == "ok" assert result["teardown"]["dead"] is True assert "tmux_session" not in result assert captured["kwargs"].get("start_new_session") is True @pytest.fixture def legacy(monkeypatch, tmp_path): import shlex import subprocess import shutil monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json") monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/tmux" if name == "tmux" else None) launcher = f"env HOME={shlex.quote(DATA_DIR)} TERM=xterm-256color /bin/bash --noprofile --norc" row = f"$8\tody-agent-chat\t1234\t4200\t%9\t4100\t{launcher}\n" state = {"rows": row, "calls": [], "released": []} def run(argv, **kwargs): state["calls"].append(argv) if argv[1] == "list-panes": return SimpleNamespace(returncode=0, stdout=state["rows"], stderr="") if argv[1] == "kill-session": state["rows"] = "" return SimpleNamespace(returncode=0, stdout="", stderr="") monkeypatch.setattr(subprocess, "run", run) monkeypatch.setattr(process_ownership, "process_table", lambda: { 4200: process_ownership.ProcessInfo(4200, 4100, "/bin/bash --noprofile --norc"), 4201: process_ownership.ProcessInfo(4201, 4200, "sleep 60"), }) monkeypatch.setattr(process_ownership, "start_token", lambda pid: f"token:{pid}") monkeypatch.setattr(process_ownership, "verify", lambda pid, token: process_ownership.OWNED) monkeypatch.setattr(containment, "_pgid_of", lambda pid: pid) def release(grant, **kwargs): state["released"].append((grant.pid, kwargs)) return containment.ReleaseOutcome(dead=True, escalated=False) monkeypatch.setattr(containment, "release", release) return state def test_legacy_cleanup_checks_home_and_identity_and_kills_children_first(legacy): report = process_reaper.reap_legacy_agent_tmux() assert report["torn_down"] == 1 assert [pid for pid, _ in legacy["released"]] == [4201, 4200] assert all(options["require_identity"] for _, options in legacy["released"]) assert legacy["calls"][-2][1:] == ["kill-session", "-t", "$8"] def test_a_name_prefix_alone_never_authorizes_cleanup(legacy): legacy["rows"] = "$8\tody-agent-chat\t1234\t4200\t%9\t4100\t/bin/bash\n" report = process_reaper.reap_legacy_agent_tmux() assert report["unverifiable"] == 1 assert legacy["released"] == [] assert all(call[1] != "kill-session" for call in legacy["calls"]) def test_a_recycled_pane_pid_is_never_signalled(legacy, monkeypatch): monkeypatch.setattr(process_ownership, "verify", lambda pid, token: process_ownership.FOREIGN if pid == 4200 else process_ownership.OWNED) report = process_reaper.reap_legacy_agent_tmux() assert report["unverifiable"] == 1 assert legacy["released"] == [] def test_a_stale_pane_pid_pointing_at_another_parent_is_not_signalled(legacy, monkeypatch): monkeypatch.setattr(process_ownership, "process_table", lambda: { 4200: process_ownership.ProcessInfo(4200, 9999, "/bin/bash --noprofile --norc"), }) assert process_reaper.reap_legacy_agent_tmux()["unverifiable"] == 1 assert legacy["released"] == [] def test_a_session_changed_during_discovery_is_never_killed(legacy, monkeypatch): original = process_ownership.process_table def table(): legacy["rows"] = legacy["rows"].replace("1234", "5678") return original() monkeypatch.setattr(process_ownership, "process_table", table) report = process_reaper.reap_legacy_agent_tmux() assert report["unverifiable"] == 1 assert legacy["released"] == [] def test_startup_reaper_does_not_kill_a_current_runtime_grant(tmp_path, monkeypatch): monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json") monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY) grant = containment.acquire(containment.agent_spec(str(tmp_path), {}, 1), owner="active-chat") monkeypatch.setattr(containment, "reap_record", lambda record: pytest.fail("startup killed current execution")) assert process_reaper.reap_containment_grants()["manager_kept"] == 1 containment.release(grant) def test_legacy_cleanup_against_a_private_real_tmux_server(tmp_path, monkeypatch): import os import shlex import shutil import subprocess real_tmux = shutil.which("tmux") if os.name == "nt" or not real_tmux: pytest.skip("requires POSIX tmux") socket = str(tmp_path / "tmux.sock") wrapper = tmp_path / "tmux" wrapper.write_text(f"#!/bin/sh\nexec {shlex.quote(real_tmux)} -S {shlex.quote(socket)} \"$@\"\n") wrapper.chmod(0o700) subprocess.run([real_tmux, "-S", socket, "-f", "/dev/null", "new-session", "-d", "-s", "ody-agent-real", "env", f"HOME={DATA_DIR}", "TERM=xterm-256color", "/bin/bash", "--noprofile", "--norc"], check=True) original = shutil.which monkeypatch.setattr(shutil, "which", lambda name: str(wrapper) if name == "tmux" else original(name)) monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json") try: report = process_reaper.reap_legacy_agent_tmux() assert report["torn_down"] == 1, report assert subprocess.run([real_tmux, "-S", socket, "has-session", "-t", "ody-agent-real"], capture_output=True).returncode != 0 assert containment.active_grants() == [] finally: subprocess.run([real_tmux, "-S", socket, "kill-server"], capture_output=True) def test_a_descendant_reissued_after_the_table_read_is_never_released(legacy, monkeypatch): reads = {"n": 0} def table(): reads["n"] += 1 rows = {4200: process_ownership.ProcessInfo(4200, 4100, "/bin/bash --noprofile --norc")} if reads["n"] <= 2: rows[4201] = process_ownership.ProcessInfo(4201, 4200, "sleep 60") else: # The child exited and its pid now names an unrelated process. rows[4201] = process_ownership.ProcessInfo(4201, 1, "sshd: stranger") return rows monkeypatch.setattr(process_ownership, "process_table", table) process_reaper.reap_legacy_agent_tmux() assert 4201 not in [pid for pid, _ in legacy["released"]] def test_an_unidentifiable_descendant_keeps_the_session_unsignalled(legacy, monkeypatch): def start_token(pid): if pid == 4201: raise process_ownership.InspectionUnavailable("/proc/4201/stat") return f"token:{pid}" monkeypatch.setattr(process_ownership, "start_token", start_token) report = process_reaper.reap_legacy_agent_tmux() assert report["unverifiable"] == 1 assert legacy["released"] == [] assert all(call[1] != "kill-session" for call in legacy["calls"])