mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-27 00:02:21 +01:00
952 lines
41 KiB
Python
952 lines
41 KiB
Python
import asyncio
|
|
import ast
|
|
import logging
|
|
import os
|
|
import re
|
|
import shlex
|
|
import secrets
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import json
|
|
from typing import Optional
|
|
from urllib.parse import urlparse, urlsplit, urlunsplit
|
|
|
|
import httpx
|
|
|
|
from src import containment
|
|
from src.constants import AGENT_ISOLATED_TMP_DIRNAME, MAX_OUTPUT_CHARS, WORKSPACE_MOUNT
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Agent shell calls must fail fast enough for the loop to recover and choose a
|
|
# better tool. A one-hour default can pin an entire benchmark worker on an
|
|
# accidental recursive scan, even though ordinary artifact commands complete
|
|
# in seconds. Long-running work belongs in manage_bg_jobs.
|
|
DEFAULT_BASH_TIMEOUT = 120
|
|
DEFAULT_PYTHON_TIMEOUT = 60 * 60
|
|
|
|
_HOST_SHELL_BRIDGE_HOSTS = {"127.0.0.1", "localhost", "::1", "host.docker.internal"}
|
|
IS_WINDOWS = sys.platform.startswith("win")
|
|
_HOST_SHELL_CANCEL_TASKS: set[asyncio.Task] = set()
|
|
|
|
|
|
def _ffmpeg_unicode_drawtext_needs_fontfile(command: str) -> bool:
|
|
"""Require a deliberate font for non-ASCII text rendered by ffmpeg.
|
|
|
|
Fontconfig's fallback is platform-dependent and commonly resolves to a
|
|
font without the requested glyphs. An explicit ``fontfile`` makes the
|
|
rendered artifact portable and prevents successful commands that produce
|
|
tofu boxes instead of text.
|
|
"""
|
|
text = str(command or "")
|
|
lowered = text.lower()
|
|
return (
|
|
bool(re.search(r"\bffmpeg\b", lowered))
|
|
and "drawtext" in lowered
|
|
and "fontfile" not in lowered
|
|
and any(ord(char) > 127 for char in text)
|
|
)
|
|
|
|
|
|
def _resolve_fontfile_for_text(text: str) -> str:
|
|
"""Resolve a host font covering the first requested non-ASCII codepoint."""
|
|
codepoint = next((ord(char) for char in str(text or "") if ord(char) > 127), None)
|
|
matcher = shutil.which("fc-match")
|
|
if codepoint is None or not matcher:
|
|
return ""
|
|
try:
|
|
completed = subprocess.run(
|
|
[matcher, "-f", "%{file}", f":charset={codepoint:04x}"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return ""
|
|
candidate = str(completed.stdout or "").strip().splitlines()[0:1]
|
|
if completed.returncode != 0 or not candidate:
|
|
return ""
|
|
path = candidate[0].strip()
|
|
return path if os.path.isfile(path) else ""
|
|
|
|
|
|
async def _cancel_host_shell_bridge_request(
|
|
url: str, token: str, request_id: str,
|
|
) -> None:
|
|
if not token or not is_host_shell_bridge_url_allowed(url):
|
|
return
|
|
target = host_shell_bridge_endpoint_url(url, "/cancel")
|
|
try:
|
|
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
|
|
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
|
await client.post(
|
|
target,
|
|
json={"request_id": request_id},
|
|
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def find_bash() -> Optional[str]:
|
|
"""Find a real Bash executable for native Windows agent runs."""
|
|
candidates = [
|
|
shutil.which("bash"),
|
|
r"C:\Program Files\Git\bin\bash.exe",
|
|
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
|
]
|
|
return next((path for path in candidates if path and os.path.isfile(path)), None)
|
|
|
|
|
|
async def _create_bash_subprocess(
|
|
command: str,
|
|
*,
|
|
cwd: Optional[str] = None,
|
|
env: Optional[dict] = None,
|
|
):
|
|
"""Create Bash structurally, avoiding cmd.exe and stray Windows tmux."""
|
|
if IS_WINDOWS:
|
|
bash = find_bash()
|
|
if not bash:
|
|
raise RuntimeError(
|
|
"Git Bash is required for the Bash tool on Windows; install Git for Windows."
|
|
)
|
|
return await asyncio.create_subprocess_exec(
|
|
bash,
|
|
"-c",
|
|
str(command or ""),
|
|
stdin=asyncio.subprocess.DEVNULL,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=env,
|
|
cwd=cwd,
|
|
)
|
|
kwargs = {"cwd": cwd} if cwd is not None else {}
|
|
return await asyncio.create_subprocess_shell(command, **kwargs)
|
|
|
|
|
|
def _host_shell_requires_detach(command: str) -> bool:
|
|
"""Recognize commands that must not block an interactive agent turn.
|
|
|
|
Models occasionally omit ``detach`` even after the host-shell contract
|
|
tells them to poll long jobs. Keep the normal synchronous path for short
|
|
commands, but make explicit background markers and clearly long sleeps
|
|
deterministic so the bridge returns a job id instead of holding the SSE
|
|
stream open.
|
|
"""
|
|
text = str(command or "").strip()
|
|
if not text:
|
|
return False
|
|
first = next((line.strip().lower() for line in text.splitlines() if line.strip()), "")
|
|
if first in {"#!bg", "#bg", "# bg", "#background", "# background", "@background", "# @background"}:
|
|
return True
|
|
match = re.search(r"\bsleep\s+(\d+(?:\.\d+)?)\b", text, re.IGNORECASE)
|
|
if match:
|
|
try:
|
|
return float(match.group(1)) >= 20
|
|
except ValueError:
|
|
return False
|
|
return False
|
|
|
|
|
|
def _host_shell_should_auto_poll(command: str) -> bool:
|
|
"""Poll implicit long-sleep jobs so a false completion cannot escape."""
|
|
text = str(command or "").lower()
|
|
if not _host_shell_requires_detach(command):
|
|
return False
|
|
return not any(
|
|
marker in text
|
|
for marker in ("#!bg", "#bg", "# bg", "#background", "# background", "@background")
|
|
)
|
|
|
|
|
|
def _docker_default_gateway_ips() -> set[str]:
|
|
gateways: set[str] = set()
|
|
try:
|
|
with open("/proc/net/route", "r", encoding="utf-8", errors="ignore") as fh:
|
|
for line in fh.readlines()[1:]:
|
|
parts = line.split()
|
|
if len(parts) < 3 or parts[1] != "00000000":
|
|
continue
|
|
raw = parts[2]
|
|
if len(raw) != 8:
|
|
continue
|
|
octets = [str(int(raw[i:i + 2], 16)) for i in range(6, -1, -2)]
|
|
gateways.add(".".join(octets))
|
|
except Exception:
|
|
return set()
|
|
return gateways
|
|
|
|
|
|
def _is_private_bridge_ip(host: str) -> bool:
|
|
"""LAN + CGNAT/Tailscale (100.64.0.0/10) literal IPs — the ranges a remote
|
|
TUI legitimately advertises when the backend is reachable over the LAN or
|
|
Tailscale. The 172.16/12 docker-private range is deliberately EXCLUDED:
|
|
on a container host those addresses are neighboring containers, not the
|
|
TUI — only the actual default gateway (checked separately) is trusted."""
|
|
parts = host.split(".")
|
|
if len(parts) != 4 or not all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
|
|
return False
|
|
a, b = int(parts[0]), int(parts[1])
|
|
if a == 10:
|
|
return True
|
|
if a == 192 and b == 168:
|
|
return True
|
|
if a == 100 and 64 <= b <= 127:
|
|
return True
|
|
return False
|
|
|
|
|
|
def is_host_shell_bridge_url_allowed(url: str) -> bool:
|
|
parsed = urlparse(str(url or "").strip())
|
|
host = (parsed.hostname or "").strip().lower().rstrip(".")
|
|
if parsed.scheme != "http" or not parsed.netloc or parsed.username or parsed.password:
|
|
return False
|
|
if (
|
|
host not in _HOST_SHELL_BRIDGE_HOSTS
|
|
and host not in _docker_default_gateway_ips()
|
|
and not _is_private_bridge_ip(host)
|
|
):
|
|
return False
|
|
if parsed.path not in ("", "/run"):
|
|
return False
|
|
if parsed.query or parsed.fragment:
|
|
return False
|
|
return True
|
|
|
|
|
|
def host_shell_bridge_endpoint_url(url: str, path: str) -> str:
|
|
"""Replace a validated bridge URL's path without changing its authority."""
|
|
parsed = urlsplit(url)
|
|
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
|
|
|
|
|
def _replace_workspace_alias(content: str, cwd: str) -> str:
|
|
"""Map virtual /workspace paths without corrupting absolute host paths."""
|
|
return re.sub(
|
|
r"(^|[\s'\"=:(\[,])/workspace(?=$|[/\s'\"`),;\]])",
|
|
lambda match: match.group(1) + cwd,
|
|
str(content or ""),
|
|
)
|
|
|
|
|
|
#: Roots the namespace argv mounts itself. A host path under one of these is
|
|
#: already reachable inside the namespace, so it needs no bind and must not get
|
|
#: a ``--dir`` chain: mkdir inside a read-only bind fails and takes the whole
|
|
#: namespace with it.
|
|
_NAMESPACE_MOUNTED_ROOTS = ("/usr", "/home", "/mnt")
|
|
|
|
#: Destinations a bind must never overlay. Replacing the private root, the
|
|
#: private /tmp or the workspace mount with a host directory undoes the
|
|
#: namespace from inside the argv that builds it.
|
|
_NAMESPACE_RESERVED_DESTS = frozenset({
|
|
"/", "/tmp", "/var", "/opt", "/etc", WORKSPACE_MOUNT,
|
|
"/root", "/run", "/proc", "/dev", "/sys", *_NAMESPACE_MOUNTED_ROOTS,
|
|
})
|
|
|
|
|
|
def _namespace_visible_without_bind(path: str) -> bool:
|
|
"""True when ``path`` is already reachable through a root the argv mounts."""
|
|
return any(
|
|
path == root or path.startswith(root + os.sep)
|
|
for root in _NAMESPACE_MOUNTED_ROOTS
|
|
)
|
|
|
|
|
|
def _namespace_dir_chain(path: str) -> list[str]:
|
|
"""``--dir`` args for every ancestor of ``path`` the argv has to create.
|
|
|
|
bwrap mounts into a tmpfs root, so a bind destination's parents have to
|
|
exist before the bind. Returns nothing when the parents already exist by
|
|
virtue of a mount the argv made — creating a directory inside a read-only
|
|
bind is an error, not a no-op.
|
|
"""
|
|
if _namespace_visible_without_bind(path):
|
|
return []
|
|
parents: list[str] = []
|
|
parent = os.path.dirname(path)
|
|
while parent not in ("/", "", "/tmp", "/etc", WORKSPACE_MOUNT, *_NAMESPACE_MOUNTED_ROOTS):
|
|
parents.append(parent)
|
|
parent = os.path.dirname(parent)
|
|
args: list[str] = []
|
|
for directory in reversed(parents):
|
|
args.extend(("--dir", directory))
|
|
return args
|
|
|
|
|
|
def _isolated_tmp_dir(cwd: str) -> str:
|
|
"""The workspace-local stand-in for the host ``/tmp``.
|
|
|
|
Creation is best-effort: the source tree is read-only in Docker and a
|
|
workspace can be mounted read-only, and a command that mentions ``/tmp/``
|
|
must not die with an OSError traceback because a scratch directory could
|
|
not be made. The rewrite still points at the workspace, so a command that
|
|
really needs to write there fails on its own terms, inside the boundary,
|
|
with its own error message.
|
|
"""
|
|
path = os.path.join(cwd, AGENT_ISOLATED_TMP_DIRNAME)
|
|
try:
|
|
os.makedirs(path, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
return path
|
|
|
|
|
|
def _execution_boundary(
|
|
cwd: str, *, wall_clock_s: int = DEFAULT_BASH_TIMEOUT,
|
|
) -> "containment.ContainmentProbe":
|
|
"""What this host can actually enforce for an agent command in ``cwd``.
|
|
|
|
The single place the shell and Python tools ask. Both used to decide for
|
|
themselves, by testing whether a namespace wrapper came back non-None, and
|
|
both then fell through to a regex if it had not — so "was that command
|
|
confined" had no answer and no field in the result. Routing the question
|
|
through :mod:`src.containment` means one mechanism table, one answer, and a
|
|
``containment`` block in the tool result either way.
|
|
|
|
``network`` is left inherited on purpose: ``--unshare-net`` was measured to
|
|
cut the loopback sidecars this product depends on (ChromaDB on 8100), and
|
|
the Dockerfile installs ``nmap``/``iproute2``/``dnsutils`` because
|
|
Docker-hosted agents are expected to do LAN work. It is a reported
|
|
dimension here, not an enforced one.
|
|
"""
|
|
try:
|
|
return containment.probe(
|
|
containment.agent_spec(
|
|
workspace=cwd,
|
|
env={},
|
|
wall_clock_s=wall_clock_s,
|
|
max_output_bytes=MAX_OUTPUT_CHARS,
|
|
)
|
|
)
|
|
except ValueError as exc:
|
|
# A workspace that is not a usable directory is a caller bug to
|
|
# containment, which raises rather than reporting. Here it must not
|
|
# take out the tool, and it is still a containment failure: nothing can
|
|
# be confined to a directory that is not there. Fail closed. The reason
|
|
# goes in the message rather than a traceback -- this is a known shape,
|
|
# not an unexpected exception.
|
|
logger.warning(
|
|
"execution boundary: cannot probe containment for workspace %r (%s); "
|
|
"treating every required dimension as unenforced",
|
|
cwd, exc,
|
|
)
|
|
return containment.ContainmentProbe(
|
|
mechanism="none",
|
|
enforced=frozenset(),
|
|
degraded=(),
|
|
unenforced_required=tuple(sorted(containment.DEFAULT_REQUIRED)),
|
|
mode=containment.CONTAINMENT_MODE,
|
|
)
|
|
|
|
|
|
#: What the fallback actually is, named so it cannot be mistaken for a
|
|
#: mechanism. ``_replace_workspace_alias`` rewrites the literal token
|
|
#: ``/workspace`` to the real path in the command string; a command that never
|
|
#: mentions ``/workspace`` is untouched by it and runs on the host unrestricted.
|
|
ALIAS_REWRITE_MECHANISM = "workspace_alias_rewrite"
|
|
|
|
#: Guards the one-per-process fallback warning below. Module state, because the
|
|
#: fact it reports is a property of the host rather than of a command.
|
|
_ALIAS_FALLBACK_LOGGED = False
|
|
|
|
|
|
def _filesystem_boundary_block(mechanism: str, mode: str, *, confined: bool) -> dict:
|
|
"""The ``containment`` block for a spawn these tools still build themselves.
|
|
|
|
Reports the **filesystem dimension only**, deliberately. The probe knows
|
|
this host could also give a process group and a real wall clock, but
|
|
Compatibility namespace previews assemble their own ``create_subprocess_*``
|
|
call and pass neither ``start_new_session`` nor a group-wide kill, so
|
|
listing those dimensions here would be the false claim
|
|
:mod:`src.containment` calls worse than an honest absence. They arrive when
|
|
this spawn path moves onto :func:`containment.run`, not before.
|
|
"""
|
|
return {
|
|
"mechanism": mechanism,
|
|
"mode": mode,
|
|
"enforced": [containment.FILESYSTEM] if confined else [],
|
|
"unenforced_required": [] if confined else [containment.FILESYSTEM],
|
|
"contained": confined,
|
|
"executed": True,
|
|
# Names the scope of the claim, so "process_tree is absent from
|
|
# enforced" reads as "not reported here" rather than "not enforced".
|
|
"reported_dimensions": [containment.FILESYSTEM],
|
|
}
|
|
|
|
|
|
def _contained_command(
|
|
content: str,
|
|
cwd: str,
|
|
*,
|
|
chdir: str = WORKSPACE_MOUNT,
|
|
interpreter_prefix: str | None = None,
|
|
) -> tuple[str, dict, bool]:
|
|
"""Resolve ``content`` into the strongest form this host can run.
|
|
|
|
Returns ``(command, containment_block, confined)``. The caller spawns
|
|
``command``, copies ``containment_block`` into its result verbatim, and
|
|
refuses instead when ``confined`` is false under enforcing mode.
|
|
|
|
This replaces ``namespaced or _replace_workspace_alias(...)``, the line this
|
|
ticket exists to delete. The two branches it chose between are not
|
|
comparable — one is a mount namespace, the other is a regex — and choosing
|
|
the second silently means an uncontained host execution reads in the
|
|
transcript exactly like a contained one. The fallback still happens under
|
|
an explicit report-only diagnostic mode; the difference is that it is now
|
|
recorded in the result.
|
|
|
|
:raises containment.ContainmentUnavailable: filesystem containment could
|
|
not be established and the mode is enforcing. The command is not run.
|
|
"""
|
|
probe = _execution_boundary(cwd)
|
|
wrapped = _wrap_workspace_namespace(
|
|
content, cwd, chdir=chdir, interpreter_prefix=interpreter_prefix,
|
|
)
|
|
# The probe's filesystem answer and the wrapper's None/not-None answer rest
|
|
# on the same functional namespace probe, so they agree
|
|
# by construction. `wrapped` is still what decides, because it is what
|
|
# actually runs: a probe that said yes to a wrapper that declined would be
|
|
# the same false claim in the other direction.
|
|
if wrapped is not None:
|
|
return wrapped, _filesystem_boundary_block(
|
|
probe.mechanism, probe.mode, confined=True,
|
|
), True
|
|
if probe.mode == containment.MODE_ENFORCING:
|
|
raise containment.ContainmentUnavailable(
|
|
frozenset({containment.FILESYSTEM}), ALIAS_REWRITE_MECHANISM,
|
|
)
|
|
# Once per process, not once per command. The host's ability to establish a
|
|
# namespace does not change between calls, so a per-call warning would
|
|
# drown the log on every macOS install while adding nothing — and the
|
|
# per-call fact is already in the result block, which is where a reader
|
|
# looking at one command will look.
|
|
global _ALIAS_FALLBACK_LOGGED
|
|
if not _ALIAS_FALLBACK_LOGGED:
|
|
_ALIAS_FALLBACK_LOGGED = True
|
|
logger.warning(
|
|
"execution boundary: no filesystem containment is available on this "
|
|
"host (mechanism %r); agent commands fall back to the %s, which is "
|
|
"a path rewrite and not a boundary. Reported per command in the "
|
|
"result's containment block.",
|
|
probe.mechanism, ALIAS_REWRITE_MECHANISM,
|
|
)
|
|
return (
|
|
_replace_workspace_alias(content, cwd),
|
|
_filesystem_boundary_block(
|
|
ALIAS_REWRITE_MECHANISM, probe.mode, confined=False,
|
|
),
|
|
False,
|
|
)
|
|
|
|
|
|
def _wrap_workspace_namespace(
|
|
content: str,
|
|
cwd: str,
|
|
*,
|
|
chdir: str = WORKSPACE_MOUNT,
|
|
interpreter_prefix: str | None = None,
|
|
) -> str | None:
|
|
"""Run a shell command with the active workspace mounted at /workspace.
|
|
|
|
Rewriting the command line alone is insufficient when a generated Python
|
|
script itself contains paths such as ``/workspace/chart.png``. A small
|
|
bubblewrap namespace preserves that public contract for each concurrent
|
|
agent without creating a process-global /workspace symlink.
|
|
"""
|
|
if IS_WINDOWS or not containment._bwrap_available():
|
|
return None
|
|
readonly = [path for path in ("/home", "/mnt") if os.path.isdir(path)]
|
|
# setup-python installs interpreters under /opt, and local CI virtualenvs
|
|
# can live under /tmp. Those paths are hidden by the private root/tmpfs.
|
|
# Expose only the active interpreter environment, read-only, so Python
|
|
# tools keep their installed packages without exposing the host /tmp.
|
|
if interpreter_prefix:
|
|
prefix = os.path.abspath(interpreter_prefix)
|
|
resolved_prefix = os.path.realpath(prefix)
|
|
already_visible = _namespace_visible_without_bind(prefix)
|
|
# A prefix is trusted only when it names a specific interpreter tree.
|
|
# In particular, never overlay the private root, tmpfs, or workspace
|
|
# with a broad host directory. Reject symlinked prefixes too: bwrap
|
|
# would otherwise bind the resolved source at a different destination.
|
|
has_environment_layout = (
|
|
os.path.isfile(os.path.join(prefix, "pyvenv.cfg"))
|
|
or (
|
|
os.path.isfile(os.path.join(prefix, "bin", "python"))
|
|
and os.path.isdir(os.path.join(
|
|
prefix, "lib", f"python{sys.version_info.major}.{sys.version_info.minor}",
|
|
))
|
|
)
|
|
)
|
|
if (
|
|
not already_visible
|
|
and prefix == resolved_prefix
|
|
and prefix not in _NAMESPACE_RESERVED_DESTS
|
|
and len(prefix.split(os.sep)) >= 3
|
|
and os.path.isdir(prefix)
|
|
and has_environment_layout
|
|
):
|
|
readonly.append(prefix)
|
|
spec = containment.ContainmentSpec(
|
|
workspace=cwd, env={}, wall_clock_s=DEFAULT_BASH_TIMEOUT,
|
|
readonly_extra=tuple(readonly),
|
|
)
|
|
args = containment._bwrap_prefix(spec)
|
|
args[-1] = chdir
|
|
args.extend(("/bin/bash", "-lc", content))
|
|
return shlex.join(args)
|
|
|
|
|
|
def _owned_spec(cwd: str, env: Optional[dict], timeout: int, readonly_extra: tuple = ()) -> 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)
|
|
visible = any(prefix == root or prefix.startswith(root + os.sep) for root in ("/usr", "/etc"))
|
|
if not visible and prefix not in _NAMESPACE_RESERVED_DESTS:
|
|
readonly.append(prefix)
|
|
from src.tool_execution import _agent_subprocess_env
|
|
clean_env = _agent_subprocess_env() if env is None else dict(env)
|
|
return containment.agent_spec(
|
|
cwd, clean_env, timeout,
|
|
readonly_extra=tuple(dict.fromkeys([*readonly, *readonly_extra])),
|
|
)
|
|
|
|
|
|
async def _run_owned_command(command, ctx: dict, *, tool: str, timeout: int, argv: bool = False,
|
|
readonly_extra: tuple = ()) -> dict:
|
|
from src.tool_execution import agent_cwd, _truncate
|
|
|
|
grant = None
|
|
launch = None
|
|
result = None
|
|
try:
|
|
from src.agent_runtime.process_resources import require_launch, publish_launch, validate_launch_spec
|
|
from src.agent_runtime.authority import active_request_authority
|
|
launch = require_launch(tool, cwd=agent_cwd())
|
|
authority = active_request_authority()
|
|
if (str(ctx.get("owner") or "").strip().casefold(), str(ctx.get("session_id") or "")) != (
|
|
authority.owner, authority.session_id):
|
|
raise ValueError("Native producer owner or session changed")
|
|
spec = _owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout, readonly_extra)
|
|
validate_launch_spec(launch, spec)
|
|
grant = containment.acquire(
|
|
spec,
|
|
owner=str(ctx.get("session_id") or ctx.get("owner") or tool),
|
|
)
|
|
containment._update_record(grant.id, launch_generation=launch.generation)
|
|
publish_launch(launch, authority, grant.id)
|
|
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"))
|
|
from src.agent_runtime.process_resources import attach_containment_processes
|
|
attach_containment_processes(launch, grant.id)
|
|
except containment.ContainmentUnavailable as exc:
|
|
return containment.unavailable_tool_result(exc, tool=tool)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
if grant is not None:
|
|
record = containment._load_records().get(grant.id, {})
|
|
if not record.get("pid") and not record.get("release"):
|
|
containment.release(grant, grace_s=0)
|
|
boundary = result.grant.to_dict() if result is not None else grant.to_dict() if grant else {}
|
|
boundary["executed"] = result is not None or bool(getattr(exc, "containment_executed", False))
|
|
if result is None and not getattr(exc, "containment_established", False):
|
|
boundary.update(contained=False, enforced=[])
|
|
return {"error": f"{tool}: execution failed: {exc}", "exit_code": 1,
|
|
"containment": boundary,
|
|
**({"failure_kind": "resource_linkage_unavailable",
|
|
"teardown": result.release.to_dict() if result.release else {"dead": False}}
|
|
if result is not None else {})}
|
|
finally:
|
|
if launch is not None and grant is not None:
|
|
record = containment._load_records().get(grant.id, {})
|
|
if (record.get("launch_generation") == launch.generation
|
|
and (record.get("release") or {}).get("dead") is True):
|
|
from src.agent_runtime.process_resources import retire_launch
|
|
try:
|
|
retire_launch(launch, grant.id)
|
|
except (OSError, ValueError, TypeError):
|
|
logger.warning("Foreground launch publication retirement failed", exc_info=True)
|
|
|
|
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
|
|
capture_note = " Captured output was truncated." if truncated else ""
|
|
common = {"containment": boundary, "teardown": teardown, "output_truncated": truncated}
|
|
if not teardown["dead"]:
|
|
return {**common, "error": f"{tool}: process teardown could not verify death.{capture_note}",
|
|
"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.{capture_note}",
|
|
"exit_code": 124, "timed_out": True, "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)}
|
|
if truncated:
|
|
note = "\n…[output truncated by containment capture limit]…"
|
|
output = output[:MAX_OUTPUT_CHARS - len(note)] + note
|
|
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
|
|
if isinstance(content, dict):
|
|
content = str(content.get("command") or content.get("cmd") or content.get("code") or "")
|
|
content = str(content or "").strip()
|
|
if not content:
|
|
return {
|
|
"error": "bash: command is required; no command was executed",
|
|
"exit_code": 1,
|
|
}
|
|
if re.search(r"(?:^|[;&|]\s*)sudo\b|^\s*sudo\b", content, re.IGNORECASE):
|
|
return {
|
|
"error": "bash: sudo/privilege escalation is unavailable in agent execution",
|
|
"exit_code": 1,
|
|
}
|
|
if re.search(r"\b(?:curl|wget)\b[^\n]*https?://", content, re.IGNORECASE):
|
|
return {
|
|
"error": (
|
|
"bash: ad-hoc HTTP downloads are disabled when native web tools are "
|
|
"available. Use pdf_extract for online PDFs, web_fetch for a concrete "
|
|
"page, or web_search for discovery. For PDF extraction "
|
|
"tasks, treat pdf_extract as the download+scan step: extract the "
|
|
"requested values, then create the requested output artifacts directly "
|
|
"from that evidence instead of trying curl/wget again."
|
|
),
|
|
"exit_code": 1,
|
|
}
|
|
from src.agent_runtime.process_resources import require_launch
|
|
from src.agent_runtime.resources import ResourceIdentityError
|
|
try:
|
|
require_launch("bash", cwd=agent_cwd(), content=content)
|
|
except ResourceIdentityError as error:
|
|
return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
|
|
if _ffmpeg_unicode_drawtext_needs_fontfile(content):
|
|
resolved_font = _resolve_fontfile_for_text(content)
|
|
resolved_hint = (
|
|
f" Host fontconfig resolved a covering font at `{resolved_font}`; "
|
|
f"pass `fontfile={resolved_font}`."
|
|
if resolved_font
|
|
else ""
|
|
)
|
|
return {
|
|
"error": (
|
|
"bash: ffmpeg drawtext with non-ASCII text requires an explicit "
|
|
"fontfile to avoid missing-glyph boxes."
|
|
+ resolved_hint
|
|
+ " If needed, resolve another suitable installed font with "
|
|
"`fc-match -f '%{file}' ':charset=<hex-codepoint>'`, then pass that "
|
|
"path as `drawtext=fontfile=...` and rerun the command."
|
|
),
|
|
"exit_code": 1,
|
|
}
|
|
if "/tmp/" in content:
|
|
isolated_tmp = _isolated_tmp_dir(agent_cwd())
|
|
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
|
|
return await _run_owned_command(content, ctx, tool="bash", timeout=DEFAULT_BASH_TIMEOUT)
|
|
|
|
class HostShellTool:
|
|
async def execute(self, content: str, ctx: dict) -> dict:
|
|
from src.tool_execution import _truncate
|
|
|
|
try:
|
|
args = json.loads(content) if str(content or "").strip().startswith("{") else {}
|
|
except Exception:
|
|
args = {}
|
|
command = str(
|
|
args.get("command")
|
|
or args.get("cmd")
|
|
or (content if not args else "")
|
|
or ""
|
|
).strip()
|
|
runtime = ctx.get("client_runtime_context")
|
|
if not isinstance(runtime, dict):
|
|
return {"error": "host_shell: no TUI host bridge advertised", "exit_code": 1}
|
|
bridge = runtime.get("host_shell_bridge") or runtime.get("hostShellBridge")
|
|
if not isinstance(bridge, dict):
|
|
return {"error": "host_shell: no TUI host bridge advertised", "exit_code": 1}
|
|
|
|
url = str(bridge.get("url") or "").strip()
|
|
token = str(bridge.get("token") or "").strip()
|
|
parsed = urlparse(url)
|
|
if not is_host_shell_bridge_url_allowed(url):
|
|
return {"error": "host_shell: invalid bridge URL", "exit_code": 1}
|
|
if not token:
|
|
return {"error": "host_shell: bridge token missing", "exit_code": 1}
|
|
run_url = host_shell_bridge_endpoint_url(url, "/run")
|
|
|
|
job_id = str(args.get("job_id") or "").strip()
|
|
if not command and not job_id:
|
|
return {"error": "host_shell: command or job_id required", "exit_code": 1}
|
|
|
|
try:
|
|
requested_timeout = int(args.get("timeout") or 30)
|
|
except Exception:
|
|
requested_timeout = 30
|
|
timeout = max(1, min(requested_timeout, 120))
|
|
|
|
from src import containment
|
|
from src.tool_execution import agent_cwd
|
|
|
|
sanitized_endpoint = f"{parsed.scheme}://{parsed.netloc}{parsed.path}" if parsed.scheme and parsed.netloc else "host_shell_bridge"
|
|
owner = str(ctx.get("session_id") or ctx.get("owner") or "host_shell")
|
|
spec = _owned_spec(agent_cwd(), ctx.get("subproc_env"), timeout)
|
|
grant = containment.declare_external_bridge(spec, owner=owner, endpoint=sanitized_endpoint)
|
|
boundary = grant.to_dict()
|
|
boundary["executed"] = False
|
|
|
|
request_body: dict[str, object] = {"timeout": timeout}
|
|
request_id = ""
|
|
if job_id:
|
|
request_body["job_id"] = job_id
|
|
else:
|
|
request_body["command"] = command
|
|
if bool(args.get("detach")) or _host_shell_requires_detach(command):
|
|
request_body["detach"] = True
|
|
else:
|
|
request_id = secrets.token_urlsafe(18)
|
|
request_body["request_id"] = request_id
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout + 5, trust_env=False) as client:
|
|
resp = await client.post(
|
|
run_url,
|
|
json=request_body,
|
|
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
|
)
|
|
if resp.status_code >= 400:
|
|
return {
|
|
"error": f"host_shell: bridge returned HTTP {resp.status_code}",
|
|
"exit_code": 1,
|
|
"host_bridge": "tui",
|
|
"containment": boundary,
|
|
}
|
|
data = resp.json()
|
|
|
|
# A long command may be detached even when the model omitted
|
|
# the flag. Complete that implicit job at the transport layer
|
|
# so the model cannot report success from a mere start ack.
|
|
if (
|
|
not job_id
|
|
and _host_shell_should_auto_poll(command)
|
|
and isinstance(data, dict)
|
|
and data.get("job_id")
|
|
and data.get("status") == "running"
|
|
):
|
|
auto_job_id = str(data["job_id"])
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
await asyncio.sleep(0.25)
|
|
poll = await client.post(
|
|
run_url,
|
|
json={"job_id": auto_job_id},
|
|
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
|
)
|
|
if poll.status_code >= 400:
|
|
return {
|
|
"error": f"host_shell: bridge returned HTTP {poll.status_code}",
|
|
"exit_code": 1,
|
|
"host_bridge": "tui",
|
|
"containment": boundary,
|
|
}
|
|
data = poll.json()
|
|
if not isinstance(data, dict):
|
|
continue
|
|
# A bridge may briefly lose the job record while its
|
|
# detached worker is being registered. Keep polling;
|
|
# do not turn that transient state into exit code 1.
|
|
if data.get("status") in {"running", "unknown"}:
|
|
continue
|
|
if data.get("status") != "running":
|
|
break
|
|
if isinstance(data, dict) and data.get("status") in {"running", "unknown"}:
|
|
data = {
|
|
**data,
|
|
"status": "running",
|
|
"detached": True,
|
|
"job_id": auto_job_id,
|
|
"output": "host job still running; poll the returned job_id",
|
|
"exit_code": 0,
|
|
}
|
|
except asyncio.CancelledError:
|
|
if request_id:
|
|
task = asyncio.create_task(
|
|
_cancel_host_shell_bridge_request(url, token, request_id),
|
|
name=f"cancel-host-shell-{request_id[:24]}",
|
|
)
|
|
_HOST_SHELL_CANCEL_TASKS.add(task)
|
|
task.add_done_callback(_HOST_SHELL_CANCEL_TASKS.discard)
|
|
raise
|
|
except Exception as e:
|
|
return {"error": f"host_shell: bridge call failed: {e}", "exit_code": 1, "containment": boundary}
|
|
|
|
if not isinstance(data, dict):
|
|
return {"error": "host_shell: bridge returned invalid payload", "exit_code": 1, "containment": boundary}
|
|
if data.get("error"):
|
|
return {
|
|
"error": _truncate(str(data["error"]), MAX_OUTPUT_CHARS),
|
|
"exit_code": 1,
|
|
"host_bridge": "tui",
|
|
"containment": boundary,
|
|
}
|
|
stdout = str(data.get("stdout") or data.get("output") or "")
|
|
stderr = str(data.get("stderr") or "")
|
|
raw_exit_code = data.get("exit_code")
|
|
if raw_exit_code is None:
|
|
raw_exit_code = data.get("returncode")
|
|
if raw_exit_code is None:
|
|
raw_exit_code = 0
|
|
if isinstance(raw_exit_code, bool) or not isinstance(raw_exit_code, int):
|
|
return {
|
|
"error": "host_shell: bridge returned an invalid exit_code",
|
|
"exit_code": 1,
|
|
"host_bridge": "tui",
|
|
"containment": boundary,
|
|
}
|
|
exit_code = raw_exit_code
|
|
output = stdout.rstrip()
|
|
if stderr.strip():
|
|
output = (output + "\nSTDERR: " + stderr.strip()).strip() if output else "STDERR: " + stderr.strip()
|
|
boundary["executed"] = True
|
|
result = {
|
|
"output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)",
|
|
"exit_code": exit_code,
|
|
"host_bridge": "tui",
|
|
"containment": boundary,
|
|
}
|
|
for key in ("detached", "job_id", "status", "running", "finished", "cwd"):
|
|
if key in data:
|
|
result[key] = data[key]
|
|
return result
|
|
|
|
def _python_child_runtime_failure(stdout: str, stderr: str, returncode: int) -> str:
|
|
"""Return an unmistakable nested-runtime failure hidden by Python exit 0.
|
|
|
|
Libraries such as Pillow may spawn a viewer and then return normally even
|
|
when that child cannot display anything. Keep this deliberately narrow:
|
|
arbitrary stderr is often a warning and must not turn a successful data
|
|
transformation into a failed tool call.
|
|
"""
|
|
if returncode != 0 or str(stdout or "").strip():
|
|
return ""
|
|
err = str(stderr or "").strip()
|
|
if re.search(r"(?im)^xdg-open: no method available for opening\b", err):
|
|
return err
|
|
return ""
|
|
|
|
|
|
def _python_with_visible_final_expression(content: str) -> str:
|
|
"""Give the Python tool REPL-like visibility for one final bare value.
|
|
|
|
The code still runs once as a normal script. Only a final expression is
|
|
assigned and rendered; explicit print calls and statement-only programs
|
|
retain their historical behavior.
|
|
"""
|
|
try:
|
|
tree = ast.parse(content)
|
|
except SyntaxError:
|
|
return content
|
|
if not tree.body or not isinstance(tree.body[-1], ast.Expr):
|
|
return content
|
|
final = tree.body[-1]
|
|
if (
|
|
isinstance(final.value, ast.Call)
|
|
and isinstance(final.value.func, ast.Name)
|
|
and final.value.func.id == "print"
|
|
):
|
|
return content
|
|
result_name = "__odysseus_final_expression_value__"
|
|
tree.body[-1:] = [
|
|
ast.Assign(targets=[ast.Name(id=result_name, ctx=ast.Store())], value=final.value),
|
|
ast.If(
|
|
test=ast.Compare(
|
|
left=ast.Name(id=result_name, ctx=ast.Load()),
|
|
ops=[ast.IsNot()],
|
|
comparators=[ast.Constant(value=None)],
|
|
),
|
|
body=[ast.Expr(value=ast.Call(
|
|
func=ast.Name(id="print", ctx=ast.Load()),
|
|
args=[ast.Call(
|
|
func=ast.Name(id="repr", ctx=ast.Load()),
|
|
args=[ast.Name(id=result_name, ctx=ast.Load())],
|
|
keywords=[],
|
|
)],
|
|
keywords=[],
|
|
))],
|
|
orelse=[],
|
|
),
|
|
]
|
|
ast.fix_missing_locations(tree)
|
|
return ast.unparse(tree)
|
|
|
|
|
|
def _python_with_configured_import_paths(content: str, env: dict | None) -> str:
|
|
"""Expose only explicitly configured package roots under Python ``-I``."""
|
|
raw = str((env or {}).get("ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES", ""))
|
|
paths = [item for item in raw.split(os.pathsep) if item and os.path.isabs(item)]
|
|
if not paths:
|
|
return content
|
|
return f"import site\n[site.addsitedir(path) for path in {paths!r}]\nexec(compile({content!r}, '<odysseus-python-tool>', 'exec'))"
|
|
|
|
|
|
class PythonTool:
|
|
async def execute(self, content: str, ctx: dict) -> dict:
|
|
from src.tool_execution import agent_cwd, _truncate
|
|
if re.search(
|
|
r"\b(?:requests\.(?:get|post|put|delete|request)|urllib\.request(?:\.\w+)?|httpx\.(?:get|post|request))\s*\(",
|
|
content,
|
|
re.IGNORECASE,
|
|
) and re.search(r"https?://", content, re.IGNORECASE) or (
|
|
re.search(r"[\"'](?:curl|wget)[\"']", content, re.IGNORECASE)
|
|
and re.search(r"https?://", content, re.IGNORECASE)
|
|
):
|
|
return {
|
|
"error": (
|
|
"python: ad-hoc HTTP access is disabled when native web tools are "
|
|
"available. Use pdf_extract for online PDFs, web_fetch for a concrete "
|
|
"page, or web_search for discovery. For PDF extraction "
|
|
"tasks, treat pdf_extract as the download+scan step: extract the "
|
|
"requested values, then create the requested output artifacts directly "
|
|
"from that evidence instead of trying requests/urllib again."
|
|
),
|
|
"exit_code": 1,
|
|
}
|
|
from src.agent_runtime.process_resources import require_launch
|
|
from src.agent_runtime.resources import ResourceIdentityError
|
|
try:
|
|
require_launch("python", cwd=agent_cwd(), content=content)
|
|
except ResourceIdentityError as error:
|
|
return {"error": str(error), "exit_code": 1, "blocked": True, "failure_kind": "resource_identity_denied"}
|
|
if "/tmp/" in content:
|
|
isolated_tmp = _isolated_tmp_dir(agent_cwd())
|
|
content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/")
|
|
_subproc_env = ctx.get("subproc_env")
|
|
content = _python_with_configured_import_paths(
|
|
_python_with_visible_final_expression(content), _subproc_env
|
|
)
|
|
# All Python code acquires the same server-defined boundary, including
|
|
# arithmetic and ordinary imports. Source text never selects a scope.
|
|
raw_paths = str((_subproc_env or {}).get("ODYSSEUS_PYTHON_TOOL_SITE_PACKAGES", ""))
|
|
roots = tuple(path for path in raw_paths.split(os.pathsep) if path and os.path.isabs(path))
|
|
return await _run_owned_command(
|
|
[sys.executable or "python", "-I", "-c", content], ctx,
|
|
tool="python", timeout=DEFAULT_PYTHON_TIMEOUT, argv=True, readonly_extra=roots,
|
|
)
|