diff --git a/astrbot/core/computer/booters/base.py b/astrbot/core/computer/booters/base.py index ea93a3d6d..d3f107450 100644 --- a/astrbot/core/computer/booters/base.py +++ b/astrbot/core/computer/booters/base.py @@ -1,4 +1,9 @@ -from ..olayer import FileSystemComponent, PythonComponent, ShellComponent +from ..olayer import ( + BrowserComponent, + FileSystemComponent, + PythonComponent, + ShellComponent, +) class ComputerBooter: @@ -11,6 +16,12 @@ class ComputerBooter: @property def shell(self) -> ShellComponent: ... + @property + def browser(self) -> BrowserComponent: + raise NotImplementedError( + f"{self.__class__.__name__} does not support browser capability." + ) + async def boot(self, session_id: str) -> None: ... async def shutdown(self) -> None: ... diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py new file mode 100644 index 000000000..66b72479a --- /dev/null +++ b/astrbot/core/computer/booters/shipyard_neo.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import os +import shlex +from typing import Any, cast + +from astrbot.api import logger + +from ..olayer import ( + BrowserComponent, + FileSystemComponent, + PythonComponent, + ShellComponent, +) +from .base import ComputerBooter + + +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 NeoPythonComponent(PythonComponent): + def __init__(self, sandbox: Any) -> None: + self._sandbox = sandbox + + async def exec( + self, + code: str, + kernel_id: str | None = None, + timeout: int = 30, + silent: bool = False, + ) -> dict[str, Any]: + _ = kernel_id # Bay runtime does not expose kernel_id in current SDK. + result = await self._sandbox.python.exec(code, timeout=timeout) + payload = _maybe_model_dump(result) + + output_text = payload.get("output", "") or "" + error_text = payload.get("error", "") or "" + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + rich_output = data.get("output") if isinstance(data.get("output"), dict) else {} + if not isinstance(rich_output.get("images"), list): + rich_output["images"] = [] + if "text" not in rich_output: + rich_output["text"] = output_text + + if silent: + rich_output["text"] = "" + + return { + "success": bool(payload.get("success", error_text == "")), + "data": { + "output": rich_output, + "error": error_text, + }, + "execution_id": payload.get("execution_id"), + "execution_time_ms": payload.get("execution_time_ms"), + "code": payload.get("code"), + "output": output_text, + "error": error_text, + } + + +class NeoShellComponent(ShellComponent): + def __init__(self, sandbox: Any) -> None: + self._sandbox = sandbox + + async def exec( + self, + command: str, + cwd: str | None = None, + env: dict[str, str] | None = None, + timeout: int | None = 30, + shell: bool = True, + background: bool = False, + ) -> dict[str, Any]: + if not shell: + return { + "stdout": "", + "stderr": "error: only shell mode is supported in shipyard_neo 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 = ( + f"nohup sh -lc {shlex.quote(run_command)} >/tmp/astrbot_bg.log 2>&1 & echo $!" + ) + + result = await self._sandbox.shell.exec( + run_command, + timeout=timeout or 30, + cwd=cwd, + ) + payload = _maybe_model_dump(result) + + stdout = payload.get("output", "") or "" + stderr = payload.get("error", "") or "" + exit_code = payload.get("exit_code") + if background: + pid: int | None = None + try: + pid = int(stdout.strip().splitlines()[-1]) + except Exception: + pid = None + return { + "pid": pid, + "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"), + } + + 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 NeoFileSystemComponent(FileSystemComponent): + def __init__(self, sandbox: Any) -> None: + self._sandbox = sandbox + + async def create_file( + self, + path: str, + content: str = "", + mode: int = 0o644, + ) -> dict[str, Any]: + _ = mode + await self._sandbox.filesystem.write_file(path, content) + return {"success": True, "path": path} + + async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]: + _ = encoding + content = await self._sandbox.filesystem.read_file(path) + return {"success": True, "path": path, "content": content} + + async def write_file( + self, + path: str, + content: str, + mode: str = "w", + encoding: str = "utf-8", + ) -> dict[str, Any]: + _ = mode + _ = encoding + await self._sandbox.filesystem.write_file(path, content) + return {"success": True, "path": path} + + async def delete_file(self, path: str) -> dict[str, Any]: + await self._sandbox.filesystem.delete(path) + return {"success": True, "path": path} + + async def list_dir( + self, + path: str = ".", + show_hidden: bool = False, + ) -> dict[str, Any]: + entries = await self._sandbox.filesystem.list_dir(path) + data = [] + for entry in entries: + item = _maybe_model_dump(entry) + if not show_hidden and str(item.get("name", "")).startswith("."): + continue + data.append(item) + return {"success": True, "path": path, "entries": data} + + +class NeoBrowserComponent(BrowserComponent): + def __init__(self, sandbox: Any) -> None: + self._sandbox = sandbox + + async def exec( + self, + cmd: str, + timeout: int = 30, + description: str | None = None, + tags: str | None = None, + learn: bool = False, + include_trace: bool = False, + ) -> dict[str, Any]: + result = await self._sandbox.browser.exec( + cmd, + timeout=timeout, + description=description, + tags=tags, + learn=learn, + include_trace=include_trace, + ) + return _maybe_model_dump(result) + + async def exec_batch( + self, + commands: list[str], + timeout: int = 60, + stop_on_error: bool = True, + description: str | None = None, + tags: str | None = None, + learn: bool = False, + include_trace: bool = False, + ) -> dict[str, Any]: + result = await self._sandbox.browser.exec_batch( + commands, + timeout=timeout, + stop_on_error=stop_on_error, + description=description, + tags=tags, + learn=learn, + include_trace=include_trace, + ) + return _maybe_model_dump(result) + + async def run_skill( + self, + skill_key: str, + timeout: int = 60, + stop_on_error: bool = True, + include_trace: bool = False, + description: str | None = None, + tags: str | None = None, + ) -> dict[str, Any]: + result = await self._sandbox.browser.run_skill( + skill_key=skill_key, + timeout=timeout, + stop_on_error=stop_on_error, + include_trace=include_trace, + description=description, + tags=tags, + ) + return _maybe_model_dump(result) + + +class ShipyardNeoBooter(ComputerBooter): + def __init__( + self, + endpoint_url: str, + access_token: str, + profile: str = "python-default", + ttl: int = 3600, + ) -> None: + self._endpoint_url = endpoint_url + self._access_token = access_token + self._profile = profile + self._ttl = ttl + self._client: Any = None + self._sandbox: Any = None + self._fs: FileSystemComponent | None = None + self._python: PythonComponent | None = None + self._shell: ShellComponent | None = None + self._browser: BrowserComponent | None = None + + @property + def bay_client(self) -> Any: + return self._client + + @property + def sandbox(self) -> Any: + return self._sandbox + + async def boot(self, session_id: str) -> None: + _ = session_id + if not self._endpoint_url or not self._access_token: + raise ValueError("Shipyard Neo sandbox configuration is incomplete.") + + from shipyard_neo import BayClient + + self._client = BayClient( + endpoint_url=self._endpoint_url, + access_token=self._access_token, + ) + await self._client.__aenter__() + self._sandbox = await self._client.create_sandbox( + profile=self._profile or "python-default", + ttl=self._ttl, + ) + + self._fs = NeoFileSystemComponent(self._sandbox) + self._python = NeoPythonComponent(self._sandbox) + self._shell = NeoShellComponent(self._sandbox) + self._browser = NeoBrowserComponent(self._sandbox) + logger.info( + "Got Shipyard Neo sandbox: %s (profile=%s)", + self._sandbox.id, + self._profile or "python-default", + ) + + async def shutdown(self) -> None: + if self._client is not None: + await self._client.__aexit__(None, None, None) + self._client = None + self._sandbox = None + + @property + def fs(self) -> FileSystemComponent: + if self._fs is None: + raise RuntimeError("ShipyardNeoBooter is not initialized.") + return self._fs + + @property + def python(self) -> PythonComponent: + if self._python is None: + raise RuntimeError("ShipyardNeoBooter is not initialized.") + return self._python + + @property + def shell(self) -> ShellComponent: + if self._shell is None: + raise RuntimeError("ShipyardNeoBooter is not initialized.") + return self._shell + + @property + def browser(self) -> BrowserComponent: + if self._browser is None: + raise RuntimeError("ShipyardNeoBooter is not initialized.") + return self._browser + + async def upload_file(self, path: str, file_name: str) -> dict: + if self._sandbox is None: + raise RuntimeError("ShipyardNeoBooter is not initialized.") + with open(path, "rb") as f: + content = f.read() + remote_path = file_name.lstrip("/") + await self._sandbox.filesystem.upload(remote_path, content) + return { + "success": True, + "message": "File uploaded successfully", + "file_path": remote_path, + } + + async def download_file(self, remote_path: str, local_path: str) -> None: + if self._sandbox is None: + raise RuntimeError("ShipyardNeoBooter is not initialized.") + content = await self._sandbox.filesystem.download(remote_path.lstrip("/")) + local_dir = os.path.dirname(local_path) + if local_dir: + os.makedirs(local_dir, exist_ok=True) + with open(local_path, "wb") as f: + f.write(cast(bytes, content)) + + async def available(self) -> bool: + if self._sandbox is None: + return False + try: + await self._sandbox.refresh() + status = getattr(self._sandbox.status, "value", str(self._sandbox.status)) + return status not in {"failed", "expired"} + except Exception as e: + logger.error(f"Error checking Shipyard Neo sandbox availability: {e}") + return False diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index 9750e7b64..0aee0eb09 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -40,15 +40,15 @@ async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None: upload_result = await booter.upload_file(zip_path, str(remote_zip)) if not upload_result.get("success", False): raise RuntimeError("Failed to upload skills bundle to sandbox.") - # Use -n flag to never overwrite existing files, fallback to Python if unzip unavailable + # Always overwrite with local source of truth to avoid stale skills in long-lived sandboxes. await booter.shell.exec( - f"unzip -n {remote_zip} -d {SANDBOX_SKILLS_ROOT} || " - f"python3 -c \"import zipfile, os, pathlib; z=zipfile.ZipFile('{remote_zip}'); " - f"[z.extract(m, '{SANDBOX_SKILLS_ROOT}') for m in z.namelist() " - f"if not os.path.exists(os.path.join('{SANDBOX_SKILLS_ROOT}', m))]\" || " - f"python -c \"import zipfile, os, pathlib; z=zipfile.ZipFile('{remote_zip}'); " - f"[z.extract(m, '{SANDBOX_SKILLS_ROOT}') for m in z.namelist() " - f"if not os.path.exists(os.path.join('{SANDBOX_SKILLS_ROOT}', m))]\"; " + f"mkdir -p {SANDBOX_SKILLS_ROOT} && " + f"rm -rf {SANDBOX_SKILLS_ROOT}/* && " + f"(unzip -o {remote_zip} -d {SANDBOX_SKILLS_ROOT} || " + f"python3 -c \"import zipfile; z=zipfile.ZipFile('{remote_zip}'); " + f"z.extractall('{SANDBOX_SKILLS_ROOT}')\" || " + f"python -c \"import zipfile; z=zipfile.ZipFile('{remote_zip}'); " + f"z.extractall('{SANDBOX_SKILLS_ROOT}')\"); " f"rm -f {remote_zip}" ) finally: @@ -66,7 +66,7 @@ async def get_booter( config = context.get_config(umo=session_id) sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {}) - booter_type = sandbox_cfg.get("booter", "shipyard") + booter_type = sandbox_cfg.get("booter", "shipyard_neo") if session_id in session_booter: booter = session_booter[session_id] @@ -86,6 +86,19 @@ async def get_booter( client = ShipyardBooter( endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions ) + elif booter_type == "shipyard_neo": + from .booters.shipyard_neo import ShipyardNeoBooter + + ep = sandbox_cfg.get("shipyard_neo_endpoint", "") + token = sandbox_cfg.get("shipyard_neo_access_token", "") + ttl = sandbox_cfg.get("shipyard_neo_ttl", 3600) + profile = sandbox_cfg.get("shipyard_neo_profile", "python-default") + client = ShipyardNeoBooter( + endpoint_url=ep, + access_token=token, + profile=profile, + ttl=ttl, + ) elif booter_type == "boxlite": from .booters.boxlite import BoxliteBooter @@ -104,6 +117,21 @@ async def get_booter( return session_booter[session_id] +async def sync_skills_to_active_sandboxes() -> None: + """Best-effort skills synchronization for all active sandbox sessions.""" + for session_id, booter in list(session_booter.items()): + try: + if not await booter.available(): + continue + await _sync_skills_to_sandbox(booter) + except Exception as e: + logger.warning( + "Failed to sync skills to sandbox for session %s: %s", + session_id, + e, + ) + + def get_local_booter() -> ComputerBooter: global local_booter if local_booter is None: diff --git a/astrbot/core/computer/olayer/__init__.py b/astrbot/core/computer/olayer/__init__.py index f099c079a..08225d260 100644 --- a/astrbot/core/computer/olayer/__init__.py +++ b/astrbot/core/computer/olayer/__init__.py @@ -1,5 +1,6 @@ +from .browser import BrowserComponent from .filesystem import FileSystemComponent from .python import PythonComponent from .shell import ShellComponent -__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"] +__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent", "BrowserComponent"] diff --git a/astrbot/core/computer/olayer/browser.py b/astrbot/core/computer/olayer/browser.py new file mode 100644 index 000000000..aa69f4501 --- /dev/null +++ b/astrbot/core/computer/olayer/browser.py @@ -0,0 +1,46 @@ +""" +Browser automation component +""" + +from typing import Any, Protocol + + +class BrowserComponent(Protocol): + """Browser operations component""" + + async def exec( + self, + cmd: str, + timeout: int = 30, + description: str | None = None, + tags: str | None = None, + learn: bool = False, + include_trace: bool = False, + ) -> dict[str, Any]: + """Execute a browser automation command""" + ... + + async def exec_batch( + self, + commands: list[str], + timeout: int = 60, + stop_on_error: bool = True, + description: str | None = None, + tags: str | None = None, + learn: bool = False, + include_trace: bool = False, + ) -> dict[str, Any]: + """Execute a browser automation command batch""" + ... + + async def run_skill( + self, + skill_key: str, + timeout: int = 60, + stop_on_error: bool = True, + include_trace: bool = False, + description: str | None = None, + tags: str | None = None, + ) -> dict[str, Any]: + """Run a browser skill by skill key""" + ... diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 546768812..dd7e03b97 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -120,11 +120,15 @@ DEFAULT_CONFIG = { }, "computer_use_runtime": "local", "sandbox": { - "booter": "shipyard", + "booter": "shipyard_neo", "shipyard_endpoint": "", "shipyard_access_token": "", "shipyard_ttl": 3600, "shipyard_max_sessions": 10, + "shipyard_neo_endpoint": "", + "shipyard_neo_access_token": "", + "shipyard_neo_profile": "python-default", + "shipyard_neo_ttl": 3600, }, }, # SubAgent orchestrator mode: @@ -2674,12 +2678,48 @@ CONFIG_METADATA_3 = { "provider_settings.sandbox.booter": { "description": "沙箱环境驱动器", "type": "string", - "options": ["shipyard"], - "labels": ["Shipyard"], + "options": ["shipyard_neo", "shipyard"], + "labels": ["Shipyard Neo", "Shipyard"], "condition": { "provider_settings.computer_use_runtime": "sandbox", }, }, + "provider_settings.sandbox.shipyard_neo_endpoint": { + "description": "Shipyard Neo API Endpoint", + "type": "string", + "hint": "Shipyard Neo(Bay) 服务的 API 访问地址。", + "condition": { + "provider_settings.computer_use_runtime": "sandbox", + "provider_settings.sandbox.booter": "shipyard_neo", + }, + }, + "provider_settings.sandbox.shipyard_neo_access_token": { + "description": "Shipyard Neo Access Token", + "type": "string", + "hint": "用于访问 Shipyard Neo(Bay) 的访问令牌。", + "condition": { + "provider_settings.computer_use_runtime": "sandbox", + "provider_settings.sandbox.booter": "shipyard_neo", + }, + }, + "provider_settings.sandbox.shipyard_neo_profile": { + "description": "Shipyard Neo Profile", + "type": "string", + "hint": "Shipyard Neo 沙箱 profile,如 python-default。", + "condition": { + "provider_settings.computer_use_runtime": "sandbox", + "provider_settings.sandbox.booter": "shipyard_neo", + }, + }, + "provider_settings.sandbox.shipyard_neo_ttl": { + "description": "Shipyard Neo Sandbox TTL", + "type": "int", + "hint": "Shipyard Neo 沙箱生存时间(秒)。", + "condition": { + "provider_settings.computer_use_runtime": "sandbox", + "provider_settings.sandbox.booter": "shipyard_neo", + }, + }, "provider_settings.sandbox.shipyard_endpoint": { "description": "Shipyard API Endpoint", "type": "string", diff --git a/pyproject.toml b/pyproject.toml index cc4bbe0ff..77d1c110c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ dependencies = [ "xinference-client", "tenacity>=9.1.2", "shipyard-python-sdk>=0.2.4", + "shipyard-neo-sdk @ git+https://github.com/AstrBotDevs/shipyard-neo.git#subdirectory=shipyard-neo-sdk", "python-socks>=2.8.0", ] @@ -109,6 +110,9 @@ reportMissingImports = false include = ["astrbot"] exclude = ["dashboard", "node_modules", "dist", "data", "tests"] +[tool.hatch.metadata] +allow-direct-references = true + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/requirements.txt b/requirements.txt index 98e1f85cf..0965a91d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,4 +53,5 @@ jieba>=0.42.1 markitdown-no-magika[docx,xls,xlsx]>=0.1.2 xinference-client tenacity>=9.1.2 -shipyard-python-sdk>=0.2.4 \ No newline at end of file +shipyard-python-sdk>=0.2.4 +shipyard-neo-sdk @ git+https://github.com/AstrBotDevs/shipyard-neo.git#subdirectory=shipyard-neo-sdk