diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index 44122361d6..a34d700ada 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -90,7 +90,7 @@ async def exec( command: str, cwd: str | None = None, env: dict[str, str] | None = None, - timeout: int | None = 30, + timeout: int | None = 300, shell: bool = True, background: bool = False, ) -> dict[str, Any]: @@ -123,7 +123,7 @@ def _run() -> dict[str, Any]: shell=shell, cwd=working_dir, env=run_env, - timeout=timeout, + timeout=timeout or 300, capture_output=True, ) return { diff --git a/astrbot/core/computer/booters/shell_background.py b/astrbot/core/computer/booters/shell_background.py new file mode 100644 index 0000000000..6fe94c133a --- /dev/null +++ b/astrbot/core/computer/booters/shell_background.py @@ -0,0 +1,18 @@ +import shlex + +_BACKGROUND_SPAWN_SCRIPT = ( + "import subprocess, sys; " + "p = subprocess.Popen(" + "['bash', '-lc', sys.argv[1]], " + "stdin=subprocess.DEVNULL, " + "stdout=subprocess.DEVNULL, " + "stderr=subprocess.DEVNULL, " + "start_new_session=True, " + "close_fds=True" + "); " + "print(p.pid)" +) + + +def build_detached_shell_command(command: str) -> str: + return f"python3 -c {shlex.quote(_BACKGROUND_SPAWN_SCRIPT)} {shlex.quote(command)}" diff --git a/astrbot/core/computer/booters/shipyard.py b/astrbot/core/computer/booters/shipyard.py index bed1b06531..a8375544da 100644 --- a/astrbot/core/computer/booters/shipyard.py +++ b/astrbot/core/computer/booters/shipyard.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shlex from typing import Any from shipyard import FileSystemComponent as ShipyardFileSystemComponent @@ -9,9 +10,93 @@ from ..olayer import FileSystemComponent, PythonComponent, ShellComponent from .base import ComputerBooter +from .shell_background import build_detached_shell_command from .shipyard_search_file_util import search_files_via_shell +def _maybe_model_dump(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if hasattr(value, "model_dump"): + dumped = value.model_dump() + if isinstance(dumped, dict): + return dumped + return {} + + +class ShipyardShellWrapper: + def __init__(self, _shipyard_shell: ShellComponent): + self._shell = _shipyard_shell + + async def exec( + self, + command: str, + cwd: str | None = None, + env: dict[str, str] | None = None, + timeout: int | None = 300, + shell: bool = True, + background: bool = False, + ) -> dict[str, Any]: + if not shell: + return { + "stdout": "", + "stderr": "error: only shell mode is supported in shipyard booter.", + "exit_code": 2, + "success": False, + } + + run_command = command + if env: + env_prefix = " ".join( + f"{k}={shlex.quote(str(v))}" for k, v in sorted(env.items()) + ) + run_command = f"{env_prefix} {run_command}" + + if background: + run_command = build_detached_shell_command(run_command) + + result = await self._shell.exec( + run_command, + timeout=timeout or 300, + cwd=cwd, + ) + payload = _maybe_model_dump(result) + + stdout = payload.get("output", payload.get("stdout", "")) or "" + stderr = payload.get("error", payload.get("stderr", "")) or "" + exit_code = payload.get("exit_code") + if background: + pid: int | None = None + try: + pid = int(str(stdout).strip().splitlines()[-1]) + except Exception: + pid = None + return { + "pid": pid, + "stdout": ( + f"Command is running in the background. pid={pid}" + if pid is not None + else "Command was submitted in the background." + ), + "stderr": stderr, + "exit_code": exit_code, + "success": bool(payload.get("success", not stderr)), + "execution_id": payload.get("execution_id"), + "execution_time_ms": payload.get("execution_time_ms"), + "command": payload.get("command"), + } + + return { + "stdout": stdout, + "stderr": stderr, + "exit_code": exit_code, + "success": bool(payload.get("success", not stderr)), + "execution_id": payload.get("execution_id"), + "execution_time_ms": payload.get("execution_time_ms"), + "command": payload.get("command"), + } + + class ShipyardFileSystemWrapper: def __init__( self, _shipyard_fs: ShipyardFileSystemComponent, _shipyard_shell: ShellComponent @@ -107,7 +192,8 @@ async def boot(self, session_id: str) -> None: ) logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}") self._ship = ship - self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._ship.shell) + self._shell = ShipyardShellWrapper(self._ship.shell) + self._fs = ShipyardFileSystemWrapper(self._ship.fs, self._shell) async def shutdown(self) -> None: logger.info("[Computer] Shipyard booter shutdown.") @@ -122,7 +208,7 @@ def python(self) -> PythonComponent: @property def shell(self) -> ShellComponent: - return self._ship.shell + return self._shell async def upload_file(self, path: str, file_name: str) -> dict: """Upload file to sandbox""" diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py index f3bb7e7b53..cec3c3a366 100644 --- a/astrbot/core/computer/booters/shipyard_neo.py +++ b/astrbot/core/computer/booters/shipyard_neo.py @@ -13,6 +13,7 @@ ShellComponent, ) from .base import ComputerBooter +from .shell_background import build_detached_shell_command from .shipyard_search_file_util import search_files_via_shell try: @@ -96,7 +97,7 @@ async def exec( command: str, cwd: str | None = None, env: dict[str, str] | None = None, - timeout: int | None = 30, + timeout: int | None = 300, shell: bool = True, background: bool = False, ) -> dict[str, Any]: @@ -116,11 +117,11 @@ async def exec( run_command = f"{env_prefix} {run_command}" if background: - run_command = f"nohup sh -lc {shlex.quote(run_command)} >/tmp/astrbot_bg.log 2>&1 & echo $!" + run_command = build_detached_shell_command(run_command) result = await self._sandbox.shell.exec( run_command, - timeout=timeout or 30, + timeout=timeout or 300, cwd=cwd, ) payload = _maybe_model_dump(result) @@ -136,7 +137,11 @@ async def exec( pid = None return { "pid": pid, - "stdout": stdout, + "stdout": ( + f"Command is running in the background. pid={pid}" + if pid is not None + else "Command was submitted in the background." + ), "stderr": stderr, "exit_code": exit_code, "success": bool(payload.get("success", not stderr)), diff --git a/astrbot/core/computer/olayer/shell.py b/astrbot/core/computer/olayer/shell.py index df2263b65a..aef1fd3b6a 100644 --- a/astrbot/core/computer/olayer/shell.py +++ b/astrbot/core/computer/olayer/shell.py @@ -13,7 +13,7 @@ async def exec( command: str, cwd: str | None = None, env: dict[str, str] | None = None, - timeout: int | None = 30, + timeout: int | None = 300, shell: bool = True, background: bool = False, ) -> dict[str, Any]: diff --git a/astrbot/core/tools/computer_tools/shell.py b/astrbot/core/tools/computer_tools/shell.py index cdefe97a0e..a6d44c2214 100644 --- a/astrbot/core/tools/computer_tools/shell.py +++ b/astrbot/core/tools/computer_tools/shell.py @@ -1,6 +1,9 @@ import json +import os import shlex +import uuid from dataclasses import dataclass, field +from pathlib import Path from typing import Any from astrbot.api import FunctionTool @@ -8,6 +11,7 @@ from astrbot.core.agent.tool import ToolExecResult from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.computer.computer_client import get_booter +from astrbot.core.utils.astrbot_path import get_astrbot_system_tmp_path from ..registry import builtin_tool from .util import check_admin_permission, is_local_runtime, workspace_root @@ -17,6 +21,32 @@ } +def _quote_redirect_path(path: str, *, local_runtime: bool) -> str: + if local_runtime and os.name == "nt": + escaped_path = path.replace('"', '""') + else: + escaped_path = path.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped_path}"' + + +def _build_background_output_path(*, local_runtime: bool) -> str: + file_name = f"astrbot_shell_stdout_{uuid.uuid4().hex[:8]}.log" + if local_runtime: + output_dir = Path(get_astrbot_system_tmp_path()) / "shell" + output_dir.mkdir(parents=True, exist_ok=True) + return str((output_dir / file_name).resolve(strict=False)) + return f"/tmp/{file_name}" + + +def _redirect_background_stdout_command( + command: str, + *, + output_path: str, + local_runtime: bool, +) -> str: + return f"({command}) > {_quote_redirect_path(output_path, local_runtime=local_runtime)} 2>&1" + + @builtin_tool(config=_COMPUTER_RUNTIME_TOOL_CONFIG) @dataclass class ExecuteShellTool(FunctionTool): @@ -32,12 +62,17 @@ class ExecuteShellTool(FunctionTool): }, "background": { "type": "boolean", - "description": "Whether to run the command in the background.", + "description": "Run the command in the background. Use the file read tool to read the output later. For long running commands, using this option.", "default": False, }, + "timeout": { + "type": "integer", + "description": "Optional timeout in seconds for the command execution.", + "default": 300, + }, "env": { "type": "object", - "description": "Optional environment variables to set for the file creation process.", + "description": "Optional environment variables to set.", "additionalProperties": {"type": "string"}, "default": {}, }, @@ -51,6 +86,7 @@ async def call( context: ContextWrapper[AstrAgentContext], command: str, background: bool = False, + timeout: int | None = 300, env: dict[str, Any] | None = None, ) -> ToolExecResult: if permission_error := check_admin_permission(context, "Shell execution"): @@ -69,6 +105,18 @@ async def call( current_workspace_root.mkdir(parents=True, exist_ok=True) cwd = str(current_workspace_root) + stdout_file: str | None = None + if background: + local_runtime = is_local_runtime(context) + stdout_file = _build_background_output_path( + local_runtime=local_runtime, + ) + command = _redirect_background_stdout_command( + command, + output_path=stdout_file, + local_runtime=local_runtime, + ) + env = dict(env or {}) effective_background = background and not _is_self_detached_command(command) result = await sb.shell.exec( @@ -76,7 +124,13 @@ async def call( cwd=cwd, background=effective_background, env=env, + timeout=timeout or 300, ) + if stdout_file: + result["stdout"] = ( + f"Command is running in the background. stdout/stderr is being " + f"written to `{stdout_file}`. Use astrbot_file_read_tool to read it." + ) return json.dumps(result, ensure_ascii=False) except Exception as e: detail = str(e) or type(e).__name__