mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-07 15:32:21 +02:00
fix(runtime): retire and reap owned agent tmux sessions (ODY-147)
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""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, "_run_tmux_bash", forbidden)
|
||||
result = await subprocess_tools.BashTool().execute("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)
|
||||
@@ -426,6 +426,7 @@ def test_reap_orphans_reports_both_stores_and_the_mechanism(
|
||||
seed_grant()
|
||||
seed_job()
|
||||
verdicts(monkeypatch, {4242: process_ownership.GONE})
|
||||
monkeypatch.setattr(process_reaper, "reap_legacy_agent_tmux", lambda: {"seen": 0})
|
||||
monkeypatch.setattr(containment, "_group_present", lambda _pgid: False)
|
||||
|
||||
report = process_reaper.reap_orphans()
|
||||
|
||||
Reference in New Issue
Block a user