fix(runtime): contain and supervise detached Bash jobs (ODY-145)

This commit is contained in:
Alexandre Teixeira
2026-10-01 21:23:21 +01:00
parent ba7c733741
commit 7892f7f650
11 changed files with 438 additions and 101 deletions
+126
View File
@@ -0,0 +1,126 @@
"""Detached Bash uses the same boundary; restart and kill retain ownership."""
import asyncio
import os
import time
from collections import namedtuple
import pytest
from src import bg_jobs, containment, process_ownership, process_reaper, tool_execution
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT
from tests.runtime_evidence_helpers import server_authorized_executor
@pytest.fixture
def jobs(tmp_path, monkeypatch):
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", tmp_path / "jobs")
monkeypatch.setattr(bg_jobs, "_STORE", tmp_path / "jobs.json")
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
monkeypatch.setattr(containment, "MECHANISMS", tuple(m for m in containment.MECHANISMS if m.name == "process_group"))
monkeypatch.setattr(tool_execution, "_owner_is_admin", lambda owner: True)
launched = []
yield tmp_path, launched
for record in launched:
current = bg_jobs.get(record["id"])
if current and current["status"] == "running":
bg_jobs.kill(record["id"])
proc = bg_jobs._LIVE_PROCS.pop(record["pid"], None)
if proc:
proc.wait(timeout=8)
def finished(job_id):
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
record = bg_jobs.get(job_id)
if record["status"] != "running":
return record
time.sleep(0.03)
pytest.fail("background job did not finish")
def test_detached_execution_owns_boundary_and_reports_death(jobs):
path, launched = jobs
record = bg_jobs.launch("printf captured", "chat", cwd=str(path))
launched.append(record)
result = finished(record["id"])
assert result["output"] == "captured"
assert result["exit_code"] == 0
assert result["containment"]["mechanism"] == "process_group"
assert result["containment"]["contained"] is False
assert result["teardown"]["dead"] is True
async def test_bg_marker_refuses_without_spawning_and_authority_still_gates(jobs, monkeypatch):
path, _ = jobs
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_ENFORCING)
monkeypatch.setattr(bg_jobs.subprocess, "Popen", lambda *args, **kwargs: pytest.fail("uncontained bg spawn"))
block = namedtuple("Block", "tool_type content")("bash", "#!bg\nprintf unsafe")
execute = server_authorized_executor(tool_execution.execute_tool_block)
_, result = await execute(block, session_id="chat", owner="alice", workspace=str(path),
security_context=NO_TOOL_SECURITY_CONTEXT)
assert result["containment"]["executed"] is False
assert "bg_job_id" not in result
_, denied = await tool_execution.execute_tool_block(
block, session_id="chat", owner="alice", workspace=str(path),
security_context=NO_TOOL_SECURITY_CONTEXT, request_authority=None,
)
assert denied["failure_kind"] == "request_authority_denied"
def test_detached_supervisor_enforces_timeout(jobs):
path, launched = jobs
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path), max_runtime_s=1)
launched.append(record)
result = finished(record["id"])
assert result["timed_out"] is True
assert result["teardown"]["dead"] is True
def test_restart_keeps_verified_background_supervisor(jobs):
path, launched = jobs
record = bg_jobs.launch("sleep 60", "chat", cwd=str(path))
launched.append(record)
report = process_reaper.reap_containment_grants()
assert report["background_kept"] == 1
killed = bg_jobs.kill(record["id"])
assert killed["killed"] is True
assert killed["teardown"]["dead"] is True
def test_kill_never_marks_a_foreign_pid_killed(jobs, monkeypatch):
record = {"id": "stale", "status": "running", "pid": 12345, "start_token": "old",
"session_id": "chat", "started_at": time.time(), "exit_path": "missing"}
bg_jobs._save({"stale": record})
monkeypatch.setattr(process_ownership, "verify", lambda *args: process_ownership.FOREIGN)
monkeypatch.setattr(bg_jobs, "_kill", lambda *args, **kwargs: pytest.fail("foreign process signalled"))
result = bg_jobs.kill("stale")
assert result["status"] == "running"
assert result.get("killed") is not True
assert result["teardown"]["dead"] is False
def test_running_detached_output_and_concurrent_grants_are_preserved(jobs):
path, launched = jobs
for number in range(3):
launched.append(bg_jobs.launch(f"printf job-{number}; sleep 0.3", "chat", cwd=str(path)))
for number, record in enumerate(launched):
assert finished(record["id"])["output"] == f"job-{number}"
grants = containment._load_records()
assert {record["containment_id"] for record in launched} <= grants.keys()
assert all(grants[record["containment_id"]]["release"]["dead"] for record in launched)
def test_detached_output_is_available_while_running(jobs):
path, launched = jobs
record = bg_jobs.launch("printf progress; sleep 5", "chat", cwd=str(path))
launched.append(record)
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
current = bg_jobs.get(record["id"])
if "progress" in current["output"]:
assert current["status"] == "running"
return
time.sleep(0.03)
pytest.fail("detached stdout was unavailable until completion")
+6 -2
View File
@@ -11,7 +11,7 @@ import time
import pytest
from src import bg_jobs
from src import bg_jobs, containment, process_ownership
from src.agent_tools.bg_job_tools import ManageBgJobsTool
@@ -23,7 +23,11 @@ def store(tmp_path, monkeypatch):
monkeypatch.setattr(bg_jobs, "_JOBS_DIR", jobs_dir)
monkeypatch.setattr(bg_jobs, "_pid_alive", lambda pid: True)
killed: list = []
monkeypatch.setattr(bg_jobs, "_kill", lambda pid: killed.append(pid))
monkeypatch.setattr(process_ownership, "verify", lambda *args: process_ownership.OWNED)
def fake_kill(pid, **kwargs):
killed.append(pid)
return containment.ReleaseOutcome(dead=True, escalated=False)
monkeypatch.setattr(bg_jobs, "_kill", fake_kill)
return {"dir": jobs_dir, "killed": killed}
@@ -140,3 +140,14 @@ async def test_python_final_expression_and_opt_in_imports(native_boundary):
})
assert result["output"] == "42"
assert result["teardown"]["dead"] is True
async def test_capture_preserves_multibyte_text_across_chunks(native_boundary):
spec = containment.ContainmentSpec(
workspace=str(native_boundary), env=dict(os.environ), wall_clock_s=5,
required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}), max_output_bytes=200000,
)
result = await containment.run(containment.acquire(spec, owner="unicode"),
[sys.executable, "-c", "import sys; sys.stdout.write('€' * 30000)"], argv=True)
assert result.stdout == "€" * 30000
assert result.output_truncated is False