import asyncio import ast import os import re import shlex import secrets import shutil import subprocess import sys import time import collections import json from typing import Optional, Callable, Awaitable, Tuple, Dict from urllib.parse import urlparse import httpx from src.constants import MAX_OUTPUT_CHARS # 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 PROGRESS_INTERVAL_S = 2.0 PROGRESS_TAIL_LINES = 12 TMUX_CAPTURE_LINES = 2000 _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: base = url.rsplit("/", 1)[0] try: timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0) async with httpx.AsyncClient(timeout=timeout) as client: await client.post( f"{base}/cancel", 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 ""), 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 _tmux_session_name(session_id: Optional[str]) -> str: raw = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(session_id or "default")).strip("-") return f"ody-agent-{raw[:80] or 'default'}" 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 ""), ) def _wrap_workspace_namespace( content: str, cwd: str, *, chdir: str = "/workspace", 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 shutil.which("bwrap"): return None args = [ "bwrap", "--die-with-parent", "--new-session", "--tmpfs", "/", "--dir", "/usr", "--ro-bind", "/usr", "/usr", "--symlink", "usr/bin", "/bin", "--symlink", "usr/lib", "/lib", "--symlink", "usr/lib64", "/lib64", "--symlink", "usr/bin", "/sbin", "--dir", "/etc", "--ro-bind", "/etc", "/etc", "--dir", "/home", "--bind", "/home", "/home", "--dir", "/mnt", "--bind", "/mnt", "/mnt", "--dir", "/tmp", "--tmpfs", "/tmp", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--dir", "/workspace", "--bind", cwd, "/workspace", ] # 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) mounted_roots = ("/usr", "/home", "/mnt") reserved_roots = { "/", "/tmp", "/var", "/opt", "/etc", "/workspace", "/root", "/run", "/proc", "/dev", "/sys", *mounted_roots, } already_visible = any( prefix == root or prefix.startswith(root + os.sep) for root in mounted_roots ) # 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 reserved_roots and len(prefix.split(os.sep)) >= 3 and os.path.isdir(prefix) and has_environment_layout ): parents = [] parent = os.path.dirname(prefix) while parent not in ("/", "/tmp", "/etc", "/workspace", *mounted_roots): parents.append(parent) parent = os.path.dirname(parent) for directory in reversed(parents): args.extend(("--dir", directory)) args.extend(("--ro-bind", prefix, prefix)) args.extend(("--chdir", chdir, "/bin/bash", "-lc", content)) return shlex.join(args) async def _run_exec(*args: str, timeout: float = 10) -> Tuple[str, str, int]: proc = await asyncio.create_subprocess_exec( *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: out_b, err_b = await asyncio.wait_for(proc.communicate(), timeout=timeout) except asyncio.TimeoutError: try: proc.kill() except Exception: pass return "", "timeout", 124 return ( out_b.decode("utf-8", errors="replace"), err_b.decode("utf-8", errors="replace"), proc.returncode or 0, ) async def _tmux_has_session(name: str) -> bool: _, _, rc = await _run_exec("tmux", "has-session", "-t", name, timeout=3) return rc == 0 async def _tmux_capture(name: str) -> str: out, _, _ = await _run_exec( "tmux", "capture-pane", "-p", "-J", "-S", f"-{TMUX_CAPTURE_LINES}", "-t", name, timeout=5, ) return out async def _tmux_send_line(name: str, line: str) -> None: if line: await _run_exec("tmux", "send-keys", "-t", name, "-l", line, timeout=5) await _run_exec("tmux", "send-keys", "-t", name, "C-m", timeout=5) async def _ensure_tmux_session(name: str, cwd: str, env: Optional[dict]) -> None: # tmux creates child panes from the long-lived server environment, not # necessarily from the app process that issued ``new-session``. On hosts # where tmux predates the Odysseus virtualenv this silently resolves # ``python`` to the system interpreter, losing plotting/PDF dependencies # and prompting futile pip-install loops. Reassert the small execution # environment on both new and reused panes. forwarded_env = { key: str(env[key]) for key in ("PATH", "VIRTUAL_ENV", "HOME", "TMPDIR") if env and env.get(key) } if await _tmux_has_session(name): if forwarded_env: exports = " ".join( f"{key}={shlex.quote(value)}" for key, value in forwarded_env.items() ) await _tmux_send_line(name, f"export {exports}") await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5) return env_args = [f"{key}={value}" for key, value in forwarded_env.items()] await _run_exec( "tmux", "new-session", "-d", "-s", name, "-c", cwd, "env", *env_args, f"TERM={env.get('TERM', 'xterm-256color') if env else 'xterm-256color'}", f"COLUMNS={env.get('COLUMNS', '120') if env else '120'}", f"LINES={env.get('LINES', '40') if env else '40'}", "/bin/bash", "--noprofile", "--norc", timeout=10, ) if not await _tmux_has_session(name): raise RuntimeError(f"failed to create tmux session {name}") await _run_exec("tmux", "send-keys", "-t", name, "stty -echo", "C-m", timeout=5) def _output_after_marker(capture: str, start_marker: str, end_marker: str) -> Tuple[str, bool]: lines = capture.splitlines() start_idx = -1 for idx, line in enumerate(lines): if line.strip() == start_marker: start_idx = idx if start_idx < 0: return capture, False end_idx = -1 for idx in range(start_idx + 1, len(lines)): if lines[idx].strip().startswith(end_marker): end_idx = idx if end_idx < 0: return "\n".join(lines[start_idx + 1:]), False return "\n".join(lines[start_idx + 1:end_idx]), True def _extract_marker_rc(capture: str, end_marker: str) -> int: for line in reversed(capture.splitlines()): stripped = line.strip() if stripped.startswith(end_marker): suffix = stripped[len(end_marker):].strip() if suffix.isdigit(): return int(suffix) return 0 async def _run_tmux_bash( content: str, *, session_id: str, cwd: str, env: Optional[dict], timeout: float, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, ) -> Tuple[str, str, Optional[int], bool]: name = _tmux_session_name(session_id) await _ensure_tmux_session(name, cwd, env) stamp = f"{int(time.time() * 1000)}-{abs(hash(content)) % 1000000}" start_marker = f"__ODYSSEUS_CMD_START_{stamp}__" end_prefix = f"__ODYSSEUS_CMD_END_{stamp}__:" # Execute each tool call in a non-interactive child shell. The tmux pane # is deliberately persistent, but handing its terminal stdin to commands # lets programs such as ffmpeg block forever on overwrite prompts. EOF is # the deterministic behavior expected from an agent tool invocation. child_command = f"/bin/bash -lc {shlex.quote(content)} timeout: try: await _run_exec("tmux", "send-keys", "-t", name, "C-c", timeout=3) except Exception: pass # Ctrl-C targets the pane's foreground process group, but a child # can outlive its wrapper shell and become an orphan. Destroy this # task-scoped session as the timeout boundary; the next tool call # recreates it through _ensure_tmux_session. try: await _run_exec("tmux", "kill-session", "-t", name, timeout=3) except Exception: pass cleaned = _clean_tmux_command_output(body, wrapped) return cleaned, "", 124, True await asyncio.sleep(0.5) def _clean_tmux_command_output(text: str, wrapped_command: str) -> str: lines = text.splitlines() wrapped_lines = {ln.rstrip() for ln in wrapped_command.splitlines() if ln.strip()} cleaned = [] for line in lines: raw = line.rstrip() stripped = raw.strip() if not stripped: cleaned.append(raw) continue if stripped in wrapped_lines: continue if stripped.startswith("__ody_rc=") or stripped.startswith("printf "): continue if re.fullmatch(r"(?:bash|sh)-[\d.]+\$ ?", stripped): continue if re.fullmatch(r"[\w.@:/~+-]+[#$] ?", stripped): continue cleaned.append(raw) return "\n".join(cleaned).strip() async def _run_subprocess_streaming( proc: asyncio.subprocess.Process, *, timeout: float, progress_cb: Optional[Callable[[Dict], Awaitable[None]]] = None, ) -> Tuple[str, str, Optional[int], bool]: started = time.time() stdout_full: list[str] = [] stderr_full: list[str] = [] tail = collections.deque(maxlen=PROGRESS_TAIL_LINES) async def _reader(stream, full_buf, label: str): if stream is None: return while True: line = await stream.readline() if not line: break decoded = line.decode("utf-8", errors="replace").rstrip("\n") full_buf.append(decoded) if label == "err": tail.append(f"! {decoded}") else: tail.append(decoded) async def _progress_emitter(): await asyncio.sleep(PROGRESS_INTERVAL_S) while True: if progress_cb: try: await progress_cb({ "elapsed_s": round(time.time() - started, 1), "tail": "\n".join(list(tail)), }) except Exception: pass await asyncio.sleep(PROGRESS_INTERVAL_S) rd_out = asyncio.create_task(_reader(proc.stdout, stdout_full, "out")) rd_err = asyncio.create_task(_reader(proc.stderr, stderr_full, "err")) prog_task = asyncio.create_task(_progress_emitter()) if progress_cb else None timed_out = False try: await asyncio.wait_for(proc.wait(), timeout=timeout) except asyncio.TimeoutError: timed_out = True try: proc.kill() except Exception: pass try: await asyncio.wait_for(proc.wait(), timeout=2) except Exception: pass except asyncio.CancelledError: try: proc.kill() except Exception: pass try: await asyncio.wait_for(proc.wait(), timeout=2) except Exception: pass for t in (rd_out, rd_err): t.cancel() if prog_task is not None: prog_task.cancel() raise finally: if prog_task is not None and not prog_task.done(): prog_task.cancel() try: await prog_task except (asyncio.CancelledError, Exception): pass for t in (rd_out, rd_err): try: await asyncio.wait_for(t, timeout=1) except Exception: pass return ( "\n".join(stdout_full), "\n".join(stderr_full), proc.returncode, timed_out, ) 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, } 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='`, then pass that " "path as `drawtext=fontfile=...` and rerun the command." ), "exit_code": 1, } isolated_tmp = os.path.join(agent_cwd(), ".tmp") if "/tmp/" in content: os.makedirs(isolated_tmp, exist_ok=True) content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/") namespaced = _wrap_workspace_namespace(content, agent_cwd()) content = namespaced or _replace_workspace_alias(content, agent_cwd()) 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"): stdout, stderr, rc, timed_out = await _run_tmux_bash( content, session_id=str(session_id), cwd=agent_cwd(), env=_subproc_env, timeout=DEFAULT_BASH_TIMEOUT, progress_cb=progress_cb, ) if timed_out: return { "error": f"bash: timed out after {DEFAULT_BASH_TIMEOUT}s — terminated task shell session", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), "tmux_session": _tmux_session_name(str(session_id)), } output = stdout.rstrip() err = stderr.rstrip() if err: output = (output + "\nSTDERR: " + err).strip() if output else "STDERR: " + err return { "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", "exit_code": rc or 0, "tmux_session": _tmux_session_name(str(session_id)), } 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} 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)} 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} 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} 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)) 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) as client: resp = await client.post( 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, } 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( 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, } 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} if not isinstance(data, dict): return {"error": "host_shell: bridge returned invalid payload", "exit_code": 1} if data.get("error"): return { "error": _truncate(str(data["error"]), MAX_OUTPUT_CHARS), "exit_code": 1, "host_bridge": "tui", } 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", } exit_code = raw_exit_code output = stdout.rstrip() if stderr.strip(): output = (output + "\nSTDERR: " + stderr.strip()).strip() if output else "STDERR: " + stderr.strip() result = { "output": _truncate(output, MAX_OUTPUT_CHARS) or "(no output)", "exit_code": exit_code, "host_bridge": "tui", } 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}, '', '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, } # Only create a mount namespace when the submitted code actually # relies on the public virtual path. Ordinary Python probes and # scripts should retain the real workspace as os.getcwd(); wrapping # every invocation would make that stable contract appear as # ``/workspace`` instead. needs_virtual_namespace = bool( "/workspace" in content or re.search(r"\b(?:runpy\.run_path|exec\s*\(|importlib\.)", content) ) isolated_tmp = os.path.join(agent_cwd(), ".tmp") if "/tmp/" in content: os.makedirs(isolated_tmp, exist_ok=True) content = content.replace("/tmp/", isolated_tmp.rstrip("/") + "/") progress_cb = ctx.get("progress_cb") _subproc_env = ctx.get("subproc_env") # Generated scripts commonly contain the public `/workspace/...` # paths shown in the tool contract. Rewriting the inline `-c` body # cannot repair paths embedded in a script loaded via `runpy`, and a # process-global `/workspace` symlink would break concurrent tasks. # Give Python the same per-task namespace Bash receives so both inline # code and loaded scripts see the stable virtual workspace root. namespaced_content = _python_with_configured_import_paths( _python_with_visible_final_expression(content), _subproc_env ) python_command = shlex.join((sys.executable or "python", "-I", "-c", namespaced_content)) # Code that explicitly uses the public /workspace path runs inside a # namespace whose stable cwd is that same bind. Host workspaces under # /tmp or another unbound parent are intentionally invisible by their # real path inside the namespace; trying to chdir there makes otherwise # valid native Python fail before execution. namespaced = ( _wrap_workspace_namespace( python_command, agent_cwd(), chdir="/workspace", interpreter_prefix=sys.prefix, ) if needs_virtual_namespace else None ) if namespaced: proc = await asyncio.create_subprocess_exec( "/bin/bash", "-lc", namespaced, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=_subproc_env, cwd=agent_cwd(), ) else: # Platforms without a usable namespace still receive the same # alias contract through a conservative source rewrite. content = _python_with_configured_import_paths( _python_with_visible_final_expression( _replace_workspace_alias(content, agent_cwd()) ), _subproc_env, ) proc = await asyncio.create_subprocess_exec( (sys.executable or "python"), "-I", "-c", content, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=_subproc_env, cwd=agent_cwd(), ) stdout, stderr, rc, timed_out = await _run_subprocess_streaming( proc, timeout=DEFAULT_PYTHON_TIMEOUT, progress_cb=progress_cb, ) if timed_out: return {"error": f"python: timed out after {DEFAULT_PYTHON_TIMEOUT}s — process killed", "exit_code": 124, "stdout": _truncate(stdout, MAX_OUTPUT_CHARS), "stderr": _truncate(stderr, MAX_OUTPUT_CHARS)} child_failure = _python_child_runtime_failure(stdout, stderr, rc) if child_failure: return { "error": _truncate( "python: a child operation failed despite a zero Python exit " "status:\n" + child_failure, MAX_OUTPUT_CHARS, ), "exit_code": 1, "stderr": _truncate(stderr, MAX_OUTPUT_CHARS), } 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}