fix(runtime): fold native execution into shared containment (ODY-152)

This commit is contained in:
Alexandre Teixeira
2026-10-01 20:59:49 +01:00
parent 1d0944d47d
commit 9bb2424e65
10 changed files with 332 additions and 155 deletions
+65 -38
View File
@@ -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
View File
@@ -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):
+6
View File
@@ -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