from __future__ import annotations import asyncio import locale import os import shutil import subprocess import sys from dataclasses import dataclass from typing import Any from astrbot.api import logger from astrbot.core.computer.olayer import ( FileSystemComponent, PythonComponent, ShellComponent, ) from astrbot.core.computer.shell_session import PersistentShellSession from astrbot.core.utils.astrbot_path import ( get_astrbot_data_path, get_astrbot_root, get_astrbot_temp_path, get_astrbot_workspaces_path, ) from .base import ComputerBooter from .bwrap import _decode_shell_output _BLOCKED_COMMAND_PATTERNS = [ " rm -rf ", " rm -fr ", " rm -r ", " mkfs", " dd if=", " shutdown", " reboot", " poweroff", " halt", " sudo ", ":(){:|:&};:", " kill -9 ", " killall ", ] def _is_safe_command(command: str) -> bool: cmd = f" {command.strip().lower()} " return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS) def _ensure_safe_path(path: str) -> str: abs_path = os.path.abspath(path) allowed_roots = [ os.path.abspath(get_astrbot_root()), os.path.abspath(get_astrbot_data_path()), os.path.abspath(get_astrbot_temp_path()), os.path.abspath(get_astrbot_workspaces_path()), ] if not any(abs_path.startswith(root) for root in allowed_roots): raise PermissionError("Path is outside the allowed computer roots.") return abs_path def _decode_bytes_with_fallback( output: bytes | None, *, preferred_encoding: str | None = None, ) -> str: if output is None: return "" preferred = locale.getpreferredencoding(False) or "utf-8" attempted_encodings: list[str] = [] def _try_decode(encoding: str) -> str | None: normalized = encoding.lower() if normalized in attempted_encodings: return None attempted_encodings.append(normalized) try: return output.decode(encoding) except (LookupError, UnicodeDecodeError): return None for encoding in filter(None, [preferred_encoding, "utf-8", "utf-8-sig"]): if decoded := _try_decode(encoding): return decoded if os.name == "nt": for encoding in ("mbcs", "cp936", "gbk", "gb18030", preferred): if decoded := _try_decode(encoding): return decoded elif decoded := _try_decode(preferred): return decoded return output.decode("utf-8", errors="replace") async def _terminate_shell_process_tree(session: PersistentShellSession) -> None: """Terminate the persistent shell and its children after a timeout.""" proc = session._proc if proc is None or proc.returncode is not None: return taskkill_succeeded = False if sys.platform == "win32": try: taskkill_result = await asyncio.to_thread( subprocess.run, ["taskkill", "/F", "/T", "/PID", str(proc.pid)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5, ) taskkill_succeeded = taskkill_result.returncode == 0 except (OSError, subprocess.SubprocessError): taskkill_succeeded = False if not taskkill_succeeded: try: proc.kill() except ProcessLookupError: pass try: await asyncio.wait_for(proc.wait(), timeout=5) except (TimeoutError, ProcessLookupError): pass @dataclass class LocalShellComponent(ShellComponent): 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, session_id: str | None = None, ) -> dict[str, Any]: if not _is_safe_command(command): raise PermissionError("Blocked unsafe shell command.") key = session_id or "default" session = PersistentShellSession.get_or_create(key) execution = session.exec( command, cwd=cwd, env=env, timeout=None if timeout is not None and not background else timeout, background=background, ) if background or timeout is None: return await execution try: return await asyncio.wait_for(execution, timeout=timeout) except TimeoutError: await _terminate_shell_process_tree(session) await PersistentShellSession.cleanup(key) raise @staticmethod async def shutdown_all() -> None: await PersistentShellSession.cleanup_all() @dataclass class LocalPythonComponent(PythonComponent): async def exec( self, code: str, kernel_id: str | None = None, timeout: int = 30, silent: bool = False, cwd: str | None = None, ) -> dict[str, Any]: def _run() -> dict[str, Any]: try: working_dir = os.path.abspath(cwd) if cwd else get_astrbot_root() result = subprocess.run( [os.environ.get("PYTHON", sys.executable), "-c", code], check=False, timeout=timeout, capture_output=True, cwd=working_dir, ) stdout = "" if silent else _decode_shell_output(result.stdout) stderr = ( _decode_shell_output(result.stderr) if result.returncode != 0 else "" ) return { "data": { "output": {"text": stdout, "images": []}, "error": stderr, }, } except subprocess.TimeoutExpired: return { "data": { "output": {"text": "", "images": []}, "error": "Execution timed out.", }, } return await asyncio.to_thread(_run) @dataclass class LocalFileSystemComponent(FileSystemComponent): async def create_file( self, path: str, content: str = "", mode: int = 0o644, ) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = _ensure_safe_path(path) os.makedirs(os.path.dirname(abs_path), exist_ok=True) with open(abs_path, "w", encoding="utf-8") as f: f.write(content) os.chmod(abs_path, mode) return {"success": True, "path": abs_path} return await asyncio.to_thread(_run) async def read_file( self, path: str, encoding: str = "utf-8", offset: int | None = None, limit: int | None = None, ) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = _ensure_safe_path(path) with open(abs_path, "rb") as f: raw_content = f.read() content = _decode_bytes_with_fallback( raw_content, preferred_encoding=encoding, ) if offset is not None: lines = content.splitlines(keepends=True) start = offset if limit is not None: lines = lines[start : start + limit] else: lines = lines[start:] content = "".join(lines) elif limit is not None: lines = content.splitlines(keepends=True)[:limit] content = "".join(lines) return {"success": True, "content": content} return await asyncio.to_thread(_run) async def search_files( self, pattern: str, path: str | None = None, glob: str | None = None, after_context: int | None = None, before_context: int | None = None, ) -> dict[str, Any]: """Search file contents using grep-like pattern matching.""" def _run() -> dict[str, Any]: search_path = _ensure_safe_path(path) if path else "." cmd = ["grep", "-rn", pattern, search_path] if after_context is not None: cmd.extend(["-A", str(after_context)]) if before_context is not None: cmd.extend(["-B", str(before_context)]) if glob: cmd.extend(["--include", glob]) try: result = subprocess.run( cmd, check=False, capture_output=True, text=True, timeout=30, ) return { "success": True, "content": result.stdout, "error": result.stderr if result.returncode != 0 else "", } except subprocess.TimeoutExpired: return { "success": False, "output": "", "error": "Search timed out.", } return await asyncio.to_thread(_run) async def edit_file( self, path: str, old_string: str, new_string: str, replace_all: bool = False, encoding: str = "utf-8", ) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = _ensure_safe_path(path) with open(abs_path, encoding=encoding) as f: content = f.read() if replace_all: new_content = content.replace(old_string, new_string) else: new_content = content.replace(old_string, new_string, 1) if new_content == content: return { "success": False, "error": f"String '{old_string}' not found in file.", } with open(abs_path, "w", encoding=encoding) as f: f.write(new_content) return {"success": True, "path": abs_path} return await asyncio.to_thread(_run) async def write_file( self, path: str, content: str, mode: str = "w", encoding: str = "utf-8", ) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = _ensure_safe_path(path) os.makedirs(os.path.dirname(abs_path), exist_ok=True) with open(abs_path, mode, encoding=encoding) as f: f.write(content) return {"success": True, "path": abs_path} return await asyncio.to_thread(_run) async def delete_file(self, path: str) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = _ensure_safe_path(path) if os.path.isdir(abs_path): shutil.rmtree(abs_path) else: os.remove(abs_path) return {"success": True, "path": abs_path} return await asyncio.to_thread(_run) async def list_dir( self, path: str = ".", show_hidden: bool = False, ) -> dict[str, Any]: def _run() -> dict[str, Any]: abs_path = _ensure_safe_path(path) entries = os.listdir(abs_path) if not show_hidden: entries = [e for e in entries if not e.startswith(".")] return {"success": True, "entries": entries} return await asyncio.to_thread(_run) class LocalBooter(ComputerBooter): def __init__(self) -> None: self._fs = LocalFileSystemComponent() self._python = LocalPythonComponent() self._shell = LocalShellComponent() async def boot(self, session_id: str) -> None: logger.info(f"Local computer booter initialized for session: {session_id}") async def shutdown(self, **kwargs) -> None: await LocalShellComponent.shutdown_all() logger.info("Local computer booter shutdown complete.") @property def fs(self) -> FileSystemComponent: return self._fs @property def python(self) -> PythonComponent: return self._python @property def shell(self) -> ShellComponent: return self._shell async def upload_file(self, path: str, file_name: str) -> dict: raise NotImplementedError( "LocalBooter does not support upload_file operation. Use shell instead.", ) async def download_file(self, remote_path: str, local_path: str) -> None: raise NotImplementedError( "LocalBooter does not support download_file operation. Use shell instead.", ) async def available(self) -> bool: return True