mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
fix(runtime): fold native execution into shared containment (ODY-152)
This commit is contained in:
+22
-31
@@ -107,51 +107,42 @@ def pid_alive(pid: Optional[int]) -> bool:
|
||||
PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid)
|
||||
)
|
||||
if not handle:
|
||||
return False
|
||||
return kernel32.GetLastError() != 87 # ERROR_INVALID_PARAMETER: PID absent
|
||||
try:
|
||||
code = wintypes.DWORD()
|
||||
if kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
||||
return code.value == STILL_ACTIVE
|
||||
return False
|
||||
return True # A failed probe does not establish death.
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ProcessLookupError):
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except OSError:
|
||||
return True # EPERM and other inspection failures are not ESRCH.
|
||||
|
||||
|
||||
def kill_process_tree(pid: Optional[int]) -> None:
|
||||
"""Terminate ``pid`` and all of its descendants.
|
||||
def kill_process_tree(pid: Optional[int]):
|
||||
"""Use the runtime's shared escalating teardown and return verified death.
|
||||
|
||||
POSIX: signal the whole process group (``killpg``), falling back to a plain
|
||||
``kill`` if the pid isn't a group leader.
|
||||
Windows: ``taskkill /T /F`` walks and kills the child tree (there is no
|
||||
process-group signalling).
|
||||
Callers retaining durable PIDs must validate their recorded identity before
|
||||
calling this compatibility entry point. Native grants retain identity at
|
||||
spawn and use containment.release directly.
|
||||
"""
|
||||
if not pid:
|
||||
return
|
||||
if IS_WINDOWS:
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
import signal
|
||||
|
||||
try:
|
||||
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||
except Exception:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
pass
|
||||
from src import containment
|
||||
if not pid or int(pid) <= 0:
|
||||
return containment.ReleaseOutcome(dead=True, escalated=False)
|
||||
spec = containment.ContainmentSpec(workspace=os.getcwd(), env={}, wall_clock_s=1,
|
||||
required=frozenset())
|
||||
grant = containment.ContainmentGrant(
|
||||
id="", mechanism="windows_tree" if IS_WINDOWS else "process_group",
|
||||
workspace=spec.workspace, enforced=frozenset(), degraded=(),
|
||||
unenforced_required=(), owner="compatibility", mode=containment.CONTAINMENT_MODE,
|
||||
spec=spec, pid=int(pid), pgid=containment._pgid_of(int(pid)),
|
||||
)
|
||||
return containment.release(grant)
|
||||
|
||||
|
||||
# ── Shell / executable resolution ───────────────────────────────────────────
|
||||
|
||||
@@ -811,6 +811,66 @@ async def _run_subprocess_streaming(
|
||||
timed_out,
|
||||
)
|
||||
|
||||
def _owned_spec(cwd: str, env: Optional[dict], timeout: int) -> containment.ContainmentSpec:
|
||||
"""Server-defined boundary shared by the native execution tools."""
|
||||
readonly = []
|
||||
for prefix in (sys.prefix, sys.base_prefix):
|
||||
prefix = os.path.realpath(prefix)
|
||||
if not _namespace_visible_without_bind(prefix) and prefix not in _NAMESPACE_RESERVED_DESTS:
|
||||
readonly.append(prefix)
|
||||
return containment.agent_spec(
|
||||
cwd, dict(os.environ if env is None else env), timeout,
|
||||
readonly_extra=tuple(dict.fromkeys(readonly)),
|
||||
)
|
||||
|
||||
|
||||
async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, argv: bool = False) -> dict:
|
||||
from src.tool_execution import agent_cwd, _truncate
|
||||
|
||||
grant = None
|
||||
try:
|
||||
grant = containment.acquire(
|
||||
_owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout),
|
||||
owner=str(ctx.get("session_id") or ctx.get("owner") or tool),
|
||||
)
|
||||
if containment.FILESYSTEM not in grant.enforced:
|
||||
if argv:
|
||||
command = [*command[:-1], _replace_workspace_alias(command[-1], grant.workspace)]
|
||||
else:
|
||||
command = _replace_workspace_alias(command, grant.workspace)
|
||||
result = await containment.run(grant, command, argv=argv, progress_cb=ctx.get("progress_cb"))
|
||||
except containment.ContainmentUnavailable as exc:
|
||||
return containment.unavailable_tool_result(exc, tool=tool)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
return {"error": f"{tool}: execution failed: {exc}", "exit_code": 1,
|
||||
"containment": grant.to_dict() if grant else {"contained": False, "executed": False}}
|
||||
|
||||
boundary = result.grant.to_dict()
|
||||
boundary["executed"] = True
|
||||
teardown = result.release.to_dict() if result.release else {"dead": False}
|
||||
output = result.stdout.rstrip()
|
||||
if result.stderr.rstrip():
|
||||
output = (output + "\nSTDERR: " + result.stderr.rstrip()).strip()
|
||||
truncated = result.output_truncated or len(output) > MAX_OUTPUT_CHARS
|
||||
common = {"containment": boundary, "teardown": teardown, "output_truncated": truncated}
|
||||
if not teardown["dead"]:
|
||||
return {**common, "error": f"{tool}: process teardown could not verify death",
|
||||
"failure_kind": "process_teardown_failed", "exit_code": 1,
|
||||
"stdout": _truncate(result.stdout, MAX_OUTPUT_CHARS),
|
||||
"stderr": _truncate(result.stderr, MAX_OUTPUT_CHARS)}
|
||||
if result.timed_out:
|
||||
return {**common, "error": f"{tool}: timed out after {timeout}s; process tree terminated",
|
||||
"exit_code": 124, "stdout": _truncate(result.stdout, MAX_OUTPUT_CHARS),
|
||||
"stderr": _truncate(result.stderr, MAX_OUTPUT_CHARS)}
|
||||
if tool == "python":
|
||||
child_failure = _python_child_runtime_failure(result.stdout, result.stderr, result.exit_code)
|
||||
if child_failure:
|
||||
return {**common, "error": _truncate("python: a child operation failed despite a zero Python exit status:\n" + child_failure, MAX_OUTPUT_CHARS),
|
||||
"exit_code": 1, "stderr": _truncate(result.stderr, MAX_OUTPUT_CHARS)}
|
||||
return {**common, "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
|
||||
"exit_code": result.exit_code if result.exit_code is not None else 1}
|
||||
|
||||
|
||||
class BashTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import agent_cwd, _truncate
|
||||
@@ -861,14 +921,14 @@ class BashTool:
|
||||
if "/tmp/" in content:
|
||||
isolated_tmp = _isolated_tmp_dir(agent_cwd())
|
||||
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
|
||||
try:
|
||||
content, boundary, _confined = _contained_command(content, agent_cwd())
|
||||
except containment.ContainmentUnavailable as exc:
|
||||
return containment.unavailable_tool_result(exc, tool="bash")
|
||||
progress_cb = ctx.get("progress_cb")
|
||||
_subproc_env = ctx.get("subproc_env")
|
||||
session_id = ctx.get("session_id")
|
||||
if not IS_WINDOWS and session_id and shutil.which("tmux"):
|
||||
try:
|
||||
content, boundary, _confined = _contained_command(content, agent_cwd())
|
||||
except containment.ContainmentUnavailable as exc:
|
||||
return containment.unavailable_tool_result(exc, tool="bash")
|
||||
stdout, stderr, rc, timed_out = await _run_tmux_bash(
|
||||
content,
|
||||
session_id=str(session_id),
|
||||
@@ -897,40 +957,7 @@ class BashTool:
|
||||
"containment": boundary,
|
||||
}
|
||||
|
||||
try:
|
||||
if IS_WINDOWS:
|
||||
proc = await _create_bash_subprocess(
|
||||
content,
|
||||
cwd=agent_cwd(),
|
||||
env=_subproc_env,
|
||||
)
|
||||
else:
|
||||
# Preserve the existing captured POSIX path; the structural
|
||||
# helper is primarily needed to avoid cmd.exe on Windows.
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
content,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=_subproc_env,
|
||||
cwd=agent_cwd(),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
return {"error": str(exc), "exit_code": 1, "containment": boundary}
|
||||
mark_operation_started('subprocess', pid=proc.pid)
|
||||
stdout, stderr, rc, timed_out = await _run_subprocess_streaming(
|
||||
proc,
|
||||
timeout=DEFAULT_BASH_TIMEOUT,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
if timed_out:
|
||||
return {"error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), "containment": boundary}
|
||||
output = stdout.rstrip()
|
||||
err = stderr.rstrip()
|
||||
if err:
|
||||
output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err
|
||||
output = _truncate(output, MAX_OUTPUT_CHARS)
|
||||
return {"output": output or "(no output)", "exit_code": rc or 0, "containment": boundary}
|
||||
return await _run_owned_command(content, ctx, tool="bash", timeout=DEFAULT_BASH_TIMEOUT)
|
||||
|
||||
class HostShellTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
|
||||
+55
-29
@@ -238,6 +238,8 @@ class ContainmentGrant:
|
||||
"unenforced_required": list(self.unenforced_required),
|
||||
"contained": self.contained,
|
||||
"external": self.external,
|
||||
"requested": sorted(self.spec.requested),
|
||||
"network": self.spec.network,
|
||||
}
|
||||
|
||||
|
||||
@@ -815,6 +817,12 @@ def _bwrap_prefix(spec: ContainmentSpec) -> list[str]:
|
||||
"--dev-bind", "/dev", "/dev", "--proc", "/proc",
|
||||
"--dir", WORKSPACE_MOUNT, "--bind", spec.workspace, WORKSPACE_MOUNT,
|
||||
]
|
||||
# Preserve absolute workspace paths in generated scripts without exposing
|
||||
# a writable parent directory.
|
||||
workspace = os.path.realpath(spec.workspace)
|
||||
if workspace not in _RESERVED_BIND_DESTS and workspace not in {"/usr", "/etc"}:
|
||||
args.extend(_dir_chain(workspace))
|
||||
args.extend(("--bind", workspace, workspace))
|
||||
for path in spec.readonly_extra:
|
||||
args.extend(_dir_chain(path))
|
||||
args.extend(("--ro-bind", path, path))
|
||||
@@ -873,6 +881,8 @@ def _launch_argv(grant: ContainmentGrant, command: Any, *, argv: bool) -> list[s
|
||||
else:
|
||||
shell = find_bash()
|
||||
if not shell:
|
||||
if IS_WINDOWS:
|
||||
raise RuntimeError("Git Bash is required for the Bash tool on Windows; install Git for Windows.")
|
||||
raise RuntimeError(
|
||||
"containment: no POSIX shell available to run a shell command"
|
||||
)
|
||||
@@ -912,7 +922,7 @@ async def _drain(stream, buffer: list[str], budget: list[int]) -> None:
|
||||
if stream is None:
|
||||
return
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
line = await stream.read(65536)
|
||||
if not line:
|
||||
break
|
||||
if budget[0] < 0:
|
||||
@@ -956,15 +966,23 @@ async def run(
|
||||
|
||||
spec = grant.spec
|
||||
launch = _launch_argv(grant, command, argv=argv)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*launch,
|
||||
stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=spec.workspace,
|
||||
env=dict(spec.env),
|
||||
**_spawn_kwargs(grant),
|
||||
)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*launch,
|
||||
stdin=asyncio.subprocess.PIPE if stdin is not None else asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=spec.workspace,
|
||||
env=dict(spec.env),
|
||||
**_spawn_kwargs(grant),
|
||||
)
|
||||
except BaseException:
|
||||
# Acquisition can precede a failed or cancelled spawn. A grant without
|
||||
# a child must not become a permanent restart orphan.
|
||||
release(grant, grace_s=0)
|
||||
raise
|
||||
from src.agent_runtime.journal import mark_operation_started
|
||||
mark_operation_started("subprocess", pid=proc.pid)
|
||||
# start_new_session makes the child its own group leader, so the group id
|
||||
# is the child's pid. Captured here rather than at teardown: once the leader
|
||||
# exits, getpgid can no longer tell us which group its children are in.
|
||||
@@ -991,16 +1009,18 @@ async def run(
|
||||
asyncio.create_task(_drain(proc.stdout, out_buf, out_budget)),
|
||||
asyncio.create_task(_drain(proc.stderr, err_buf, err_budget)),
|
||||
]
|
||||
if stdin is not None and proc.stdin is not None:
|
||||
try:
|
||||
proc.stdin.write(stdin)
|
||||
await proc.stdin.drain()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
async def _wait() -> None:
|
||||
# Pipe backpressure is execution time too. Feeding a child that never
|
||||
# reads stdin must remain inside the same timeout/cancellation scope.
|
||||
if stdin is not None and proc.stdin is not None:
|
||||
try:
|
||||
proc.stdin.write(stdin)
|
||||
await proc.stdin.drain()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass
|
||||
finally:
|
||||
proc.stdin.close()
|
||||
await proc.wait()
|
||||
|
||||
async def _progress() -> None:
|
||||
while True:
|
||||
@@ -1016,7 +1036,7 @@ async def run(
|
||||
outcome: Optional[ReleaseOutcome] = None
|
||||
try:
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=spec.wall_clock_s)
|
||||
await asyncio.wait_for(_wait(), timeout=spec.wall_clock_s)
|
||||
except asyncio.TimeoutError:
|
||||
timed_out = True
|
||||
outcome = await _release_awaited(live, proc)
|
||||
@@ -1056,12 +1076,8 @@ async def run(
|
||||
|
||||
|
||||
# ── release ─────────────────────────────────────────────────────────────────
|
||||
# These primitives duplicate the escalating teardown that PR #46 adds to
|
||||
# core/platform_compat.kill_process_tree. They are here because this branch is
|
||||
# cut from a lab SHA that predates it, and containment cannot ship a teardown
|
||||
# that only sends SIGTERM. When #46 lands, release() should delegate to that
|
||||
# function and the helpers below should go — carrying two copies of a
|
||||
# process-group kill is exactly the divergence this module exists to end.
|
||||
# core.platform_compat.kill_process_tree delegates here as well. Native tools,
|
||||
# detached jobs and compatibility callers share escalation and death probes.
|
||||
def _own_pgid() -> int:
|
||||
try:
|
||||
return os.getpgid(0)
|
||||
@@ -1097,8 +1113,10 @@ def _group_present(pgid: Optional[int]) -> bool:
|
||||
try:
|
||||
os.killpg(pgid, 0)
|
||||
return True
|
||||
except (OSError, ProcessLookupError):
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except OSError:
|
||||
return True # EPERM is a live group we cannot signal, not verified death.
|
||||
|
||||
|
||||
def _signal_tree(pid: Optional[int], pgid: Optional[int], sig: int) -> None:
|
||||
@@ -1188,7 +1206,11 @@ def _ownership_gate(
|
||||
"""
|
||||
verdict = process_ownership.verify(pid, token)
|
||||
if verdict == process_ownership.OWNED:
|
||||
return None
|
||||
if IS_WINDOWS or not pgid or _pgid_of(pid) == pgid:
|
||||
return None
|
||||
# A valid leader identity does not establish ownership of an arbitrary
|
||||
# recorded process group. Refuse a stale or inconsistent PGID.
|
||||
verdict = process_ownership.UNVERIFIABLE
|
||||
|
||||
if verdict == process_ownership.GONE:
|
||||
# The leader is gone. Its group may still hold processes it
|
||||
@@ -1268,6 +1290,10 @@ def release(grant: ContainmentGrant, *, grace_s: float = 2.0) -> ReleaseOutcome:
|
||||
pgid = int(pgid) if pgid else None
|
||||
except (TypeError, ValueError):
|
||||
pgid = None
|
||||
if pid <= 0:
|
||||
pid = 0
|
||||
if pgid is not None and pgid <= 0:
|
||||
pgid = None
|
||||
grant = replace(grant, pid=pid or None, pgid=pgid)
|
||||
|
||||
if not pid and not _group_present(pgid):
|
||||
|
||||
@@ -73,6 +73,12 @@ def reap_containment_grants() -> Dict[str, Any]:
|
||||
continue
|
||||
verdict = process_ownership.verify_record(record)
|
||||
if verdict == process_ownership.GONE:
|
||||
if containment._group_present(record.get("pgid")):
|
||||
# Leader death does not prove tree death. Without a surviving
|
||||
# identity we cannot signal the group, so retain the evidence.
|
||||
report["failed"] += 1
|
||||
logger.error("process_reaper: grant %s leader is gone but group survives", grant_id)
|
||||
continue
|
||||
containment.forget(grant_id)
|
||||
report["already_gone"] += 1
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Captured spawns exercise the production runner without signalling fake PIDs."""
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src import containment
|
||||
|
||||
|
||||
def capture_owned_spawn(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_REPORT_ONLY)
|
||||
monkeypatch.setattr(containment, "_store_path", lambda: tmp_path / "grants.json")
|
||||
monkeypatch.setattr(containment, "_pgid_of", lambda pid: pid)
|
||||
|
||||
async def fake_exec(*argv, **kwargs):
|
||||
captured.update(argv=argv, kwargs=kwargs, command=argv[-1])
|
||||
stdout = asyncio.StreamReader()
|
||||
stdout.feed_data(b"ok")
|
||||
stdout.feed_eof()
|
||||
stderr = asyncio.StreamReader()
|
||||
stderr.feed_eof()
|
||||
async def wait():
|
||||
return 0
|
||||
return SimpleNamespace(pid=99999999, stdout=stdout, stderr=stderr,
|
||||
returncode=0, wait=wait)
|
||||
|
||||
async def release(*args, **kwargs):
|
||||
return containment.ReleaseOutcome(dead=True, escalated=False)
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec)
|
||||
monkeypatch.setattr(containment, "_release_awaited", release)
|
||||
return captured
|
||||
@@ -188,25 +188,14 @@ def test_direct_bash_subprocess_has_closed_stdin(monkeypatch, tmp_path):
|
||||
from src.agent_tools import subprocess_tools
|
||||
from src import tool_execution
|
||||
|
||||
captured = {}
|
||||
sentinel = SimpleNamespace(pid=12345)
|
||||
|
||||
async def fake_create(command, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return sentinel
|
||||
|
||||
async def fake_stream(proc, **_kwargs):
|
||||
assert proc is sentinel
|
||||
return "ok", "", 0, False
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_shell", fake_create)
|
||||
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
|
||||
from tests.containment_helpers import capture_owned_spawn
|
||||
captured = capture_owned_spawn(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
|
||||
|
||||
result = asyncio.run(subprocess_tools.BashTool().execute("echo ok", {}))
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert captured["stdin"] is asyncio.subprocess.DEVNULL
|
||||
assert captured["kwargs"]["stdin"] is asyncio.subprocess.DEVNULL
|
||||
assert not (tmp_path / ".tmp").exists()
|
||||
|
||||
|
||||
@@ -258,19 +247,8 @@ def test_bash_allows_unicode_ffmpeg_drawtext_with_explicit_fontfile(monkeypatch,
|
||||
from src.agent_tools import subprocess_tools
|
||||
from src import tool_execution
|
||||
|
||||
captured = {}
|
||||
sentinel = SimpleNamespace(pid=12345)
|
||||
|
||||
async def fake_create(command, **kwargs):
|
||||
captured["command"] = command
|
||||
return sentinel
|
||||
|
||||
async def fake_stream(proc, **_kwargs):
|
||||
assert proc is sentinel
|
||||
return "ok", "", 0, False
|
||||
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_shell", fake_create)
|
||||
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
|
||||
from tests.containment_helpers import capture_owned_spawn
|
||||
captured = capture_owned_spawn(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
|
||||
|
||||
command = (
|
||||
|
||||
@@ -6,6 +6,8 @@ import pytest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.agent_tools import subprocess_tools
|
||||
from src import containment
|
||||
from tests.containment_helpers import capture_owned_spawn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -84,26 +86,17 @@ async def test_windows_bash_applies_the_subprocess_env(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_bash_tool_passes_ctx_env_through_to_the_child(monkeypatch):
|
||||
captured = {}
|
||||
async def test_windows_bash_tool_passes_ctx_env_through_to_the_child(monkeypatch, tmp_path):
|
||||
captured = capture_owned_spawn(monkeypatch, tmp_path)
|
||||
env = {"PATH": r"C:\Odysseus\venv\Scripts", "VIRTUAL_ENV": r"C:\Odysseus\venv"}
|
||||
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(
|
||||
subprocess_tools, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe"
|
||||
)
|
||||
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: r"D:\Workspaces\Project")
|
||||
|
||||
async def fake_exec(*argv, **kwargs):
|
||||
captured["argv"] = argv
|
||||
captured["kwargs"] = kwargs
|
||||
return SimpleNamespace(pid=4242)
|
||||
|
||||
async def fake_stream(_process, **_kwargs):
|
||||
return "ok", "", 0, False
|
||||
|
||||
monkeypatch.setattr(subprocess_tools.asyncio, "create_subprocess_exec", fake_exec)
|
||||
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
|
||||
monkeypatch.setattr(containment, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(containment, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe")
|
||||
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: str(tmp_path))
|
||||
|
||||
result = await subprocess_tools.BashTool().execute(
|
||||
"pwd",
|
||||
@@ -134,9 +127,13 @@ async def test_windows_bash_without_git_bash_fails_clearly(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypatch):
|
||||
async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypatch, tmp_path):
|
||||
capture_owned_spawn(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(subprocess_tools, "find_bash", lambda: None)
|
||||
monkeypatch.setattr(containment, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(containment, "find_bash", lambda: None)
|
||||
monkeypatch.setattr("src.tool_execution.agent_cwd", lambda: str(tmp_path))
|
||||
|
||||
result = await subprocess_tools.BashTool().execute(
|
||||
"pwd",
|
||||
@@ -148,11 +145,13 @@ async def test_bash_tool_returns_install_hint_when_git_bash_is_missing(monkeypat
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch):
|
||||
captured = {}
|
||||
workspace = r"D:\Workspaces\Project with spaces"
|
||||
async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch, tmp_path):
|
||||
captured = capture_owned_spawn(monkeypatch, tmp_path)
|
||||
workspace = str(tmp_path)
|
||||
|
||||
monkeypatch.setattr(subprocess_tools, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(containment, "IS_WINDOWS", True)
|
||||
monkeypatch.setattr(containment, "find_bash", lambda: r"C:\Program Files\Git\bin\bash.exe")
|
||||
monkeypatch.setattr(
|
||||
subprocess_tools.shutil,
|
||||
"which",
|
||||
@@ -163,17 +162,7 @@ async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch):
|
||||
async def fail_tmux(*_args, **_kwargs):
|
||||
pytest.fail("native Windows must not enter the POSIX tmux path")
|
||||
|
||||
async def fake_create(command, **kwargs):
|
||||
captured["command"] = command
|
||||
captured["kwargs"] = kwargs
|
||||
return SimpleNamespace(pid=12345)
|
||||
|
||||
async def fake_stream(_process, **_kwargs):
|
||||
return "ok", "", 0, False
|
||||
|
||||
monkeypatch.setattr(subprocess_tools, "_run_tmux_bash", fail_tmux)
|
||||
monkeypatch.setattr(subprocess_tools, "_create_bash_subprocess", fake_create)
|
||||
monkeypatch.setattr(subprocess_tools, "_run_subprocess_streaming", fake_stream)
|
||||
|
||||
result = await subprocess_tools.BashTool().execute(
|
||||
"pwd",
|
||||
@@ -185,7 +174,7 @@ async def test_windows_bash_does_not_use_a_stray_tmux_executable(monkeypatch):
|
||||
# Every bash result now carries the execution boundary it actually got.
|
||||
# Asserting dict equality here would make that field impossible to add
|
||||
# without touching a test about tmux, so the shape is asserted instead.
|
||||
assert result["containment"]["reported_dimensions"] == ["filesystem"]
|
||||
assert result["containment"]["network"] == "inherit"
|
||||
assert captured["command"] == "pwd"
|
||||
assert captured["kwargs"]["cwd"] == workspace
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Native execution must use the shared boundary and report actual teardown."""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from src import containment, tool_execution
|
||||
from src.agent_tools import subprocess_tools
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def native_boundary(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(tmp_path))
|
||||
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"
|
||||
))
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="real POSIX group teardown")
|
||||
async def test_native_bash_owns_and_releases_its_child(native_boundary):
|
||||
result = await subprocess_tools.BashTool().execute(
|
||||
"if read answer; then echo unexpected; else printf '%s' \"$ODY_TEST_ENV\"; fi",
|
||||
{"subproc_env": {"PATH": "/usr/bin:/bin", "ODY_TEST_ENV": "captured"}},
|
||||
)
|
||||
assert result["output"] == "captured"
|
||||
assert result["exit_code"] == 0
|
||||
assert result["teardown"]["dead"] is True
|
||||
assert result["containment"]["enforced"] == ["process_tree", "wall_clock"]
|
||||
assert result["containment"]["unenforced_required"] == ["filesystem"]
|
||||
assert result["containment"]["network"] == "inherit"
|
||||
assert containment.active_grants() == []
|
||||
|
||||
|
||||
async def test_native_bash_refuses_before_spawn_when_required_boundary_missing(monkeypatch):
|
||||
monkeypatch.setattr(containment, "CONTAINMENT_MODE", containment.MODE_ENFORCING)
|
||||
async def forbidden(*args, **kwargs):
|
||||
pytest.fail("refused command reached spawn")
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", forbidden)
|
||||
result = await subprocess_tools.BashTool().execute("echo hello", {})
|
||||
assert result["containment"]["executed"] is False
|
||||
assert result["containment"]["unenforced_required"] == ["filesystem"]
|
||||
|
||||
|
||||
async def test_failed_spawn_releases_unstarted_grant(native_boundary, monkeypatch):
|
||||
async def fail(*args, **kwargs):
|
||||
raise OSError("spawn failed")
|
||||
monkeypatch.setattr(asyncio, "create_subprocess_exec", fail)
|
||||
result = await subprocess_tools.BashTool().execute("echo hello", {})
|
||||
assert result["exit_code"] == 1
|
||||
assert containment.active_grants() == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="real POSIX process")
|
||||
async def test_long_line_is_drained_and_truncation_reported(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=100,
|
||||
)
|
||||
result = await containment.run(containment.acquire(spec, owner="long-line"),
|
||||
[sys.executable, "-c", "print('x' * 200000)"], argv=True)
|
||||
assert result.exit_code == 0
|
||||
assert result.stdout == "x" * 100
|
||||
assert result.output_truncated is True
|
||||
assert result.release.dead is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="real POSIX process")
|
||||
async def test_output_exactly_at_cap_is_complete(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=100,
|
||||
)
|
||||
result = await containment.run(containment.acquire(spec, owner="exact-cap"),
|
||||
[sys.executable, "-c", "import sys; sys.stdout.write('x' * 100)"], argv=True)
|
||||
assert len(result.stdout) == 100
|
||||
assert result.output_truncated is False
|
||||
|
||||
|
||||
def test_permission_denied_is_not_verified_death(monkeypatch):
|
||||
from core import platform_compat
|
||||
def denied(*args):
|
||||
raise PermissionError("EPERM")
|
||||
monkeypatch.setattr(platform_compat, "IS_WINDOWS", False)
|
||||
monkeypatch.setattr(os, "kill", denied)
|
||||
monkeypatch.setattr(os, "killpg", denied)
|
||||
monkeypatch.setattr(containment, "_own_pgid", lambda: 1)
|
||||
assert platform_compat.pid_alive(987654) is True
|
||||
assert containment._group_present(987654) is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="real POSIX process")
|
||||
async def test_blocked_stdin_is_inside_wall_clock(native_boundary):
|
||||
spec = containment.ContainmentSpec(
|
||||
workspace=str(native_boundary), env=dict(os.environ), wall_clock_s=1,
|
||||
required=frozenset({containment.PROCESS_TREE, containment.WALL_CLOCK}),
|
||||
)
|
||||
result = await asyncio.wait_for(containment.run(
|
||||
containment.acquire(spec, owner="blocked-stdin"), "sleep 60", stdin=b"x" * 2000000,
|
||||
), timeout=8)
|
||||
assert result.timed_out is True
|
||||
assert result.release.dead is True
|
||||
@@ -165,6 +165,7 @@ def test_a_gone_leader_with_a_live_group_is_reported_not_killed(
|
||||
def test_a_verified_grant_is_torn_down_normally(grant_store, monkeypatch):
|
||||
seed_grant()
|
||||
verdicts(monkeypatch, {4242: process_ownership.OWNED})
|
||||
monkeypatch.setattr(containment, "_pgid_of", lambda pid: 4242)
|
||||
signals = []
|
||||
monkeypatch.setattr(
|
||||
containment, "_signal_tree",
|
||||
@@ -179,6 +180,28 @@ def test_a_verified_grant_is_torn_down_normally(grant_store, monkeypatch):
|
||||
assert outcome.ownership == ""
|
||||
|
||||
|
||||
def test_verified_leader_does_not_authorize_a_different_group(grant_store, monkeypatch):
|
||||
seed_grant(pgid=9999)
|
||||
verdicts(monkeypatch, {4242: process_ownership.OWNED})
|
||||
monkeypatch.setattr(containment, "_pgid_of", lambda pid: 4242)
|
||||
monkeypatch.setattr(containment, "_signal_tree", lambda *args: pytest.fail("foreign group signalled"))
|
||||
outcome = containment.reap_record(grant_store()["grant-1"])
|
||||
assert outcome.dead is False
|
||||
assert outcome.ownership == process_ownership.UNVERIFIABLE
|
||||
|
||||
|
||||
def test_reaper_retains_a_group_after_its_leader_dies(grant_store, monkeypatch):
|
||||
from src import process_reaper
|
||||
seed_grant()
|
||||
verdicts(monkeypatch, {4242: process_ownership.GONE})
|
||||
monkeypatch.setattr(containment, "_group_present", lambda pgid: True)
|
||||
monkeypatch.setattr(containment, "_signal_tree", lambda *args: pytest.fail("unidentified group signalled"))
|
||||
report = process_reaper.reap_containment_grants()
|
||||
assert report["failed"] == 1
|
||||
assert report["already_gone"] == 0
|
||||
assert "grant-1" in grant_store()
|
||||
|
||||
|
||||
def test_an_in_process_grant_is_not_subjected_to_the_gate(monkeypatch, tmp_path):
|
||||
"""A grant carrying its own pid belongs to the caller holding it.
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ The source tree reads **108** `ODYSSEUS_*` variables: 78 an operator may want to
|
||||
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_FRAMES` | `'3'` | `src/agent_loop.py:15361` | How many video frames one tool result may contribute. Clamped to 1-8. |
|
||||
| `ODYSSEUS_MAX_VISUAL_EVIDENCE_IMAGES` | `'1'` | `src/agent_loop.py:15329` | How many images one tool result may contribute to the model turn. Clamped to 1-8. |
|
||||
| `ODYSSEUS_MCP_ALLOWED_COMMANDS` | `''` | `src/agent_tools/admin_tools.py:140` | Security-relevant. Comma-separated allowlist of MCP launcher basenames the agent may start. Empty by default, and the deny list still wins. |
|
||||
| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_tools/subprocess_tools.py:1153` | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. |
|
||||
| `ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES` | `''` | `src/agent_tools/subprocess_tools.py:1180` | Security-relevant. Absolute package roots, separated by the platform path separator, exposed to the sandboxed Python tool. Empty exposes none. |
|
||||
| `ODYSSEUS_SCRIPT_HOST` | `'localhost'` | `src/builtin_actions.py:919` | Default host for the run-script action. `localhost`, `127.0.0.1`, `local` and empty run locally; any other value runs over SSH. |
|
||||
| `ODYSSEUS_TOOL_APPROVAL_GATE` | `'0'` | `src/tool_capabilities.py:645` | Security-relevant. Truthy makes tool calls pass through the approval gate. Off by default. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user