From 195333e684826457004503923bf950f87891d594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sat, 9 May 2026 12:24:00 +0900 Subject: [PATCH 001/155] feat: extract sandbox runtimes from core --- astrbot/core/astr_agent_tool_exec.py | 22 +- astrbot/core/astr_main_agent.py | 102 +- astrbot/core/computer/booters/bay_manager.py | 259 --- astrbot/core/computer/booters/boxlite.py | 194 --- astrbot/core/computer/booters/cua.py | 830 ---------- astrbot/core/computer/booters/cua_defaults.py | 17 - astrbot/core/computer/booters/local.py | 20 +- .../core/computer/booters/shell_background.py | 18 - astrbot/core/computer/booters/shipyard.py | 249 --- astrbot/core/computer/booters/shipyard_neo.py | 700 -------- .../booters/shipyard_search_file_util.py | 148 -- astrbot/core/computer/computer_client.py | 210 +-- astrbot/core/computer/sandbox_manager.py | 629 +++++++ astrbot/core/computer/sandbox_models.py | 120 ++ astrbot/core/computer/sandbox_provider.py | 30 + astrbot/core/computer/sandbox_registry.py | 335 ++++ astrbot/core/config/default.py | 154 +- astrbot/core/tools/computer_tools/__init__.py | 38 - astrbot/core/tools/computer_tools/cua.py | 177 -- .../computer_tools/shipyard_neo/__init__.py | 31 - .../computer_tools/shipyard_neo/browser.py | 204 --- .../computer_tools/shipyard_neo/neo_skills.py | 556 ------- astrbot/dashboard/routes/__init__.py | 2 + astrbot/dashboard/routes/config.py | 59 - astrbot/dashboard/routes/sandbox.py | 165 ++ astrbot/dashboard/routes/skills.py | 341 +--- astrbot/dashboard/server.py | 2 + tests/test_computer_config.py | 215 +-- tests/test_computer_tool_permissions.py | 100 -- tests/test_dashboard.py | 279 ++-- tests/test_neo_skill_tools.py | 88 - tests/test_profile_aware_tools.py | 287 ---- tests/test_shipyard_neo_booter.py | 344 ---- tests/unit/test_astr_main_agent.py | 95 -- tests/unit/test_computer.py | 338 +--- tests/unit/test_cua_computer_use.py | 1458 ----------------- tests/unit/test_cua_extracted_from_core.py | 57 + tests/unit/test_sandbox_computer_client.py | 218 +++ tests/unit/test_sandbox_manager.py | 147 ++ tests/unit/test_sandbox_models.py | 61 + tests/unit/test_sandbox_provider.py | 15 + tests/unit/test_sandbox_registry.py | 102 ++ 42 files changed, 2140 insertions(+), 7276 deletions(-) delete mode 100644 astrbot/core/computer/booters/bay_manager.py delete mode 100644 astrbot/core/computer/booters/boxlite.py delete mode 100644 astrbot/core/computer/booters/cua.py delete mode 100644 astrbot/core/computer/booters/cua_defaults.py delete mode 100644 astrbot/core/computer/booters/shell_background.py delete mode 100644 astrbot/core/computer/booters/shipyard.py delete mode 100644 astrbot/core/computer/booters/shipyard_neo.py delete mode 100644 astrbot/core/computer/booters/shipyard_search_file_util.py create mode 100644 astrbot/core/computer/sandbox_manager.py create mode 100644 astrbot/core/computer/sandbox_models.py create mode 100644 astrbot/core/computer/sandbox_provider.py create mode 100644 astrbot/core/computer/sandbox_registry.py delete mode 100644 astrbot/core/tools/computer_tools/cua.py delete mode 100644 astrbot/core/tools/computer_tools/shipyard_neo/__init__.py delete mode 100644 astrbot/core/tools/computer_tools/shipyard_neo/browser.py delete mode 100644 astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py create mode 100644 astrbot/dashboard/routes/sandbox.py delete mode 100644 tests/test_computer_tool_permissions.py delete mode 100644 tests/test_neo_skill_tools.py delete mode 100644 tests/test_profile_aware_tools.py delete mode 100644 tests/test_shipyard_neo_booter.py delete mode 100644 tests/unit/test_cua_computer_use.py create mode 100644 tests/unit/test_cua_extracted_from_core.py create mode 100644 tests/unit/test_sandbox_computer_client.py create mode 100644 tests/unit/test_sandbox_manager.py create mode 100644 tests/unit/test_sandbox_models.py create mode 100644 tests/unit/test_sandbox_provider.py create mode 100644 tests/unit/test_sandbox_registry.py diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index de5caad554..aaaacebe6b 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -31,9 +31,6 @@ from astrbot.core.provider.entites import ProviderRequest from astrbot.core.provider.register import llm_tools from astrbot.core.tools.computer_tools import ( - CuaKeyboardTypeTool, - CuaMouseClickTool, - CuaScreenshotTool, ExecuteShellTool, FileDownloadTool, FileEditTool, @@ -211,17 +208,14 @@ def _get_runtime_computer_tools( edit_tool.name: edit_tool, grep_tool.name: grep_tool, } - if booter == "cua": - screenshot_tool = tool_mgr.get_builtin_tool(CuaScreenshotTool) - mouse_click_tool = tool_mgr.get_builtin_tool(CuaMouseClickTool) - keyboard_type_tool = tool_mgr.get_builtin_tool(CuaKeyboardTypeTool) - tools.update( - { - screenshot_tool.name: screenshot_tool, - mouse_click_tool.name: mouse_click_tool, - keyboard_type_tool.name: keyboard_type_tool, - } - ) + from astrbot.core.computer.computer_client import get_sandbox_provider_info + + provider_info = get_sandbox_provider_info(booter) + if provider_info: + for tool_name in provider_info.get("tool_names", []): + provider_tool = tool_mgr.get_func(tool_name) + if provider_tool and getattr(provider_tool, "active", True): + tools[provider_tool.name] = provider_tool return tools if runtime == "local": shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool) diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 3916215e5b..b2be308e2b 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -47,32 +47,15 @@ from astrbot.core.star.star import star_registry from astrbot.core.star.star_handler import star_map from astrbot.core.tools.computer_tools import ( - AnnotateExecutionTool, - BrowserBatchExecTool, - BrowserExecTool, - CreateSkillCandidateTool, - CreateSkillPayloadTool, - CuaKeyboardTypeTool, - CuaMouseClickTool, - CuaScreenshotTool, - EvaluateSkillCandidateTool, ExecuteShellTool, FileDownloadTool, FileEditTool, FileReadTool, FileUploadTool, FileWriteTool, - GetExecutionHistoryTool, - GetSkillPayloadTool, GrepTool, - ListSkillCandidatesTool, - ListSkillReleasesTool, LocalPythonTool, - PromoteSkillCandidateTool, PythonTool, - RollbackSkillReleaseTool, - RunBrowserSkillTool, - SyncSkillReleaseTool, normalize_umo_for_workspace, ) from astrbot.core.tools.cron_tools import FutureTaskTool @@ -990,15 +973,7 @@ def _apply_sandbox_tools( req.func_tool = ToolSet() if req.system_prompt is None: req.system_prompt = "" - booter = config.sandbox_cfg.get("booter", "shipyard_neo") - if booter == "shipyard": - ep = config.sandbox_cfg.get("shipyard_endpoint", "") - at = config.sandbox_cfg.get("shipyard_access_token", "") - if not ep or not at: - logger.error("Shipyard sandbox configuration is incomplete.") - return - os.environ["SHIPYARD_ENDPOINT"] = ep - os.environ["SHIPYARD_ACCESS_TOKEN"] = at + booter = config.sandbox_cfg.get("booter", "") tool_mgr = llm_tools req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExecuteShellTool)) @@ -1009,73 +984,14 @@ def _apply_sandbox_tools( req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileWriteTool)) req.func_tool.add_tool(tool_mgr.get_builtin_tool(FileEditTool)) req.func_tool.add_tool(tool_mgr.get_builtin_tool(GrepTool)) - if booter == "shipyard_neo": - # Neo-specific path rule: filesystem tools operate relative to sandbox - # workspace root. Do not prepend "/workspace". - req.system_prompt += ( - "\n[Shipyard Neo File Path Rule]\n" - "When using sandbox filesystem tools (upload/download/read/write/list/delete), " - "always pass paths relative to the sandbox workspace root. " - "Example: use `baidu_homepage.png` instead of `/workspace/baidu_homepage.png`.\n" - ) - - req.system_prompt += ( - "\n[Neo Skill Lifecycle Workflow]\n" - "When user asks to create/update a reusable skill in Neo mode, use lifecycle tools instead of directly writing local skill folders.\n" - "Preferred sequence:\n" - "1) Use `astrbot_create_skill_payload` to store canonical payload content and get `payload_ref`.\n" - "2) Use `astrbot_create_skill_candidate` with `skill_key` + `source_execution_ids` (and optional `payload_ref`) to create a candidate.\n" - "3) Use `astrbot_promote_skill_candidate` to release: `stage=canary` for trial; `stage=stable` for production.\n" - "For stable release, set `sync_to_local=true` to sync `payload.skill_markdown` into local `SKILL.md`.\n" - "Do not treat ad-hoc generated files as reusable Neo skills unless they are captured via payload/candidate/release.\n" - "To update an existing skill, create a new payload/candidate and promote a new release version; avoid patching old local folders directly.\n" - ) - - # Determine sandbox capabilities from an already-booted session. - # If no session exists yet (first request), capabilities is None - # and we register all tools conservatively. - from astrbot.core.computer.computer_client import session_booter - - sandbox_capabilities: list[str] | None = None - existing_booter = session_booter.get(session_id) - if existing_booter is not None: - sandbox_capabilities = getattr(existing_booter, "capabilities", None) - - # Browser tools: only register if profile supports browser - # (or if capabilities are unknown because sandbox hasn't booted yet) - if sandbox_capabilities is None or "browser" in sandbox_capabilities: - req.func_tool.add_tool(tool_mgr.get_builtin_tool(BrowserExecTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(BrowserBatchExecTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(RunBrowserSkillTool)) - - # Neo-specific tools (always available for shipyard_neo) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(GetExecutionHistoryTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(AnnotateExecutionTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateSkillPayloadTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(GetSkillPayloadTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateSkillCandidateTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListSkillCandidatesTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(EvaluateSkillCandidateTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(PromoteSkillCandidateTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListSkillReleasesTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(RollbackSkillReleaseTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(SyncSkillReleaseTool)) - - if booter == "cua": - req.system_prompt += ( - "\n[CUA Desktop Control]\n" - "Use `astrbot_execute_shell` with `background=true` to launch GUI apps. " - 'Use Firefox for browser tasks, for example `firefox "https://example.com"`. ' - "After each visible step, call `astrbot_cua_screenshot` with " - "`send_to_user=true` and `return_image_to_llm=true` so the user can " - "monitor progress. When typing, inspect the screenshot first and confirm " - "the target field is focused and empty or safe to append to. Use " - "`astrbot_cua_mouse_click` for coordinates and `astrbot_cua_keyboard_type` " - "for text input; use text=`\\n` for Enter.\n" - ) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(CuaScreenshotTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(CuaMouseClickTool)) - req.func_tool.add_tool(tool_mgr.get_builtin_tool(CuaKeyboardTypeTool)) + from astrbot.core.computer.computer_client import get_sandbox_provider_info + + provider_info = get_sandbox_provider_info(booter) + if provider_info: + for tool_name in provider_info.get("tool_names", []): + tool = tool_mgr.get_func(tool_name) + if tool and getattr(tool, "active", True): + req.func_tool.add_tool(tool) req.system_prompt = f"{req.system_prompt or ''}\n{SANDBOX_MODE_PROMPT}\n" diff --git a/astrbot/core/computer/booters/bay_manager.py b/astrbot/core/computer/booters/bay_manager.py deleted file mode 100644 index 61ccc1b3a5..0000000000 --- a/astrbot/core/computer/booters/bay_manager.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Manage Bay container lifecycle for zero-config Shipyard Neo integration. - -When no Bay endpoint is configured, AstrBot can automatically start a Bay -container using the Docker socket (like BoxliteBooter does for Ship -containers). -""" - -from __future__ import annotations - -import asyncio -import io -import json -import tarfile -from typing import Any - -import aiodocker -import aiohttp - -from astrbot.api import logger - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -BAY_IMAGE = "ghcr.io/astrbotdevs/shipyard-neo-bay:latest" -BAY_CONTAINER_NAME = "astrbot-bay" -BAY_LABEL = "astrbot.bay.managed" -BAY_PORT = 8114 -HEALTH_TIMEOUT_S = 60 -HEALTH_POLL_INTERVAL_S = 2 - - -class BayContainerManager: - """Start / reuse / stop a Bay container via Docker Engine API.""" - - def __init__( - self, - image: str = BAY_IMAGE, - host_port: int = BAY_PORT, - ) -> None: - self._image = image - self._host_port = host_port - self._docker: aiodocker.Docker | None = None - self._container: Any = None - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - async def ensure_running(self) -> str: - """Make sure a Bay container is running. Returns the endpoint URL. - - If a container labelled ``astrbot.bay.managed`` already exists - and is running, it will be reused. Otherwise a new container is - created from *self._image*. - """ - try: - self._docker = aiodocker.Docker() - except Exception as exc: - raise RuntimeError( - "Failed to connect to Docker daemon. " - "Ensure Docker is installed and running, or configure " - "an explicit Bay endpoint instead of auto-start mode." - ) from exc - - # 1. Look for an existing managed container - existing = await self._find_managed_container() - if existing is not None: - state = existing["State"] - if state.get("Running"): - cid = existing["Id"][:12] - logger.info("[BayManager] Reusing existing Bay container: %s", cid) - self._container = await self._docker.containers.get(existing["Id"]) - return f"http://127.0.0.1:{self._host_port}" - else: - # Container exists but stopped — restart it - logger.info("[BayManager] Restarting stopped Bay container") - container = await self._docker.containers.get(existing["Id"]) - await container.start() - self._container = container - return f"http://127.0.0.1:{self._host_port}" - - # 2. Pull image if needed - await self._pull_image_if_needed() - - # 3. Create and start container - logger.info( - "[BayManager] Starting Bay container: image=%s, port=%d", - self._image, - self._host_port, - ) - config = { - "Image": self._image, - "Labels": {BAY_LABEL: "true"}, - "Env": [ - "BAY_SERVER__HOST=0.0.0.0", - f"BAY_SERVER__PORT={BAY_PORT}", - "BAY_DATA_DIR=/app/data", - # allow_anonymous=false → auto-provisions API key - "BAY_SECURITY__ALLOW_ANONYMOUS=false", - ], - "HostConfig": { - "PortBindings": { - f"{BAY_PORT}/tcp": [{"HostPort": str(self._host_port)}], - }, - "Binds": [ - # Bay needs Docker socket to create sandbox containers - "/var/run/docker.sock:/var/run/docker.sock", - ], - "RestartPolicy": {"Name": "unless-stopped"}, - }, - } - self._container = await self._docker.containers.create_or_replace( - BAY_CONTAINER_NAME, config - ) - await self._container.start() - logger.info("[BayManager] Bay container started: %s", BAY_CONTAINER_NAME) - - return f"http://127.0.0.1:{self._host_port}" - - async def wait_healthy(self, timeout: int = HEALTH_TIMEOUT_S) -> None: - """Block until Bay's ``/health`` endpoint returns 200.""" - url = f"http://127.0.0.1:{self._host_port}/health" - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - last_error: str = "" - - async with aiohttp.ClientSession() as session: - while loop.time() < deadline: - try: - async with session.get( - url, timeout=aiohttp.ClientTimeout(total=3) - ) as resp: - if resp.status == 200: - logger.info("[BayManager] Bay is healthy") - return - last_error = f"HTTP {resp.status}" - except Exception as exc: - last_error = str(exc) - - await asyncio.sleep(HEALTH_POLL_INTERVAL_S) - - raise TimeoutError( - f"Bay did not become healthy within {timeout}s (last error: {last_error})" - ) - - async def read_credentials(self) -> str: - """Read auto-provisioned API key from Bay container. - - Bay writes ``credentials.json`` to its data directory when - ``allow_anonymous=false`` and no explicit API key is set. - """ - if self._container is None: - return "" - - try: - # Read credentials.json from container filesystem - tar_stream = await self._container.get_archive("/app/data/credentials.json") - # get_archive returns (tar_data, stat) - tar_data = tar_stream - - if isinstance(tar_data, dict): - raw = tar_data.get("data", b"") - elif isinstance(tar_data, tuple): - # (stream, stat_info) - raw = b"" - stream = tar_data[0] - if hasattr(stream, "read"): - raw = await stream.read() - elif isinstance(stream, bytes): - raw = stream - else: - # It might be a chunked response - chunks = [] - async for chunk in stream: - chunks.append(chunk) - raw = b"".join(chunks) - else: - raw = tar_data if isinstance(tar_data, bytes) else b"" - - if not raw: - logger.debug("[BayManager] Empty tar response from container") - return "" - - tario = io.BytesIO(raw) - with tarfile.open(fileobj=tario) as tar: - for member in tar.getmembers(): - f = tar.extractfile(member) - if f: - creds = json.loads(f.read().decode("utf-8")) - api_key = creds.get("api_key", "") - if api_key: - masked = ( - f"{api_key[:8]}..." - if len(api_key) >= 10 - else "redacted" - ) - logger.info( - "[BayManager] Auto-discovered Bay API key: %s", - masked, - ) - return api_key - except Exception as exc: - logger.debug( - "[BayManager] Failed to read credentials from container: %s", exc - ) - - return "" - - async def close_client(self) -> None: - """Close the Docker client without stopping the container. - - The Bay container stays running for reuse by future sessions. - """ - if self._docker is not None: - await self._docker.close() - self._docker = None - - async def stop(self) -> None: - """Stop and remove the managed Bay container.""" - if self._container is not None: - try: - await self._container.stop() - await self._container.delete(force=True) - logger.info("[BayManager] Bay container stopped and removed") - except Exception as exc: - logger.debug("[BayManager] Error stopping Bay container: %s", exc) - finally: - self._container = None - - await self.close_client() - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - async def _find_managed_container(self) -> dict | None: - """Find an existing container with our management label.""" - assert self._docker is not None - containers = await self._docker.containers.list( - all=True, - filters=json.dumps({"label": [f"{BAY_LABEL}=true"]}), - ) - if containers: - # Inspect first match to get full state - return await containers[0].show() - return None - - async def _pull_image_if_needed(self) -> None: - """Pull the Bay image if it doesn't exist locally.""" - assert self._docker is not None - try: - await self._docker.images.inspect(self._image) - logger.debug("[BayManager] Image %s already exists", self._image) - except aiodocker.exceptions.DockerError: - logger.info("[BayManager] Pulling image %s ...", self._image) - # Pull with progress logging - await self._docker.images.pull(self._image) - logger.info("[BayManager] Image %s pulled successfully", self._image) diff --git a/astrbot/core/computer/booters/boxlite.py b/astrbot/core/computer/booters/boxlite.py deleted file mode 100644 index aa3ca59761..0000000000 --- a/astrbot/core/computer/booters/boxlite.py +++ /dev/null @@ -1,194 +0,0 @@ -import asyncio -import random -from typing import Any - -import aiohttp -import boxlite -from shipyard import FileSystemComponent as ShipyardFileSystemComponent -from shipyard.python import PythonComponent as ShipyardPythonComponent -from shipyard.shell import ShellComponent as ShipyardShellComponent - -from astrbot.api import logger - -from ..olayer import FileSystemComponent, PythonComponent, ShellComponent -from .base import ComputerBooter -from .shipyard import ShipyardFileSystemWrapper - - -class MockShipyardSandboxClient: - def __init__(self, sb_url: str) -> None: - self.sb_url = sb_url.rstrip("/") - - async def _exec_operation( - self, - ship_id: str, - operation_type: str, - payload: dict[str, Any], - session_id: str, - ) -> dict[str, Any]: - async with aiohttp.ClientSession() as session: - headers = {"X-SESSION-ID": session_id} - async with session.post( - f"{self.sb_url}/{operation_type}", - json=payload, - headers=headers, - ) as response: - if response.status == 200: - return await response.json() - else: - error_text = await response.text() - raise Exception( - f"Failed to exec operation: {response.status} {error_text}" - ) - - async def upload_file(self, path: str, remote_path: str) -> dict: - """Upload a file to the sandbox""" - url = f"http://{self.sb_url}/upload" - - try: - # Read file content - with open(path, "rb") as f: - file_content = f.read() - - # Create multipart form data - data = aiohttp.FormData() - data.add_field( - "file", - file_content, - filename=remote_path.split("/")[-1], - content_type="application/octet-stream", - ) - data.add_field("file_path", remote_path) - - timeout = aiohttp.ClientTimeout(total=120) # 2 minutes for file upload - - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post(url, data=data) as response: - if response.status == 200: - logger.info( - "[Computer] File uploaded to Boxlite sandbox: %s", - remote_path, - ) - return { - "success": True, - "message": "File uploaded successfully", - "file_path": remote_path, - } - else: - error_text = await response.text() - return { - "success": False, - "error": f"Server returned {response.status}: {error_text}", - "message": "File upload failed", - } - - except aiohttp.ClientError as e: - logger.error(f"Failed to upload file: {e}") - return { - "success": False, - "error": f"Connection error: {str(e)}", - "message": "File upload failed", - } - except asyncio.TimeoutError: - return { - "success": False, - "error": "File upload timeout", - "message": "File upload failed", - } - except FileNotFoundError: - logger.error(f"File not found: {path}") - return { - "success": False, - "error": f"File not found: {path}", - "message": "File upload failed", - } - except Exception as e: - logger.error(f"Unexpected error uploading file: {e}") - return { - "success": False, - "error": f"Internal error: {str(e)}", - "message": "File upload failed", - } - - async def wait_healthy(self, ship_id: str, session_id: str) -> None: - """Mock wait healthy""" - loop = 60 - while loop > 0: - try: - logger.info( - f"Checking health for sandbox {ship_id} on {self.sb_url}..." - ) - url = f"{self.sb_url}/health" - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 200: - logger.info(f"Sandbox {ship_id} is healthy") - return - except Exception: - await asyncio.sleep(1) - loop -= 1 - - -class BoxliteBooter(ComputerBooter): - async def boot(self, session_id: str) -> None: - logger.info( - f"Booting(Boxlite) for session: {session_id}, this may take a while..." - ) - random_port = random.randint(20000, 30000) - self.box = boxlite.SimpleBox( - image="soulter/shipyard-ship", - memory_mib=512, - cpus=1, - ports=[ - { - "host_port": random_port, - "guest_port": 8123, - } - ], - ) - await self.box.start() - logger.info(f"Boxlite booter started for session: {session_id}") - self.mocked = MockShipyardSandboxClient( - sb_url=f"http://127.0.0.1:{random_port}" - ) - self._python = ShipyardPythonComponent( - client=self.mocked, # type: ignore - ship_id=self.box.id, - session_id=session_id, - ) - self._shell = ShipyardShellComponent( - client=self.mocked, # type: ignore - ship_id=self.box.id, - session_id=session_id, - ) - self._ship_fs = ShipyardFileSystemComponent( - client=self.mocked, # type: ignore - ship_id=self.box.id, - session_id=session_id, - ) - self._fs = ShipyardFileSystemWrapper( - _shipyard_fs=self._ship_fs, _shipyard_shell=self._shell - ) - - await self.mocked.wait_healthy(self.box.id, session_id) - - async def shutdown(self) -> None: - logger.info(f"Shutting down Boxlite booter for ship: {self.box.id}") - self.box.shutdown() - logger.info(f"Boxlite booter for ship: {self.box.id} stopped") - - @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: - """Upload file to sandbox""" - return await self.mocked.upload_file(path, file_name) diff --git a/astrbot/core/computer/booters/cua.py b/astrbot/core/computer/booters/cua.py deleted file mode 100644 index dd72a0aa8a..0000000000 --- a/astrbot/core/computer/booters/cua.py +++ /dev/null @@ -1,830 +0,0 @@ -from __future__ import annotations - -import base64 -import inspect -import shlex -from dataclasses import asdict, dataclass, is_dataclass -from pathlib import Path -from typing import Any - -from astrbot.api import logger - -from ..olayer import FileSystemComponent, GUIComponent, PythonComponent, ShellComponent -from .base import ComputerBooter -from .cua_defaults import CUA_CONFIG_KEYS, CUA_DEFAULT_CONFIG -from .shipyard_search_file_util import search_files_via_shell - -_POSIX_OS_TYPES = {"linux", "darwin", "macos"} - -_CUA_BACKGROUND_LAUNCHER = """ -import subprocess, sys, time - -p = subprocess.Popen( - ["sh", "-lc", sys.argv[1]], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, -) -sys.stdout.write(str(p.pid) + "\\n") -sys.stdout.flush() -time.sleep(0.2) -code = p.poll() -sys.exit(0 if code is None else code) -""".strip() - - -async def _maybe_await(value: Any) -> Any: - if inspect.isawaitable(value): - return await value - return value - - -def build_cua_booter_kwargs(sandbox_cfg: dict[str, Any]) -> dict[str, Any]: - return { - name: sandbox_cfg.get(config_key, CUA_DEFAULT_CONFIG[name]) - for name, config_key in CUA_CONFIG_KEYS.items() - } - - -async def _write_base64_via_shell( - shell: ShellComponent, - path: str, - data: bytes, -) -> dict[str, Any]: - encoded = base64.b64encode(data).decode("ascii") - decoder = ( - "import base64,pathlib,sys; " - "pathlib.Path(sys.argv[1]).write_bytes(base64.b64decode(sys.stdin.read()))" - ) - return await shell.exec( - f"python3 -c {shlex.quote(decoder)} {shlex.quote(path)} <<'EOF'\n{encoded}\nEOF" - ) - - -@dataclass(slots=True) -class ProcessResult: - stdout: str - stderr: str - exit_code: int | None - success: bool - - -def _maybe_model_dump(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return value - if is_dataclass(value) and not isinstance(value, type): - return asdict(value) - if hasattr(value, "model_dump"): - dumped = value.model_dump() - if isinstance(dumped, dict): - return dumped - if hasattr(value, "dict"): - dumped = value.dict() - if isinstance(dumped, dict): - return dumped - attr_payload = { - key: getattr(value, key) - for key in ( - "stdout", - "stderr", - "output", - "error", - "returncode", - "return_code", - "exit_code", - "success", - ) - if hasattr(value, key) - } - if attr_payload: - return attr_payload - return {} - - -def _slice_content_by_lines( - content: str, - *, - offset: int | None = None, - limit: int | None = None, -) -> str: - lines = content.splitlines(keepends=True) - start = 0 if offset is None else offset - selected = lines[start:] if limit is None else lines[start : start + limit] - return "".join(selected) - - -def _normalize_process_result(raw: Any) -> ProcessResult: - """Best-effort normalization for the process shapes returned by CUA SDKs.""" - payload = _maybe_model_dump(raw) - if not payload and isinstance(raw, str): - payload = {"stdout": raw} - - def first_text(*keys: str) -> str: - for key in keys: - value = payload.get(key) - if value is not None: - return str(value) - return "" - - stdout = first_text("stdout", "output") - stderr = first_text("stderr", "error") - exit_code = payload.get("exit_code") - if exit_code is None: - exit_code = payload.get("returncode") - if exit_code is None: - exit_code = payload.get("return_code") - if exit_code is None: - exit_code = 0 if not stderr else 1 - success = bool(payload.get("success", not stderr and exit_code in (0, None))) - return ProcessResult( - stdout=stdout, - stderr=stderr, - exit_code=exit_code, - success=success, - ) - - -def _is_missing_python3_error(stderr: str) -> bool: - lowered = stderr.lower() - return "python3" in lowered and ( - "not found" in lowered - or "command not found" in lowered - or "no such file" in lowered - ) - - -def _python3_requirement_error(operation: str, stderr: str) -> str: - return f"CUA {operation} requires python3 in the sandbox image: {stderr}" - - -def _normalize_with_python3_requirement(raw: Any, operation: str) -> ProcessResult: - proc = _normalize_process_result(raw) - if proc.stderr and _is_missing_python3_error(proc.stderr): - return ProcessResult( - stdout=proc.stdout, - stderr=_python3_requirement_error(operation, proc.stderr), - exit_code=proc.exit_code, - success=proc.success, - ) - return proc - - -async def _exec_python3_or_error( - shell: ShellComponent, - code: str, - *, - operation: str, - timeout: int | None = 30, -) -> ProcessResult: - result = await shell.exec(f"python3 - <<'PY'\n{code}\nPY", timeout=timeout) - return _normalize_with_python3_requirement(result, operation) - - -def _is_posix_os_type(os_type: str) -> bool: - return os_type.lower() in _POSIX_OS_TYPES - - -def _posix_fs_error_message(os_type: str) -> str: - return ( - "CUA filesystem shell fallback is only supported for POSIX images; " - f"os_type={os_type!r} does not support the required shell commands." - ) - - -def _non_posix_filesystem_result(path: str, os_type: str) -> dict[str, Any]: - error = _posix_fs_error_message(os_type) - return {"success": False, "path": path, "error": error, "message": error} - - -def _raise_non_posix_filesystem_error(os_type: str) -> None: - raise RuntimeError(_posix_fs_error_message(os_type)) - - -def _resolve_component_method( - component: Any, - method_names: str | tuple[str, ...], -) -> Any | None: - if component is None: - return None - names = (method_names,) if isinstance(method_names, str) else method_names - for method_name in names: - method = getattr(component, method_name, None) - if method is not None: - return method - return None - - -def _missing_component_method_error( - component_name: str, - method_names: str | tuple[str, ...], -) -> RuntimeError: - names = (method_names,) if isinstance(method_names, str) else method_names - candidates = ", ".join(f"{component_name}.{name}" for name in names) - return RuntimeError( - f"CUA sandbox does not provide any of: {candidates}. " - "Please check the installed CUA SDK version and sandbox backend." - ) - - -def _has_component_method(root: Any, component_name: str, method_name: str) -> bool: - component = getattr(root, component_name, None) - return getattr(component, method_name, None) is not None - - -class CuaShellComponent(ShellComponent): - def __init__(self, sandbox: Any, os_type: str = "linux") -> None: - self._sandbox = sandbox - self._os_type = os_type.lower() - shell = sandbox.shell - self._exec_raw = getattr(shell, "exec", None) or getattr(shell, "run", None) - if self._exec_raw is None: - raise RuntimeError("CUA sandbox shell must provide `.exec` or `.run`.") - - 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 CUA booter.", - "exit_code": 2, - "success": False, - } - - kwargs: dict[str, Any] = {} - if cwd is not None: - kwargs["cwd"] = cwd - if timeout is not None: - kwargs["timeout"] = timeout - if env: - kwargs["env"] = env - if background: - if not _is_posix_os_type(self._os_type): - return { - "stdout": "", - "stderr": "error: background shell execution is only supported for POSIX CUA images.", - "exit_code": 2, - "success": False, - } - command = _build_cua_background_command(command) - - result = await _maybe_await(self._exec_raw(command, **kwargs)) - proc = ( - _normalize_with_python3_requirement(result, "background execution") - if background - else _normalize_process_result(result) - ) - response = { - "stdout": proc.stdout, - "stderr": proc.stderr, - "exit_code": proc.exit_code, - "success": proc.success, - } - if background: - try: - response["pid"] = int(proc.stdout.strip().splitlines()[-1]) - except Exception: - response["pid"] = None - return response - - -def _build_cua_background_command(command: str) -> str: - return f"python3 -c {shlex.quote(_CUA_BACKGROUND_LAUNCHER)} {shlex.quote(command)}" - - -class CuaPythonComponent(PythonComponent): - def __init__(self, sandbox: Any, os_type: str = "linux") -> None: - self._sandbox = sandbox - self._os_type = os_type - python = getattr(sandbox, "python", None) - self._python_exec = None - if python is not None: - self._python_exec = getattr(python, "exec", None) or getattr( - python, "run", None - ) - - async def exec( - self, - code: str, - kernel_id: str | None = None, - timeout: int = 30, - silent: bool = False, - ) -> dict[str, Any]: - _ = kernel_id - if self._python_exec is not None: - result = await _maybe_await(self._python_exec(code, timeout=timeout)) - proc = _normalize_process_result(result) - else: - shell = CuaShellComponent(self._sandbox, os_type=self._os_type) - proc = await _exec_python3_or_error( - shell, - code, - operation="Python execution fallback", - timeout=timeout, - ) - - output_text = "" if silent else proc.stdout - error_text = proc.stderr - return { - "success": proc.success if not silent else not bool(error_text), - "data": { - "output": {"text": output_text, "images": []}, - "error": error_text, - }, - "output": output_text, - "error": error_text, - } - - -def _write_result(path: str, result: dict[str, Any]) -> dict[str, Any]: - stderr = result.get("stderr", "") - if stderr and _is_missing_python3_error(stderr): - result = { - **result, - "stderr": _python3_requirement_error("filesystem write fallback", stderr), - } - if result.get("stderr") or result.get("success") is False: - return {"success": False, "path": path, **result} - return {"success": True, "path": path, **result} - - -class CuaFileSystemComponent(FileSystemComponent): - def __init__( - self, sandbox: Any, os_type: str = CUA_DEFAULT_CONFIG["os_type"] - ) -> None: - self._shell = CuaShellComponent(sandbox, os_type=os_type) - self._fs = getattr(sandbox, "filesystem", None) - self._os_type = os_type.lower() - self._fallback = _PosixShellFileSystem(self._shell, self._os_type) - - async def create_file( - self, - path: str, - content: str = "", - mode: int = 0o644, - ) -> dict[str, Any]: - write_result = await self.write_file(path, content) - if not write_result.get("success"): - return {**write_result, "mode": mode, "mode_applied": False} - return {"success": True, "path": path, "mode": mode, "mode_applied": False} - - async def read_file( - self, - path: str, - encoding: str = "utf-8", - offset: int | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - read_file = None if self._fs is None else getattr(self._fs, "read_file", None) - if read_file is None: - return await self._fallback.read_file(path, encoding, offset, limit) - else: - content = await _maybe_await(read_file(path)) - if isinstance(content, bytes): - content = content.decode(encoding, errors="replace") - return { - "success": True, - "path": path, - "content": _slice_content_by_lines( - str(content), offset=offset, limit=limit - ), - } - - async def write_file( - self, - path: str, - content: str, - mode: str = "w", - encoding: str = "utf-8", - ) -> dict[str, Any]: - _ = mode - write_file = None if self._fs is None else getattr(self._fs, "write_file", None) - if write_file is None: - return await self._fallback.write_file(path, content, mode, encoding) - else: - await _maybe_await(write_file(path, content)) - return {"success": True, "path": path} - - async def delete_file(self, path: str) -> dict[str, Any]: - delete = None - if self._fs is not None: - delete = getattr(self._fs, "delete", None) or getattr( - self._fs, "delete_file", None - ) - if delete is None: - return await self._fallback.delete_file(path) - else: - await _maybe_await(delete(path)) - return {"success": True, "path": path} - - async def list_dir( - self, - path: str = ".", - show_hidden: bool = False, - ) -> dict[str, Any]: - list_dir = None if self._fs is None else getattr(self._fs, "list_dir", None) - if list_dir is not None: - entries = await _maybe_await(list_dir(path)) - return {"success": True, "path": path, "entries": entries} - return await self._fallback.list_dir(path, show_hidden) - - 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]: - return await self._fallback.search_files( - pattern=pattern, - path=path, - glob=glob, - after_context=after_context, - before_context=before_context, - ) - - async def edit_file( - self, - path: str, - old_string: str, - new_string: str, - replace_all: bool = False, - encoding: str = "utf-8", - ) -> dict[str, Any]: - read_result = await self.read_file(path, encoding=encoding) - if not read_result.get("success"): - return read_result - content = read_result.get("content", "") - occurrences = content.count(old_string) - if occurrences == 0: - return { - "success": False, - "error": "old string not found in file", - "replacements": 0, - } - updated = content.replace(old_string, new_string, -1 if replace_all else 1) - write_result = await self.write_file(path, updated, encoding=encoding) - if not write_result.get("success"): - return write_result - return { - "success": True, - "path": path, - "replacements": occurrences if replace_all else 1, - } - - -class _PosixShellFileSystem(FileSystemComponent): - def __init__(self, shell: CuaShellComponent, os_type: str) -> None: - self._shell = shell - self._os_type = os_type.lower() - - def _ensure_posix(self, path: str) -> dict[str, Any] | None: - if _is_posix_os_type(self._os_type): - return None - return _non_posix_filesystem_result(path, self._os_type) - - async def read_file( - self, - path: str, - encoding: str = "utf-8", - offset: int | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - _ = encoding - if error := self._ensure_posix(path): - return error - result = await self._shell.exec(f"cat {shlex.quote(path)}") - if result.get("stderr"): - return {"success": False, "path": path, "error": result["stderr"]} - return { - "success": True, - "path": path, - "content": _slice_content_by_lines( - str(result.get("stdout", "")), offset=offset, limit=limit - ), - } - - async def write_file( - self, - path: str, - content: str, - mode: str = "w", - encoding: str = "utf-8", - ) -> dict[str, Any]: - _ = mode - if error := self._ensure_posix(path): - return error - result = await _write_base64_via_shell( - self._shell, path, content.encode(encoding) - ) - return _write_result(path, result) - - async def delete_file(self, path: str) -> dict[str, Any]: - if error := self._ensure_posix(path): - return error - result = await self._shell.exec(f"rm -rf {shlex.quote(path)}") - if result.get("stderr"): - return {"success": False, "path": path, "error": result["stderr"]} - return {"success": True, "path": path} - - async def list_dir( - self, - path: str = ".", - show_hidden: bool = False, - ) -> dict[str, Any]: - if error := self._ensure_posix(path): - return error - return await _list_dir_via_shell(self._shell, path, show_hidden) - - 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_path = path or "." - if error := self._ensure_posix(search_path): - return error - return await search_files_via_shell( - self._shell, - pattern=pattern, - path=path, - glob=glob, - after_context=after_context, - before_context=before_context, - ) - - -async def _list_dir_via_shell( - shell: CuaShellComponent, - path: str, - show_hidden: bool, -) -> dict[str, Any]: - flags = "-1A" if show_hidden else "-1" - result = await shell.exec(f"ls {flags} {shlex.quote(path)}") - stdout = result.get("stdout", "") - return { - "success": not bool(result.get("stderr")), - "path": path, - "entries": [line for line in stdout.splitlines() if line.strip()], - "error": result.get("stderr", ""), - } - - -class CuaGUIComponent(GUIComponent): - def __init__(self, sandbox: Any) -> None: - self._sandbox = sandbox - mouse = getattr(sandbox, "mouse", None) - keyboard = getattr(sandbox, "keyboard", None) - self._click = _resolve_component_method(mouse, "click") - self._type_text = _resolve_component_method(keyboard, "type") - self._press_key = _resolve_component_method( - keyboard, ("press", "key_press", "press_key") - ) - - async def screenshot(self, path: str | None = None) -> dict[str, Any]: - raw = await self._sandbox.screenshot() - data = _screenshot_to_bytes(raw) - if path: - Path(path).parent.mkdir(parents=True, exist_ok=True) - Path(path).write_bytes(data) - return { - "success": True, - "path": path, - "mime_type": "image/png", - "base64": base64.b64encode(data).decode("ascii"), - } - - async def click(self, x: int, y: int, button: str = "left") -> dict[str, Any]: - if self._click is None: - raise _missing_component_method_error("mouse", "click") - result = await _maybe_await(self._click(x, y, button=button)) - payload = _maybe_model_dump(result) - return {"success": bool(payload.get("success", True)), **payload} - - async def type_text(self, text: str) -> dict[str, Any]: - if self._type_text is None: - raise _missing_component_method_error("keyboard", "type") - result = await _maybe_await(self._type_text(text)) - payload = _maybe_model_dump(result) - return {"success": bool(payload.get("success", True)), **payload} - - async def press_key(self, key: str) -> dict[str, Any]: - if self._press_key is None: - raise _missing_component_method_error( - "keyboard", ("press", "key_press", "press_key") - ) - result = await _maybe_await(self._press_key(key)) - payload = _maybe_model_dump(result) - return {"success": bool(payload.get("success", True)), **payload} - - -def _screenshot_to_bytes(raw: Any) -> bytes: - def from_str(value: str) -> bytes: - if value.startswith("data:image"): - value = value.split(",", 1)[1] - try: - return base64.b64decode(value, validate=True) - except Exception: - candidate = Path(value) - if candidate.is_file(): - return candidate.read_bytes() - return value.encode("utf-8") - - if isinstance(raw, (bytes, bytearray)): - return bytes(raw) - if isinstance(raw, str): - return from_str(raw) - if hasattr(raw, "save"): - import io - - output = io.BytesIO() - raw.save(output, format="PNG") - return output.getvalue() - payload = _maybe_model_dump(raw) - for key in ("data", "base64", "image"): - value = payload.get(key) - if value: - return _screenshot_to_bytes(value) - raise TypeError(f"Unsupported CUA screenshot result: {type(raw)!r}") - - -@dataclass(slots=True) -class _CuaRuntime: - sandbox_cm: Any - sandbox: Any - shell: CuaShellComponent - python: CuaPythonComponent - fs: CuaFileSystemComponent - gui: CuaGUIComponent | None - - -class CuaBooter(ComputerBooter): - def __init__( - self, - image: str = CUA_DEFAULT_CONFIG["image"], - os_type: str = CUA_DEFAULT_CONFIG["os_type"], - ttl: int = CUA_DEFAULT_CONFIG["ttl"], - telemetry_enabled: bool = CUA_DEFAULT_CONFIG["telemetry_enabled"], - local: bool = CUA_DEFAULT_CONFIG["local"], - api_key: str = CUA_DEFAULT_CONFIG["api_key"], - ) -> None: - self.image = image - self.os_type = os_type - self.ttl = ttl - self.telemetry_enabled = telemetry_enabled - self.local = local - self.api_key = api_key - self._runtime: _CuaRuntime | None = None - - async def boot(self, session_id: str) -> None: - _ = session_id - try: - from cua import Image, Sandbox - except ImportError as exc: - raise RuntimeError( - "CUA sandbox support requires the optional `cua` package. " - "Install it with `pip install cua` in the AstrBot environment." - ) from exc - - image_obj = self._build_image(Image) - ephemeral_kwargs = self._build_ephemeral_kwargs(Sandbox.ephemeral) - sandbox_cm = Sandbox.ephemeral(image_obj, **ephemeral_kwargs) - sandbox = await sandbox_cm.__aenter__() - try: - self._runtime = _CuaRuntime( - sandbox_cm=sandbox_cm, - sandbox=sandbox, - shell=CuaShellComponent(sandbox, os_type=self.os_type), - python=CuaPythonComponent(sandbox, os_type=self.os_type), - fs=CuaFileSystemComponent(sandbox, os_type=self.os_type), - gui=CuaGUIComponent(sandbox), - ) - except Exception: - await sandbox_cm.__aexit__(None, None, None) - self._runtime = None - raise - logger.info( - "[Computer] CUA sandbox booted: image=%s, os_type=%s", - self.image, - self.os_type, - ) - - def _build_image(self, image_cls: Any) -> Any: - image_name = (self.image or self.os_type or "linux").strip().lower() - factory = getattr(image_cls, image_name, None) - if callable(factory): - return factory() - os_factory = getattr(image_cls, (self.os_type or "linux").strip().lower(), None) - if callable(os_factory): - return os_factory() - return image_name - - def _build_ephemeral_kwargs(self, ephemeral: Any) -> dict[str, Any]: - try: - parameters = inspect.signature(ephemeral).parameters - except (TypeError, ValueError): - return {} - kwargs: dict[str, Any] = {} - if "ttl" in parameters: - kwargs["ttl"] = self.ttl - if "telemetry_enabled" in parameters: - kwargs["telemetry_enabled"] = self.telemetry_enabled - if "local" in parameters: - kwargs["local"] = self.local - if "api_key" in parameters and self.api_key: - kwargs["api_key"] = self.api_key - return kwargs - - async def shutdown(self) -> None: - if self._runtime is not None: - await self._runtime.sandbox_cm.__aexit__(None, None, None) - self._runtime = None - - @property - def capabilities(self) -> tuple[str, ...] | None: - capabilities = ["python", "shell", "filesystem"] - if self._runtime is None: - return tuple(capabilities) - - sandbox = self._runtime.sandbox - has_screenshot = getattr(sandbox, "screenshot", None) is not None - has_mouse = _has_component_method(sandbox, "mouse", "click") - has_keyboard = _has_component_method(sandbox, "keyboard", "type") - if has_screenshot or has_mouse or has_keyboard: - capabilities.append("gui") - if has_screenshot: - capabilities.append("screenshot") - if has_mouse: - capabilities.append("mouse") - if has_keyboard: - capabilities.append("keyboard") - return tuple(capabilities) - - @property - def fs(self) -> FileSystemComponent: - if self._runtime is None: - raise RuntimeError("CuaBooter is not initialized.") - return self._runtime.fs - - @property - def python(self) -> PythonComponent: - if self._runtime is None: - raise RuntimeError("CuaBooter is not initialized.") - return self._runtime.python - - @property - def shell(self) -> ShellComponent: - if self._runtime is None: - raise RuntimeError("CuaBooter is not initialized.") - return self._runtime.shell - - @property - def gui(self) -> GUIComponent | None: - return None if self._runtime is None else self._runtime.gui - - async def upload_file(self, path: str, file_name: str) -> dict: - local_path = Path(path) - if not local_path.is_file(): - return {"success": False, "error": f"File not found: {path}"} - sandbox = None if self._runtime is None else self._runtime.sandbox - if sandbox is not None and hasattr(sandbox, "upload_file"): - return _maybe_model_dump( - await sandbox.upload_file(str(local_path), file_name) - ) - if not _is_posix_os_type(self.os_type): - return _non_posix_filesystem_result(file_name, self.os_type) - result = await _write_base64_via_shell( - self.shell, file_name, local_path.read_bytes() - ) - return { - "success": not bool(result.get("stderr")), - "file_path": file_name, - **result, - } - - async def download_file(self, remote_path: str, local_path: str) -> None: - sandbox = None if self._runtime is None else self._runtime.sandbox - if sandbox is not None and hasattr(sandbox, "download_file"): - await sandbox.download_file(remote_path, local_path) - return - if not _is_posix_os_type(self.os_type): - _raise_non_posix_filesystem_error(self.os_type) - result = await self.shell.exec(f"base64 {shlex.quote(remote_path)}") - if result.get("stderr"): - raise RuntimeError(result["stderr"]) - Path(local_path).parent.mkdir(parents=True, exist_ok=True) - Path(local_path).write_bytes(base64.b64decode(result.get("stdout", ""))) - - async def available(self) -> bool: - return self._runtime is not None diff --git a/astrbot/core/computer/booters/cua_defaults.py b/astrbot/core/computer/booters/cua_defaults.py deleted file mode 100644 index 4c506154ad..0000000000 --- a/astrbot/core/computer/booters/cua_defaults.py +++ /dev/null @@ -1,17 +0,0 @@ -CUA_DEFAULT_CONFIG = { - "image": "linux", - "os_type": "linux", - "ttl": 3600, - "telemetry_enabled": False, - "local": True, - "api_key": "", -} - -CUA_CONFIG_KEYS = { - "image": "cua_image", - "os_type": "cua_os_type", - "ttl": "cua_ttl", - "telemetry_enabled": "cua_telemetry_enabled", - "local": "cua_local", - "api_key": "cua_api_key", -} diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py index 1fb7b5cf7a..8ac4dd6a4d 100644 --- a/astrbot/core/computer/booters/local.py +++ b/astrbot/core/computer/booters/local.py @@ -20,7 +20,8 @@ from ..olayer import FileSystemComponent, PythonComponent, ShellComponent from .base import ComputerBooter -from .shipyard_search_file_util import _truncate_long_lines + +_MAX_SEARCH_LINE_COLUMNS = 1000 _BLOCKED_COMMAND_PATTERNS = [ " rm -rf ", @@ -83,6 +84,23 @@ def _decode_shell_output(output: bytes | None) -> str: return _decode_bytes_with_fallback(output, preferred_encoding="utf-8") +def _truncate_long_lines(text: str) -> str: + output_lines: list[str] = [] + for line in text.splitlines(keepends=True): + line_ending = "" + line_body = line + if line.endswith("\r\n"): + line_body = line[:-2] + line_ending = "\r\n" + elif line.endswith("\n") or line.endswith("\r"): + line_body = line[:-1] + line_ending = line[-1] + if len(line_body) > _MAX_SEARCH_LINE_COLUMNS: + line_body = line_body[:_MAX_SEARCH_LINE_COLUMNS] + output_lines.append(f"{line_body}{line_ending}") + return "".join(output_lines) + + @dataclass class LocalShellComponent(ShellComponent): async def exec( diff --git a/astrbot/core/computer/booters/shell_background.py b/astrbot/core/computer/booters/shell_background.py deleted file mode 100644 index 6fe94c133a..0000000000 --- a/astrbot/core/computer/booters/shell_background.py +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index a8375544da..0000000000 --- a/astrbot/core/computer/booters/shipyard.py +++ /dev/null @@ -1,249 +0,0 @@ -from __future__ import annotations - -import shlex -from typing import Any - -from shipyard import FileSystemComponent as ShipyardFileSystemComponent -from shipyard import ShipyardClient, Spec - -from astrbot.api import logger - -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 - ): - self._fs = _shipyard_fs - self._shell = _shipyard_shell - - async def create_file( - self, path: str, content: str = "", mode: int = 420 - ) -> dict[str, Any]: - return await self._fs.create_file(path=path, content=content, mode=mode) - - async def read_file( - self, - path: str, - encoding: str = "utf-8", - offset: int | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - return await self._fs.read_file( - path=path, encoding=encoding, offset=offset, limit=limit - ) - - async def write_file( - self, path: str, content: str, mode: str = "w", encoding: str = "utf-8" - ) -> dict[str, Any]: - return await self._fs.write_file( - path=path, content=content, mode=mode, encoding=encoding - ) - - async def list_dir( - self, path: str = ".", show_hidden: bool = False - ) -> dict[str, Any]: - return await self._fs.list_dir(path=path, show_hidden=show_hidden) - - async def delete_file(self, path: str) -> dict[str, Any]: - return await self._fs.delete_file(path=path) - - 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]: - return await search_files_via_shell( - self._shell, - pattern=pattern, - path=path, - glob=glob, - after_context=after_context, - before_context=before_context, - ) - - async def edit_file( - self, - path: str, - old_string: str, - new_string: str, - replace_all: bool = False, - encoding: str = "utf-8", - ) -> dict[str, Any]: - return await self._fs.edit_file( - path=path, - old_string=old_string, - new_string=new_string, - replace_all=replace_all, - encoding=encoding, - ) - - -class ShipyardBooter(ComputerBooter): - def __init__( - self, - endpoint_url: str, - access_token: str, - ttl: int = 3600, - session_num: int = 10, - ) -> None: - self._sandbox_client = ShipyardClient( - endpoint_url=endpoint_url, access_token=access_token - ) - self._ttl = ttl - self._session_num = session_num - - async def boot(self, session_id: str) -> None: - ship = await self._sandbox_client.create_ship( - ttl=self._ttl, - spec=Spec(cpus=1.0, memory="512m"), - max_session_num=self._session_num, - session_id=session_id, - ) - logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}") - self._ship = ship - 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.") - - @property - def fs(self) -> FileSystemComponent: - return self._fs - - @property - def python(self) -> PythonComponent: - return self._ship.python - - @property - def shell(self) -> ShellComponent: - return self._shell - - async def upload_file(self, path: str, file_name: str) -> dict: - """Upload file to sandbox""" - result = await self._ship.upload_file(path, file_name) - logger.info("[Computer] File uploaded to Shipyard sandbox: %s", file_name) - return result - - async def download_file(self, remote_path: str, local_path: str): - """Download file from sandbox.""" - result = await self._ship.download_file(remote_path, local_path) - logger.info( - "[Computer] File downloaded from Shipyard sandbox: %s -> %s", - remote_path, - local_path, - ) - return result - - async def available(self) -> bool: - """Check if the sandbox is available.""" - try: - ship_id = self._ship.id - data = await self._sandbox_client.get_ship(ship_id) - if not data: - logger.info( - "[Computer] Shipyard sandbox health check: id=%s, healthy=False (no data)", - ship_id, - ) - return False - health = bool(data.get("status", 0) == 1) - logger.info( - "[Computer] Shipyard sandbox health check: id=%s, healthy=%s", - ship_id, - health, - ) - return health - except Exception as e: - logger.error(f"Error checking Shipyard sandbox availability: {e}") - return False diff --git a/astrbot/core/computer/booters/shipyard_neo.py b/astrbot/core/computer/booters/shipyard_neo.py deleted file mode 100644 index a1a8ad55a3..0000000000 --- a/astrbot/core/computer/booters/shipyard_neo.py +++ /dev/null @@ -1,700 +0,0 @@ -from __future__ import annotations - -import asyncio -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 -from .shell_background import build_detached_shell_command -from .shipyard_search_file_util import search_files_via_shell - -try: - from shipyard_neo import BayClient - from shipyard_neo.sandbox import Sandbox -except ImportError: - logger.warning( - "shipyard_neo_sdk is not installed. ShipyardNeoBooter will not work without it." - ) - - -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 {} - - -def _slice_content_by_lines( - content: str, - *, - offset: int | None = None, - limit: int | None = None, -) -> str: - lines = content.splitlines(keepends=True) - start = 0 if offset is None else offset - selected = lines[start:] if limit is None else lines[start : start + limit] - return "".join(selected) - - -class NeoPythonComponent(PythonComponent): - def __init__(self, sandbox: Sandbox) -> 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: Sandbox) -> None: - self._sandbox = sandbox - - 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_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 = build_detached_shell_command(run_command) - - result = await self._sandbox.shell.exec( - run_command, - timeout=timeout or 300, - 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": ( - 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 NeoFileSystemComponent(FileSystemComponent): - def __init__(self, sandbox: Sandbox, shell: ShellComponent) -> None: - self._sandbox = sandbox - self._shell = shell - - 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", - offset: int | None = None, - limit: int | None = None, - ) -> dict[str, Any]: - _ = encoding - content = await self._sandbox.filesystem.read_file(path) - return { - "success": True, - "path": path, - "content": _slice_content_by_lines( - content, - offset=offset, - limit=limit, - ), - } - - 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]: - return await search_files_via_shell( - self._shell, - pattern=pattern, - path=path, - glob=glob, - after_context=after_context, - before_context=before_context, - ) - - async def edit_file( - self, - path: str, - old_string: str, - new_string: str, - replace_all: bool = False, - encoding: str = "utf-8", - ) -> dict[str, Any]: - _ = encoding - content = await self._sandbox.filesystem.read_file(path) - occurrences = content.count(old_string) - if occurrences == 0: - return { - "success": False, - "error": "old string not found in file", - "replacements": 0, - } - if replace_all: - updated = content.replace(old_string, new_string) - replacements = occurrences - else: - updated = content.replace(old_string, new_string, 1) - replacements = 1 - await self._sandbox.filesystem.write_file(path, updated) - return { - "success": True, - "path": path, - "replacements": replacements, - } - - 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: Sandbox) -> 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): - """Booter backed by Shipyard Neo (Bay). - - If *endpoint_url* is empty or set to ``"__auto__"``, Bay will be - started automatically as a Docker container (like Boxlite does for - Ship containers). - """ - - AUTO_SENTINEL = "__auto__" - DEFAULT_PROFILE = "python-default" - - def __init__( - self, - endpoint_url: str, - access_token: str, - profile: str = DEFAULT_PROFILE, - ttl: int = 3600, - ) -> None: - self._endpoint_url = endpoint_url - self._access_token = access_token - self._profile = profile - self._ttl = ttl - self._client: BayClient | None = None - self._sandbox: Sandbox | None = None - self._bay_manager: Any = None # BayContainerManager when auto-started - 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 - - @property - def capabilities(self) -> tuple[str, ...] | None: - """Sandbox capabilities from the Bay profile. - - Returns an immutable tuple after :meth:`boot`; ``None`` before boot. - """ - if self._sandbox is None: - return None - caps = getattr(self._sandbox, "capabilities", None) - return tuple(caps) if caps is not None else None - - @property - def is_auto_mode(self) -> bool: - """True when Bay should be auto-started.""" - ep = (self._endpoint_url or "").strip() - return not ep or ep == self.AUTO_SENTINEL - - async def boot(self, session_id: str) -> None: - _ = session_id - - # --- Auto-start Bay if needed --- - if self.is_auto_mode: - from .bay_manager import BayContainerManager - - # Clean up previous manager if re-booting - if self._bay_manager is not None: - await self._bay_manager.close_client() - - logger.info("[Computer] Neo auto-start mode: launching Bay container") - self._bay_manager = BayContainerManager() - self._endpoint_url = await self._bay_manager.ensure_running() - await self._bay_manager.wait_healthy() - # Read auto-provisioned credentials - if not self._access_token: - self._access_token = await self._bay_manager.read_credentials() - logger.info("[Computer] Bay auto-started at %s", self._endpoint_url) - - if not self._endpoint_url or not self._access_token: - if self._bay_manager is not None: - raise ValueError( - "Bay container started but credentials could not be read. " - "Ensure Bay generated credentials.json, or set access_token manually." - ) - raise ValueError( - "Shipyard Neo sandbox configuration is incomplete. " - "Set endpoint (default http://127.0.0.1:8114) and access token, " - "or ensure Bay's credentials.json is accessible for auto-discovery." - ) - - self._client = BayClient( - endpoint_url=self._endpoint_url, - access_token=self._access_token, - ) - await self._client.__aenter__() - - # Resolve profile: user-specified > smart selection > default - resolved_profile = await self._resolve_profile(self._client) - - self._sandbox = await self._client.create_sandbox( - profile=resolved_profile, - ttl=self._ttl, - ) - - # --- Readiness gate: wait until sandbox session is READY --- - await self._wait_until_ready(self._sandbox) - - self._shell = NeoShellComponent(self._sandbox) - self._fs = NeoFileSystemComponent(self._sandbox, self._shell) - self._python = NeoPythonComponent(self._sandbox) - - caps = self.capabilities or () - self._browser = ( - NeoBrowserComponent(self._sandbox) if "browser" in caps else None - ) - - logger.info( - "Got Shipyard Neo sandbox: %s (profile=%s, capabilities=%s, auto=%s)", - self._sandbox.id, - resolved_profile, - list(caps), - bool(self._bay_manager), - ) - - async def _wait_until_ready(self, sandbox: Sandbox) -> None: - """Poll sandbox status until READY, or raise on FAILED / timeout. - - Covers both warm-pool hits (near-instant) and cold starts (up to 180s). - On FAILED, EXPIRED, or timeout the sandbox is deleted before raising - so no orphan resources leak on Bay. - """ - READINESS_TIMEOUT = 180 # seconds - POLL_INTERVAL = 2 # seconds - - sandbox_id = sandbox.id - deadline = asyncio.get_running_loop().time() + READINESS_TIMEOUT - - while True: - await sandbox.refresh() - status = getattr(sandbox.status, "value", str(sandbox.status)) - - if status == "ready": - logger.info( - "[Computer] Sandbox %s is ready (profile=%s)", - sandbox_id, - sandbox.profile, - ) - return - - if status in {"failed", "expired"}: - logger.error( - "[Computer] Sandbox %s reached terminal state: %s", - sandbox_id, - status, - ) - try: - await sandbox.delete() - except Exception as del_err: - logger.warning( - "[Computer] Failed to delete failed sandbox %s: %s", - sandbox_id, - del_err, - ) - raise RuntimeError( - f"Sandbox {sandbox_id} is in terminal state: {status}" - ) - - remaining = deadline - asyncio.get_running_loop().time() - if remaining <= 0: - logger.error( - "[Computer] Sandbox %s did not become ready within %ds " - "(last status: %s)", - sandbox_id, - READINESS_TIMEOUT, - status, - ) - try: - await sandbox.delete() - except Exception as del_err: - logger.warning( - "[Computer] Failed to delete timed-out sandbox %s: %s", - sandbox_id, - del_err, - ) - raise TimeoutError( - f"Sandbox {sandbox_id} did not become ready within " - f"{READINESS_TIMEOUT}s (last status: {status})" - ) - - logger.debug( - "[Computer] Sandbox %s status=%s, waiting...", - sandbox_id, - status, - ) - await asyncio.sleep(POLL_INTERVAL) - - async def _resolve_profile(self, client: Any) -> str: - """Pick the best profile for this session. - - Resolution order: - 1. User-specified profile (non-empty, non-default) → use as-is. - 2. Query ``GET /v1/profiles`` and pick the profile with the most - capabilities, preferring profiles that include ``"browser"``. - 3. Fall back to :attr:`DEFAULT_PROFILE`. - - Auth errors (401/403) are re-raised immediately — they indicate a - misconfigured token, and silently falling back would just delay the - real failure to ``create_sandbox``. - """ - # User explicitly set a profile → honour it - if self._profile and self._profile != self.DEFAULT_PROFILE: - logger.info("[Computer] Using user-specified profile: %s", self._profile) - return self._profile - - # Query Bay for available profiles - from shipyard_neo.errors import ForbiddenError, UnauthorizedError - - try: - profile_list = await client.list_profiles() - profiles = profile_list.items - except (UnauthorizedError, ForbiddenError): - raise # auth errors must not be silenced - except Exception as exc: - logger.warning( - "[Computer] Failed to query Bay profiles, falling back to %s: %s", - self.DEFAULT_PROFILE, - exc, - ) - return self.DEFAULT_PROFILE - - if not profiles: - return self.DEFAULT_PROFILE - - def _score(p: Any) -> tuple[int, int]: - """(has_browser, capability_count) — higher is better.""" - caps = getattr(p, "capabilities", []) or [] - return (1 if "browser" in caps else 0, len(caps)) - - best = max(profiles, key=_score) - chosen = getattr(best, "id", self.DEFAULT_PROFILE) - - if chosen != self.DEFAULT_PROFILE: - caps = getattr(best, "capabilities", []) - logger.info( - "[Computer] Auto-selected profile %s (capabilities=%s)", - chosen, - caps, - ) - - return chosen - - async def shutdown(self, *, delete_sandbox: bool = False) -> None: - if self._client is not None: - sandbox_id = getattr(self._sandbox, "id", "unknown") - - # Delete sandbox on Bay BEFORE closing the HTTP client. - # This is critical for cleanup — calling delete after - # __aexit__ would fail because the httpx session is already - # torn down. - if delete_sandbox and self._sandbox is not None: - try: - logger.info( - "[Computer] Deleting Shipyard Neo sandbox: id=%s", sandbox_id - ) - await self._sandbox.delete() - logger.info( - "[Computer] Shipyard Neo sandbox deleted: id=%s", sandbox_id - ) - except Exception as e: - logger.warning( - "[Computer] Failed to delete sandbox %s (may already be " - "cleaned up by Bay GC): %s", - sandbox_id, - e, - ) - - logger.info( - "[Computer] Shutting down Shipyard Neo sandbox client: id=%s", - sandbox_id, - ) - await self._client.__aexit__(None, None, None) - self._client = None - self._sandbox = None - logger.info( - "[Computer] Shipyard Neo sandbox client shut down: id=%s", sandbox_id - ) - - # NOTE: We intentionally do NOT stop the Bay container here. - # It stays running for reuse by future sessions. The user can - # stop it manually or via ``BayContainerManager.stop()``. - if self._bay_manager is not None: - await self._bay_manager.close_client() - - @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) - logger.info("[Computer] File uploaded to Neo sandbox: %s", remote_path) - 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)) - logger.info( - "[Computer] File downloaded from Neo sandbox: %s -> %s", - remote_path, - local_path, - ) - - 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)) - healthy = status not in {"failed", "expired"} - logger.info( - "[Computer] Neo sandbox health check: id=%s, status=%s, healthy=%s", - getattr(self._sandbox, "id", "unknown"), - status, - healthy, - ) - return healthy - except Exception as e: - logger.error(f"Error checking Shipyard Neo sandbox availability: {e}") - return False diff --git a/astrbot/core/computer/booters/shipyard_search_file_util.py b/astrbot/core/computer/booters/shipyard_search_file_util.py deleted file mode 100644 index 1227244de3..0000000000 --- a/astrbot/core/computer/booters/shipyard_search_file_util.py +++ /dev/null @@ -1,148 +0,0 @@ -from __future__ import annotations - -import shlex -from typing import Any - -from ..olayer import ShellComponent - -_MAX_SEARCH_LINE_COLUMNS = 1000 - - -def _truncate_long_lines(text: str) -> str: - output_lines: list[str] = [] - for line in text.splitlines(keepends=True): - line_ending = "" - line_body = line - if line.endswith("\r\n"): - line_body = line[:-2] - line_ending = "\r\n" - elif line.endswith("\n") or line.endswith("\r"): - line_body = line[:-1] - line_ending = line[-1] - - if len(line_body) > _MAX_SEARCH_LINE_COLUMNS: - line_body = line_body[:_MAX_SEARCH_LINE_COLUMNS] - - output_lines.append(f"{line_body}{line_ending}") - return "".join(output_lines) - - -def _build_rg_command( - *, - pattern: str, - path: str, - glob: str | None, - after_context: int | None, - before_context: int | None, -) -> list[str]: - command = [ - "rg", - "--color=never", - "-n", - "--max-columns", - str(_MAX_SEARCH_LINE_COLUMNS), - "-e", - pattern, - ] - if glob: - command.extend(["-g", glob]) - if after_context is not None: - command.extend(["-A", str(after_context)]) - if before_context is not None: - command.extend(["-B", str(before_context)]) - command.extend(["--", path]) - return command - - -def _build_grep_command( - *, - pattern: str, - path: str, - glob: str | None, - after_context: int | None, - before_context: int | None, -) -> list[str]: - command = ["grep", "-R", "-H", "-n", "-e", pattern] - if glob: - command.append(f"--include={glob}") - if after_context is not None: - command.extend(["-A", str(after_context)]) - if before_context is not None: - command.extend(["-B", str(before_context)]) - command.extend(["--", path]) - return command - - -def _quote_command(command: list[str]) -> str: - return " ".join(shlex.quote(part) for part in command) - - -def build_search_command( - *, - pattern: str, - path: str, - glob: str | None, - after_context: int | None, - before_context: int | None, -) -> str: - rg_command = _quote_command( - _build_rg_command( - pattern=pattern, - path=path, - glob=glob, - after_context=after_context, - before_context=before_context, - ) - ) - grep_command = _quote_command( - _build_grep_command( - pattern=pattern, - path=path, - glob=glob, - after_context=after_context, - before_context=before_context, - ) - ) - return ( - "if command -v rg >/dev/null 2>&1; then " - f"{rg_command}; " - "elif command -v grep >/dev/null 2>&1; then " - f"{grep_command}; " - "else " - "echo 'Neither rg nor grep is available in the sandbox.' >&2; " - "exit 127; " - "fi" - ) - - -async def search_files_via_shell( - shell: ShellComponent, - *, - pattern: str, - path: str | None = None, - glob: str | None = None, - after_context: int | None = None, - before_context: int | None = None, - timeout: int = 30, -) -> dict[str, Any]: - command = build_search_command( - pattern=pattern, - path=path or ".", - glob=glob, - after_context=after_context, - before_context=before_context, - ) - result = await shell.exec(command, timeout=timeout) - stdout = _truncate_long_lines(str(result.get("stdout", "") or "")) - stderr = str(result.get("stderr", "") or "") - exit_code = result.get("exit_code") - if exit_code in (0, None): - return {"success": True, "content": stdout} - if exit_code == 1: - return {"success": True, "content": ""} - return { - "success": False, - "content": "", - "error": stderr or f"command exited with code {exit_code}", - "exit_code": exit_code, - } diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index a50549fba7..d10dba0897 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -1,5 +1,4 @@ import json -import os import shutil import uuid from pathlib import Path @@ -14,12 +13,74 @@ from .booters.base import ComputerBooter from .booters.local import LocalBooter +from .sandbox_manager import SandboxManager +from .sandbox_provider import SandboxProvider +from .sandbox_registry import SandboxRegistry session_booter: dict[str, ComputerBooter] = {} local_booter: ComputerBooter | None = None +sandbox_registry = SandboxRegistry() +sandbox_manager = SandboxManager(registry=sandbox_registry, providers={}) _MANAGED_SKILLS_FILE = ".astrbot_managed_skills.json" +def _sandbox_provider_info(provider_id: str, provider: SandboxProvider) -> dict: + return { + "provider_id": provider_id, + "capabilities": sorted(getattr(provider, "capabilities", set())), + "tool_names": sorted(getattr(provider, "tool_names", set())), + } + + +def _has_managed_sandboxes_for_provider(provider_id: str) -> bool: + return any( + record.get("managed") and record.get("provider") == provider_id + for record in sandbox_manager.registry.list_sandboxes() + ) + + +def register_sandbox_provider( + provider: SandboxProvider, + *, + replace: bool = False, +) -> None: + """Register a plugin-provided sandbox runtime.""" + if not provider.provider_id: + raise ValueError("Sandbox provider_id must be a non-empty string.") + if provider.provider_id in sandbox_manager.providers and not replace: + raise RuntimeError( + f"Sandbox provider {provider.provider_id} is already registered" + ) + sandbox_manager.providers[provider.provider_id] = provider + + +def unregister_sandbox_provider(provider_id: str, *, force: bool = False) -> None: + if not force and _has_managed_sandboxes_for_provider(provider_id): + raise RuntimeError( + f"Sandbox provider {provider_id} has active managed sandboxes; " + "destroy them or pass force=True before unregistering." + ) + sandbox_manager.providers.pop(provider_id, None) + + +def get_sandbox_provider_info(provider_id: str) -> dict | None: + provider = sandbox_manager.providers.get(provider_id) + if provider is None: + return None + return _sandbox_provider_info(provider_id, provider) + + +def list_sandbox_providers() -> list[dict]: + return [ + _sandbox_provider_info(provider_id, provider) + for provider_id, provider in sorted(sandbox_manager.providers.items()) + ] + + +async def cleanup_managed_sandboxes() -> None: + await sandbox_manager.cleanup_managed_sandboxes() + + def _list_local_skill_dirs(skills_root: Path) -> list[Path]: skills: list[Path] = [] for entry in sorted(skills_root.iterdir()): @@ -64,65 +125,6 @@ def _normalize_shell_exec_result(result: object) -> dict: return {"exit_code": 0, "stdout": "", "stderr": ""} -def _discover_bay_credentials(endpoint: str) -> str: - """Try to auto-discover Bay API key from credentials.json. - - Search order: - 1. BAY_DATA_DIR env var - 2. Mono-repo relative path: ../pkgs/bay/ (dev layout) - 3. Current working directory - - Returns: - API key string, or empty string if not found. - """ - candidates: list[Path] = [] - - # 1. BAY_DATA_DIR env var - bay_data_dir = os.environ.get("BAY_DATA_DIR") - if bay_data_dir: - candidates.append(Path(bay_data_dir) / "credentials.json") - - # 2. Mono-repo layout: AstrBot/../pkgs/bay/credentials.json - astrbot_root = Path(__file__).resolve().parents[3] # astrbot/core/computer/ → root - candidates.append(astrbot_root.parent / "pkgs" / "bay" / "credentials.json") - - # 3. Current working directory - candidates.append(Path.cwd() / "credentials.json") - - for cred_path in candidates: - if not cred_path.is_file(): - continue - try: - data = json.loads(cred_path.read_text()) - api_key = data.get("api_key", "") - if api_key: - # Optionally verify endpoint matches - cred_endpoint = data.get("endpoint", "") - if ( - cred_endpoint - and endpoint - and cred_endpoint.rstrip("/") != endpoint.rstrip("/") - ): - logger.warning( - "[Computer] credentials.json endpoint mismatch: " - "file=%s, configured=%s — using key anyway", - cred_endpoint, - endpoint, - ) - masked_key = f"{api_key[:4]}..." if len(api_key) >= 6 else "redacted" - logger.info( - "[Computer] Auto-discovered Bay API key from %s (prefix=%s)", - cred_path, - masked_key, - ) - return api_key - except (json.JSONDecodeError, OSError) as exc: - logger.debug("[Computer] Failed to read %s: %s", cred_path, exc) - - logger.debug("[Computer] No Bay credentials.json found in search paths") - return "" - - def _build_python_exec_command(script: str) -> str: return ( "if command -v python3 >/dev/null 2>&1; then PYBIN=python3; " @@ -485,21 +487,13 @@ async def get_booter( raise RuntimeError("Sandbox runtime is disabled by configuration.") sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {}) - booter_type = sandbox_cfg.get("booter", "shipyard_neo") + booter_type = sandbox_cfg.get("booter", "") if session_id in session_booter: booter = session_booter[session_id] if not await booter.available(): - # Clean up old booter before rebuilding so sandbox resources - # on Bay (containers, volumes, networks) are not leaked. - # Only ShipyardNeoBooter supports delete_sandbox; other booters - # (local, boxlite, cua, etc.) are not backed by a remote sandbox - # manager and don't need it. try: - if booter_type == "shipyard_neo": - await booter.shutdown(delete_sandbox=True) - else: - await booter.shutdown() + await booter.shutdown() except Exception as shutdown_err: logger.warning( "[Computer] Error shutting down stale booter for session %s: %s", @@ -508,80 +502,16 @@ async def get_booter( ) session_booter.pop(session_id, None) if session_id not in session_booter: - uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex logger.info( f"[Computer] Initializing booter: type={booter_type}, session={session_id}" ) - if booter_type == "shipyard": - from .booters.shipyard import ShipyardBooter - - ep = sandbox_cfg.get("shipyard_endpoint", "") - token = sandbox_cfg.get("shipyard_access_token", "") - ttl = sandbox_cfg.get("shipyard_ttl", 3600) - max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10) - - 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") - - # Auto-discover token from Bay's credentials.json if not configured - if not token: - token = _discover_bay_credentials(ep) - - logger.info( - f"[Computer] Shipyard Neo config: endpoint={ep}, profile={profile}, ttl={ttl}" - ) - client = ShipyardNeoBooter( - endpoint_url=ep, - access_token=token, - profile=profile, - ttl=ttl, - ) - elif booter_type == "cua": - from .booters.cua import CuaBooter, build_cua_booter_kwargs - - cua_kwargs = build_cua_booter_kwargs(sandbox_cfg) - logger.info( - f"[Computer] CUA config: image={cua_kwargs['image']}, " - f"os_type={cua_kwargs['os_type']}, ttl={cua_kwargs['ttl']}" - ) - client = CuaBooter(**cua_kwargs) - elif booter_type == "boxlite": - from .booters.boxlite import BoxliteBooter - - client = BoxliteBooter() - else: - raise ValueError(f"Unknown booter type: {booter_type}") - - try: - await client.boot(uuid_str) - logger.info( - f"[Computer] Sandbox booted successfully: type={booter_type}, session={session_id}" + if booter_type in sandbox_manager.providers: + return await sandbox_manager.get_or_create_booter( + context, + session_id, + booter_type, ) - await _sync_skills_to_sandbox(client) - except Exception as e: - logger.error(f"Error booting sandbox for session {session_id}: {e}") - try: - if booter_type == "shipyard_neo": - await client.shutdown(delete_sandbox=True) - else: - await client.shutdown() - except Exception as shutdown_error: - logger.warning( - "Failed to shutdown sandbox after boot error for session %s: %s", - session_id, - shutdown_error, - ) - raise e - - session_booter[session_id] = client + raise ValueError(f"Unknown booter type: {booter_type}") return session_booter[session_id] diff --git a/astrbot/core/computer/sandbox_manager.py b/astrbot/core/computer/sandbox_manager.py new file mode 100644 index 0000000000..350b3b30f0 --- /dev/null +++ b/astrbot/core/computer/sandbox_manager.py @@ -0,0 +1,629 @@ +from __future__ import annotations + +import asyncio +import time +import uuid +from dataclasses import dataclass + +from astrbot.api import logger +from astrbot.core.computer.booters.base import ComputerBooter +from astrbot.core.computer.sandbox_provider import SandboxProvider +from astrbot.core.computer.sandbox_registry import SandboxRegistry +from astrbot.core.star.context import Context + +SANDBOX_LEASE_SECONDS = 300 + + +@dataclass(slots=True) +class SandboxIdleState: + expires_at: float + task: asyncio.Task + + +class SandboxManager: + def __init__( + self, + *, + registry: SandboxRegistry, + providers: dict[str, SandboxProvider], + ) -> None: + self.registry = registry + self.providers = providers + self.session_booter: dict[str, ComputerBooter] = {} + self.idle_state: dict[str, SandboxIdleState] = {} + self.boot_locks: dict[str, asyncio.Lock] = {} + + def save_registry(self) -> None: + try: + self.registry.save() + except Exception as exc: + logger.warning("[Computer] Failed to save sandbox registry: %s", exc) + + def _sandbox_boot_lock(self, sandbox_id: str) -> asyncio.Lock: + lock = self.boot_locks.get(sandbox_id) + if lock is None: + lock = asyncio.Lock() + self.boot_locks[sandbox_id] = lock + return lock + + def drop_boot_lock(self, sandbox_id: str) -> None: + self.boot_locks.pop(sandbox_id, None) + + def get_provider(self, provider_id: str) -> SandboxProvider: + provider = self.providers.get(provider_id) + if provider is None: + raise RuntimeError(f"Provider {provider_id} is not supported") + return provider + + def build_record_payload( + self, + *, + sandbox_id: str, + sandbox_name: str, + session_id: str, + provider_id: str, + idle_timeout: float, + connect_info: dict, + is_default: bool = False, + ) -> dict: + return { + "sandbox_id": sandbox_id, + "sandbox_name": sandbox_name, + "booter_type": provider_id, + "provider": provider_id, + "managed": True, + "created_by_astrbot": True, + "owner_user_id": session_id, + "owner_session_id": session_id, + "connect_info": connect_info, + "capabilities": sorted( + getattr(self.get_provider(provider_id), "capabilities", set()) + ), + "is_default": is_default, + "idle_timeout": idle_timeout, + } + + def new_sandbox_id(self, provider_id: str) -> str: + return f"{provider_id}-{uuid.uuid4().hex[:12]}" + + def get_default_sandbox_id(self, provider_id: str) -> str | None: + default_sandbox_id = self.registry.get_default_sandbox_id(provider_id) + if default_sandbox_id: + record = self.registry.get_sandbox(default_sandbox_id) + if record and record.get("provider") == provider_id: + return default_sandbox_id + for record in self.registry.list_sandboxes(): + if record.get("managed") and record.get("provider") == provider_id: + return record["sandbox_id"] + return None + + async def booter_available(self, booter: ComputerBooter) -> bool: + available = getattr(booter, "available", None) + if available is None: + return True + return await available() + + def acquire_lease( + self, sandbox_id: str, session_id: str, *, ttl: float | None = None + ) -> bool: + return self.registry.acquire_lease( + sandbox_id=sandbox_id, + session_id=session_id, + user_id=session_id, + ttl=SANDBOX_LEASE_SECONDS if ttl is None else ttl, + ) + + def sandbox_has_active_lease(self, sandbox_id: str) -> bool: + record = self.registry.get_sandbox(sandbox_id) + if record is None: + return False + lease_expires_at = record.get("lease_expires_at") + controller_session_id = record.get("controller_session_id") + return bool( + controller_session_id + and lease_expires_at + and lease_expires_at > time.time() + ) + + def sandbox_controlled_by_other_session( + self, sandbox_id: str, session_id: str + ) -> bool: + record = self.registry.get_sandbox(sandbox_id) + if record is None: + return False + lease_expires_at = record.get("lease_expires_at") + controller_session_id = record.get("controller_session_id") + if not controller_session_id or controller_session_id == session_id: + return False + return bool(lease_expires_at and lease_expires_at > time.time()) + + def _upsert_new_sandbox_record( + self, context: Context, session_id: str, provider_id: str, create_config: dict + ) -> str: + provider = self.get_provider(provider_id) + sandbox_id = self.new_sandbox_id(provider_id) + self.registry.upsert_sandbox( + **self.build_record_payload( + sandbox_id=sandbox_id, + sandbox_name=sandbox_id, + session_id=session_id, + provider_id=provider_id, + idle_timeout=provider.get_idle_timeout(context, session_id), + connect_info=provider.build_connect_info(sandbox_id, create_config), + ) + ) + self.save_registry() + return sandbox_id + + async def get_or_create_booter( + self, context: Context, session_id: str, provider_id: str + ) -> ComputerBooter: + provider = self.get_provider(provider_id) + create_config = provider.build_create_config(context, session_id) + idle_timeout = provider.get_idle_timeout(context, session_id) + + current_sandbox_id = self.registry.get_current_sandbox_id(session_id) + current_record = self.registry.get_sandbox(current_sandbox_id) + if current_sandbox_id and ( + current_record is None or current_record.get("provider") != provider_id + ): + self.registry.set_current_sandbox_id(session_id, None) + self.save_registry() + if ( + current_sandbox_id + and current_record + and current_record.get("provider") == provider_id + and current_sandbox_id in self.session_booter + ): + if not self.acquire_lease(current_sandbox_id, session_id): + raise RuntimeError(f"Sandbox {current_sandbox_id} is busy") + booter = self.session_booter[current_sandbox_id] + if await self.booter_available(booter): + self.registry.touch_sandbox(current_sandbox_id) + self.save_registry() + self.schedule_idle_cleanup(current_sandbox_id, idle_timeout) + return booter + self.session_booter.pop(current_sandbox_id, None) + + created_target_record = False + target_sandbox_id = self.get_default_sandbox_id(provider_id) + if target_sandbox_id is None: + target_sandbox_id = self.new_sandbox_id(provider_id) + created_target_record = True + record = self.registry.upsert_sandbox( + **self.build_record_payload( + sandbox_id=target_sandbox_id, + sandbox_name=target_sandbox_id, + session_id=session_id, + provider_id=provider_id, + idle_timeout=idle_timeout, + connect_info=provider.build_connect_info( + target_sandbox_id, create_config + ), + is_default=True, + ) + ) + self.registry.set_default_sandbox_id(record["sandbox_id"]) + self.save_registry() + + if self.sandbox_controlled_by_other_session(target_sandbox_id, session_id): + target_sandbox_id = self._upsert_new_sandbox_record( + context, session_id, provider_id, create_config + ) + created_target_record = True + + while True: + async with self._sandbox_boot_lock(target_sandbox_id): + if target_sandbox_id in self.session_booter and not self.acquire_lease( + target_sandbox_id, session_id + ): + target_sandbox_id = self._upsert_new_sandbox_record( + context, session_id, provider_id, create_config + ) + created_target_record = True + continue + + if target_sandbox_id in self.session_booter: + booter = self.session_booter[target_sandbox_id] + if await self.booter_available(booter): + break + self.session_booter.pop(target_sandbox_id, None) + self.registry.release_lease(target_sandbox_id) + self.registry.update_sandbox_status(target_sandbox_id, "unknown") + self.save_registry() + + if not self.acquire_lease(target_sandbox_id, session_id): + target_sandbox_id = self._upsert_new_sandbox_record( + context, session_id, provider_id, create_config + ) + created_target_record = True + continue + + try: + client = await provider.create_booter( + context, session_id, target_sandbox_id, create_config + ) + except Exception: + if created_target_record: + self.registry.delete_sandbox(target_sandbox_id) + else: + self.registry.release_lease(target_sandbox_id) + self.registry.update_sandbox_status( + target_sandbox_id, "unknown" + ) + self.drop_boot_lock(target_sandbox_id) + self.save_registry() + raise + setattr(client, "sandbox_id", target_sandbox_id) + self.session_booter[target_sandbox_id] = client + break + break + + self.registry.touch_sandbox(target_sandbox_id) + self.registry.update_sandbox_status(target_sandbox_id, "running") + self.registry.set_current_sandbox_id(session_id, target_sandbox_id) + self.save_registry() + self.schedule_idle_cleanup(target_sandbox_id, idle_timeout) + return self.session_booter[target_sandbox_id] + + async def create_sandbox_uncontrolled( + self, + context: Context, + session_id: str, + provider_id: str, + sandbox_name: str | None = None, + ) -> dict: + provider = self.get_provider(provider_id) + create_config = provider.build_create_config(context, session_id) + sandbox_id = self.new_sandbox_id(provider_id) + sandbox_name = sandbox_name or sandbox_id + idle_timeout = provider.get_idle_timeout(context, session_id) + record = self.registry.upsert_sandbox( + **self.build_record_payload( + sandbox_id=sandbox_id, + sandbox_name=sandbox_name, + session_id=session_id, + provider_id=provider_id, + idle_timeout=idle_timeout, + connect_info=provider.build_connect_info(sandbox_name, create_config), + ) + ) + try: + client = await provider.create_booter( + context, session_id, sandbox_id, create_config + ) + except Exception: + self.registry.delete_sandbox(sandbox_id) + self.drop_boot_lock(sandbox_id) + self.save_registry() + raise + setattr(client, "sandbox_id", sandbox_id) + self.session_booter[sandbox_id] = client + self.registry.touch_sandbox(sandbox_id) + self.registry.update_sandbox_status(sandbox_id, "running") + self.save_registry() + self.schedule_idle_cleanup(sandbox_id, idle_timeout) + return self.registry.get_sandbox(sandbox_id) or record + + async def create_sandbox( + self, + context: Context, + session_id: str, + provider_id: str, + sandbox_name: str | None = None, + ) -> dict: + sandbox = await self.create_sandbox_uncontrolled( + context, session_id, provider_id, sandbox_name + ) + sandbox_id = sandbox["sandbox_id"] + if not self.acquire_lease(sandbox_id, session_id): + raise RuntimeError(f"Sandbox {sandbox_id} is busy") + self.registry.set_current_sandbox_id(session_id, sandbox_id) + self.save_registry() + return self.registry.get_sandbox(sandbox_id) or sandbox + + def list_sandboxes(self) -> list[dict]: + records = [] + for record in self.registry.list_sandboxes(): + if not record.get("managed"): + continue + provider = self.providers.get(record.get("provider")) + record["capabilities"] = sorted( + getattr(provider, "capabilities", set()) if provider else [] + ) + record["tool_names"] = sorted( + getattr(provider, "tool_names", set()) if provider else [] + ) + records.append(record) + return records + + def set_default_sandbox(self, sandbox_id: str) -> dict: + record = self.registry.get_sandbox(sandbox_id) + if record is None or not record.get("managed"): + raise RuntimeError(f"Sandbox {sandbox_id} not found") + self.registry.set_default_sandbox_id(sandbox_id) + self.save_registry() + return self.registry.get_sandbox(sandbox_id) or record + + def update_sandbox_config( + self, + sandbox_id: str, + *, + sandbox_name: str | None = None, + idle_timeout: int | float | None, + expires_at: int | float | None, + retention_policy: str, + ) -> dict: + record = self.registry.get_sandbox(sandbox_id) + if record is None or not record.get("managed"): + raise RuntimeError(f"Sandbox {sandbox_id} not found") + if retention_policy not in {"temporary", "persistent"}: + raise RuntimeError("retention_policy must be temporary or persistent") + if retention_policy == "persistent": + idle_timeout = None + expires_at = None + updates = { + "idle_timeout": idle_timeout, + "expires_at": expires_at, + "retention_policy": retention_policy, + } + if sandbox_name is not None: + updates["sandbox_name"] = sandbox_name + provider = self.providers.get(record.get("provider", "")) + if provider is not None: + updates["connect_info"] = provider.update_connect_info( + record, + sandbox_name=sandbox_name.strip(), + ) + updated = self.registry.update_sandbox_config(sandbox_id, **updates) + if retention_policy == "persistent" or not idle_timeout: + self.clear_idle_state(sandbox_id) + else: + self.schedule_idle_cleanup(sandbox_id, float(idle_timeout)) + self.save_registry() + return updated or record + + async def switch_current_sandbox_checked( + self, session_id: str, sandbox_id: str + ) -> dict: + record = self.registry.get_sandbox(sandbox_id) + if record is None or not record.get("managed"): + raise RuntimeError(f"Sandbox {sandbox_id} not found") + booter = self.session_booter.get(sandbox_id) + if booter is None: + raise RuntimeError(f"Sandbox {sandbox_id} is not running") + if not await self.booter_available(booter): + self.session_booter.pop(sandbox_id, None) + self.registry.update_sandbox_status(sandbox_id, "unknown") + self.save_registry() + raise RuntimeError(f"Sandbox {sandbox_id} is not running") + if not self.acquire_lease(sandbox_id, session_id): + raise RuntimeError(f"Sandbox {sandbox_id} is busy") + return self._set_current_sandbox_after_lease(session_id, sandbox_id, record) + + def _set_current_sandbox_after_lease( + self, session_id: str, sandbox_id: str, record: dict + ) -> dict: + previous_sandbox_id = self.registry.get_current_sandbox_id(session_id) + if previous_sandbox_id and previous_sandbox_id != sandbox_id: + previous = self.registry.get_sandbox(previous_sandbox_id) + if previous and previous.get("controller_session_id") == session_id: + self.registry.release_lease(previous_sandbox_id) + self.registry.set_current_sandbox_id(session_id, sandbox_id) + self.registry.touch_sandbox(sandbox_id) + self.save_registry() + return self.registry.get_sandbox(sandbox_id) or record + + def get_current_sandbox(self, session_id: str) -> dict: + sandbox_id = self.registry.get_current_sandbox_id(session_id) + return { + "current_sandbox_id": sandbox_id, + "sandbox": self.registry.get_sandbox(sandbox_id) if sandbox_id else None, + } + + def release_current_sandbox( + self, session_id: str, sandbox_id: str | None = None + ) -> dict: + target_sandbox_id = sandbox_id or self.registry.get_current_sandbox_id( + session_id + ) + if target_sandbox_id is None: + raise RuntimeError("No current sandbox") + record = self.registry.get_sandbox(target_sandbox_id) + if record is None: + raise RuntimeError(f"Sandbox {target_sandbox_id} not found") + controller_session_id = record.get("controller_session_id") + if ( + controller_session_id + and controller_session_id != session_id + and self.sandbox_has_active_lease(target_sandbox_id) + ): + raise RuntimeError( + f"Sandbox {target_sandbox_id} is controlled by another session" + ) + released = self.registry.release_lease(target_sandbox_id) or record + if self.registry.get_current_sandbox_id(session_id) == target_sandbox_id: + self.registry.set_current_sandbox_id(session_id, None) + self.save_registry() + return released + + def takeover_sandbox(self, session_id: str, sandbox_id: str) -> dict: + record = self.registry.get_sandbox(sandbox_id) + if record is None or not record.get("managed"): + raise RuntimeError(f"Sandbox {sandbox_id} not found") + updated = ( + self.registry.takeover_lease( + sandbox_id=sandbox_id, + session_id=session_id, + user_id=session_id, + ttl=SANDBOX_LEASE_SECONDS, + ) + or record + ) + self.save_registry() + return updated + + async def destroy_sandbox(self, session_id: str, sandbox_id: str) -> dict: + record = self.registry.get_sandbox(sandbox_id) + if record is None or not record.get("managed"): + raise RuntimeError(f"Sandbox {sandbox_id} not found") + controller_session_id = record.get("controller_session_id") + if ( + controller_session_id + and controller_session_id != session_id + and self.sandbox_has_active_lease(sandbox_id) + ): + raise RuntimeError(f"Sandbox {sandbox_id} is controlled by another session") + provider = self.get_provider(record.get("provider", "")) + booter = self.session_booter.get(sandbox_id) + if booter is not None: + await provider.destroy_booter(booter, record) + self.session_booter.pop(sandbox_id, None) + self.clear_idle_state(sandbox_id) + self.registry.delete_sandbox(sandbox_id) + self.drop_boot_lock(sandbox_id) + self.save_registry() + return record + + async def get_observer_booter_by_id(self, sandbox_id: str) -> ComputerBooter: + record = self.registry.get_sandbox(sandbox_id) + if record is None or not record.get("managed"): + raise RuntimeError(f"Sandbox {sandbox_id} not found") + booter = self.session_booter.get(sandbox_id) + if booter is None: + raise RuntimeError(f"Sandbox {sandbox_id} is not running") + if not await self.booter_available(booter): + self.session_booter.pop(sandbox_id, None) + self.registry.update_sandbox_status(sandbox_id, "unknown") + self.save_registry() + raise RuntimeError(f"Sandbox {sandbox_id} is not running") + self.registry.touch_sandbox(sandbox_id) + self.save_registry() + idle_timeout = record.get("idle_timeout") or 0 + self.schedule_idle_cleanup(sandbox_id, float(idle_timeout)) + return booter + + async def reconcile_on_startup(self) -> None: + self.registry.load() + self.registry.reconcile_startup() + self.session_booter.clear() + for sandbox_id in list(self.idle_state): + self.clear_idle_state(sandbox_id) + self.registry.save() + + async def cleanup_managed_sandboxes(self) -> None: + managed_records = self.list_sandboxes() + for record in managed_records: + sandbox_id = record["sandbox_id"] + if record.get("retention_policy") == "persistent": + logger.info( + "[Computer] Preserve persistent sandbox during shutdown: sandbox_id=%s", + sandbox_id, + ) + continue + try: + provider = self.get_provider(record.get("provider", "")) + except RuntimeError as provider_error: + self.registry.update_sandbox_status(sandbox_id, "unknown") + self.save_registry() + logger.warning( + "[Computer] Skip managed sandbox cleanup for unsupported provider: sandbox_id=%s error=%s", + sandbox_id, + provider_error, + ) + continue + booter = self.session_booter.get(sandbox_id) + if booter is not None: + try: + await provider.destroy_booter(booter, record) + self.session_booter.pop(sandbox_id, None) + except Exception as shutdown_err: + self.registry.update_sandbox_status(sandbox_id, "unknown") + self.save_registry() + logger.warning( + "[Computer] Failed to shutdown managed sandbox %s: %s", + sandbox_id, + shutdown_err, + ) + continue + self.clear_idle_state(sandbox_id) + self.registry.delete_sandbox(sandbox_id) + self.drop_boot_lock(sandbox_id) + self.registry.save() + + def clear_idle_state(self, sandbox_id: str) -> None: + state = self.idle_state.pop(sandbox_id, None) + if state is not None and not state.task.done(): + state.task.cancel() + + def schedule_idle_cleanup(self, sandbox_id: str, timeout: float) -> None: + self.clear_idle_state(sandbox_id) + if timeout <= 0: + return + self.registry.touch_sandbox(sandbox_id) + expires_at = time.monotonic() + timeout + task = asyncio.create_task( + self._expire_when_idle(sandbox_id, timeout, expires_at) + ) + self.idle_state[sandbox_id] = SandboxIdleState(expires_at=expires_at, task=task) + + async def _expire_when_idle( + self, sandbox_id: str, timeout: float, initial_expires_at: float + ) -> None: + current_expires_at = initial_expires_at + try: + while True: + remaining = current_expires_at - time.monotonic() + if remaining > 0: + await asyncio.sleep(remaining) + state = self.idle_state.get(sandbox_id) + if state is None or state.expires_at != current_expires_at: + return + record = self.registry.get_sandbox(sandbox_id) + if record is None: + self.session_booter.pop(sandbox_id, None) + return + if self.sandbox_has_active_lease(sandbox_id): + current_expires_at = time.monotonic() + timeout + self.idle_state[sandbox_id] = SandboxIdleState( + expires_at=current_expires_at, task=state.task + ) + continue + last_used_at = record.get("last_used_at") + if last_used_at is not None: + idle_remaining = timeout - (time.time() - float(last_used_at)) + if idle_remaining > 0: + current_expires_at = time.monotonic() + idle_remaining + self.idle_state[sandbox_id] = SandboxIdleState( + expires_at=current_expires_at, task=state.task + ) + continue + booter = self.session_booter.get(sandbox_id) + if booter is not None: + try: + provider = self.get_provider(record.get("provider", "")) + self.session_booter.pop(sandbox_id, None) + await provider.destroy_booter(booter, record) + except Exception as shutdown_err: + self.session_booter[sandbox_id] = booter + self.registry.update_sandbox_status(sandbox_id, "unknown") + self.save_registry() + logger.warning( + "[Computer] Failed to shutdown idle sandbox %s: %s", + sandbox_id, + shutdown_err, + ) + return + if record.get("retention_policy") == "persistent": + self.registry.update_sandbox_status(sandbox_id, "stopped") + else: + self.registry.delete_sandbox(sandbox_id) + self.drop_boot_lock(sandbox_id) + self.save_registry() + return + except asyncio.CancelledError: + raise + finally: + state = self.idle_state.get(sandbox_id) + if state is not None and state.expires_at == current_expires_at: + self.idle_state.pop(sandbox_id, None) diff --git a/astrbot/core/computer/sandbox_models.py b/astrbot/core/computer/sandbox_models.py new file mode 100644 index 0000000000..da0316e78c --- /dev/null +++ b/astrbot/core/computer/sandbox_models.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class SandboxRetentionPolicy(str, Enum): + TEMPORARY = "temporary" + PERSISTENT = "persistent" + + +class SandboxStatus(str, Enum): + RUNNING = "running" + STOPPED = "stopped" + UNKNOWN = "unknown" + + +@dataclass(slots=True) +class SandboxRecord: + sandbox_id: str + sandbox_name: str + booter_type: str + provider: str + managed: bool + created_by_astrbot: bool + is_default: bool = False + owner_user_id: str | None = None + owner_session_id: str | None = None + controller_user_id: str | None = None + controller_session_id: str | None = None + lease_expires_at: float | None = None + last_used_at: float | None = None + idle_timeout: int | float | None = None + expires_at: float | None = None + retention_policy: SandboxRetentionPolicy = SandboxRetentionPolicy.TEMPORARY + status: SandboxStatus = SandboxStatus.RUNNING + connect_info: dict[str, Any] = field(default_factory=dict) + capabilities: list[str] = field(default_factory=list) + labels: dict[str, Any] = field(default_factory=dict) + notes: str | None = None + + @staticmethod + def _required_string(data: dict[str, Any], field_name: str) -> str: + value = data[field_name] + if not isinstance(value, str): + raise ValueError(f"{field_name} must be a non-empty string") + value = value.strip() + if not value: + raise ValueError(f"{field_name} must be a non-empty string") + return value + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SandboxRecord: + return cls( + sandbox_id=cls._required_string(data, "sandbox_id"), + sandbox_name=cls._required_string(data, "sandbox_name"), + booter_type=cls._required_string(data, "booter_type"), + provider=cls._required_string(data, "provider"), + managed=bool(data["managed"]), + created_by_astrbot=bool(data["created_by_astrbot"]), + is_default=bool(data.get("is_default", False)), + owner_user_id=data.get("owner_user_id"), + owner_session_id=data.get("owner_session_id"), + controller_user_id=data.get("controller_user_id"), + controller_session_id=data.get("controller_session_id"), + lease_expires_at=data.get("lease_expires_at"), + last_used_at=data.get("last_used_at"), + idle_timeout=data.get("idle_timeout"), + expires_at=data.get("expires_at"), + retention_policy=SandboxRetentionPolicy( + data.get("retention_policy", SandboxRetentionPolicy.TEMPORARY) + ), + status=SandboxStatus(data.get("status", SandboxStatus.RUNNING)), + connect_info=dict(data.get("connect_info") or {}), + capabilities=sorted( + str(item) for item in data.get("capabilities", []) if item + ), + labels=dict(data.get("labels") or {}), + notes=data.get("notes"), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "sandbox_id": self.sandbox_id, + "sandbox_name": self.sandbox_name, + "booter_type": self.booter_type, + "provider": self.provider, + "managed": self.managed, + "created_by_astrbot": self.created_by_astrbot, + "is_default": self.is_default, + "owner_user_id": self.owner_user_id, + "owner_session_id": self.owner_session_id, + "controller_user_id": self.controller_user_id, + "controller_session_id": self.controller_session_id, + "lease_expires_at": self.lease_expires_at, + "last_used_at": self.last_used_at, + "idle_timeout": self.idle_timeout, + "expires_at": self.expires_at, + "retention_policy": self.retention_policy.value, + "status": self.status.value, + "connect_info": dict(self.connect_info), + "capabilities": list(self.capabilities), + "labels": dict(self.labels), + "notes": self.notes, + } + + def has_active_lease(self, *, now: float | None = None) -> bool: + current_time = time.time() if now is None else now + return bool( + self.controller_session_id + and self.lease_expires_at + and self.lease_expires_at > current_time + ) + + def is_controlled_by(self, session_id: str, *, now: float | None = None) -> bool: + return self.controller_session_id == session_id and self.has_active_lease( + now=now + ) diff --git a/astrbot/core/computer/sandbox_provider.py b/astrbot/core/computer/sandbox_provider.py new file mode 100644 index 0000000000..92c4d84a1d --- /dev/null +++ b/astrbot/core/computer/sandbox_provider.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Protocol + +from astrbot.core.computer.booters.base import ComputerBooter +from astrbot.core.star.context import Context + + +class SandboxProvider(Protocol): + provider_id: str + capabilities: set[str] + tool_names: set[str] + + def build_create_config(self, context: Context, session_id: str) -> dict: ... + + def build_connect_info(self, sandbox_name: str, config: dict) -> dict: ... + + def update_connect_info(self, record: dict, *, sandbox_name: str) -> dict: ... + + def get_idle_timeout(self, context: Context, session_id: str) -> float: ... + + async def create_booter( + self, + context: Context, + session_id: str, + sandbox_id: str, + config: dict, + ) -> ComputerBooter: ... + + async def destroy_booter(self, booter: ComputerBooter, record: dict) -> None: ... diff --git a/astrbot/core/computer/sandbox_registry.py b/astrbot/core/computer/sandbox_registry.py new file mode 100644 index 0000000000..2b3c8479ab --- /dev/null +++ b/astrbot/core/computer/sandbox_registry.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import json +import time +from copy import deepcopy +from pathlib import Path +from typing import Any + +from astrbot.api import logger +from astrbot.core.computer.sandbox_models import SandboxRecord +from astrbot.core.utils.astrbot_path import get_astrbot_data_path + +_UNSET = object() + + +def _default_registry_payload() -> dict[str, Any]: + return { + "default_sandbox_id": None, + "default_sandbox_ids": {}, + "sandboxes": {}, + "session_current": {}, + } + + +class SandboxRegistry: + def __init__(self, storage_path: str | Path | None = None): + if storage_path is None: + storage_path = Path(get_astrbot_data_path()) / "sandbox_registry.json" + self.storage_path = Path(storage_path) + self._payload = _default_registry_payload() + + @property + def default_sandbox_id(self) -> str | None: + return self._payload["default_sandbox_id"] + + def get_default_sandbox_id(self, provider: str) -> str | None: + sandbox_id = self._payload.get("default_sandbox_ids", {}).get(provider) + if sandbox_id and sandbox_id in self._payload["sandboxes"]: + return sandbox_id + if self._payload["default_sandbox_id"]: + record = self.get_sandbox(self._payload["default_sandbox_id"]) + if record and record.get("provider") == provider: + return self._payload["default_sandbox_id"] + return None + + def get_sandbox(self, sandbox_id: str | None) -> dict[str, Any] | None: + if sandbox_id is None: + return None + record = self._payload["sandboxes"].get(sandbox_id) + return deepcopy(record) if record is not None else None + + def list_sandboxes(self) -> list[dict[str, Any]]: + return [deepcopy(item) for item in self._payload["sandboxes"].values()] + + def set_default_sandbox_id(self, sandbox_id: str | None) -> None: + old_default = self._payload["default_sandbox_id"] + self._payload["default_sandbox_id"] = sandbox_id + if sandbox_id and sandbox_id in self._payload["sandboxes"]: + record = self._payload["sandboxes"][sandbox_id] + provider = record.get("provider") + if provider: + old_provider_default = self._payload.setdefault( + "default_sandbox_ids", {} + ).get(provider) + if ( + old_provider_default + and old_provider_default in self._payload["sandboxes"] + ): + self._payload["sandboxes"][old_provider_default]["is_default"] = ( + False + ) + self._payload["default_sandbox_ids"][provider] = sandbox_id + record["is_default"] = True + elif old_default and old_default in self._payload["sandboxes"]: + self._payload["sandboxes"][old_default]["is_default"] = False + + def get_current_sandbox_id(self, session_id: str) -> str | None: + return self._payload["session_current"].get(session_id) + + def set_current_sandbox_id(self, session_id: str, sandbox_id: str | None) -> None: + if sandbox_id is None: + self._payload["session_current"].pop(session_id, None) + else: + self._payload["session_current"][session_id] = sandbox_id + + def upsert_sandbox( + self, + *, + sandbox_id: str, + sandbox_name: str, + booter_type: str, + provider: str, + managed: bool, + created_by_astrbot: bool, + owner_user_id: str | None, + owner_session_id: str | None, + connect_info: dict[str, Any], + is_default: bool | object = _UNSET, + status: str | object = _UNSET, + idle_timeout: int | float | None | object = _UNSET, + expires_at: float | None | object = _UNSET, + retention_policy: str | object = _UNSET, + last_used_at: float | None | object = _UNSET, + controller_user_id: str | None | object = _UNSET, + controller_session_id: str | None | object = _UNSET, + lease_expires_at: float | None | object = _UNSET, + labels: dict[str, Any] | None | object = _UNSET, + capabilities: list[str] | set[str] | None | object = _UNSET, + notes: str | None | object = _UNSET, + ) -> dict[str, Any]: + record = self._payload["sandboxes"].get(sandbox_id, {}) + record.update( + { + "sandbox_id": sandbox_id, + "sandbox_name": sandbox_name, + "booter_type": booter_type, + "provider": provider, + "managed": managed, + "created_by_astrbot": created_by_astrbot, + "owner_user_id": owner_user_id, + "owner_session_id": owner_session_id, + "connect_info": deepcopy(connect_info), + } + ) + defaults = { + "controller_user_id": None, + "controller_session_id": None, + "lease_expires_at": None, + "last_used_at": None, + "idle_timeout": None, + "expires_at": None, + "retention_policy": "temporary", + "status": "running", + "is_default": False, + "labels": {}, + "capabilities": [], + "notes": None, + } + updates = { + "controller_user_id": controller_user_id, + "controller_session_id": controller_session_id, + "lease_expires_at": lease_expires_at, + "last_used_at": last_used_at, + "idle_timeout": idle_timeout, + "expires_at": expires_at, + "retention_policy": retention_policy, + "status": status, + "is_default": is_default, + "labels": deepcopy(labels) if labels is not _UNSET else _UNSET, + "capabilities": sorted(capabilities) + if capabilities is not _UNSET + else _UNSET, + "notes": notes, + } + for field_name, default_value in defaults.items(): + value = updates[field_name] + if value is _UNSET: + record.setdefault(field_name, deepcopy(default_value)) + else: + record[field_name] = value + record = SandboxRecord.from_dict(record).to_dict() + self._payload["sandboxes"][sandbox_id] = record + if is_default is True or ( + managed and self._payload["default_sandbox_id"] is None + ): + self.set_default_sandbox_id(sandbox_id) + return deepcopy(record) + + def delete_sandbox(self, sandbox_id: str) -> None: + was_default = self._payload["default_sandbox_id"] == sandbox_id + deleted = self._payload["sandboxes"].pop(sandbox_id, None) + if deleted: + provider = deleted.get("provider") + if ( + provider + and self._payload.get("default_sandbox_ids", {}).get(provider) + == sandbox_id + ): + self._payload["default_sandbox_ids"].pop(provider, None) + for candidate_id, candidate in self._payload["sandboxes"].items(): + if ( + candidate.get("managed") + and candidate.get("provider") == provider + ): + self.set_default_sandbox_id(candidate_id) + break + if was_default: + self._payload["default_sandbox_id"] = None + for candidate_id, candidate in self._payload["sandboxes"].items(): + if candidate.get("managed"): + self.set_default_sandbox_id(candidate_id) + break + stale_sessions = [ + session_id + for session_id, current_id in self._payload["session_current"].items() + if current_id == sandbox_id + ] + for session_id in stale_sessions: + self._payload["session_current"].pop(session_id, None) + + def touch_sandbox( + self, sandbox_id: str, *, ts: float | None = None + ) -> dict[str, Any] | None: + record = self._payload["sandboxes"].get(sandbox_id) + if record is None: + return None + record["last_used_at"] = ts if ts is not None else time.time() + return deepcopy(record) + + def update_sandbox_config( + self, + sandbox_id: str, + *, + sandbox_name: str | object = _UNSET, + connect_info: dict[str, Any] | object = _UNSET, + idle_timeout: int | float | None | object = _UNSET, + expires_at: int | float | None | object = _UNSET, + retention_policy: str | object = _UNSET, + ) -> dict[str, Any] | None: + record = self._payload["sandboxes"].get(sandbox_id) + if record is None: + return None + if sandbox_name is not _UNSET: + name = str(sandbox_name).strip() + if not name: + raise ValueError("sandbox_name must be a non-empty string") + record["sandbox_name"] = name + if connect_info is not _UNSET: + record["connect_info"] = deepcopy(connect_info) + if idle_timeout is not _UNSET: + record["idle_timeout"] = idle_timeout + if expires_at is not _UNSET: + record["expires_at"] = expires_at + if retention_policy is not _UNSET: + record["retention_policy"] = retention_policy + return deepcopy(record) + + def update_sandbox_status( + self, sandbox_id: str, status: str + ) -> dict[str, Any] | None: + record = self._payload["sandboxes"].get(sandbox_id) + if record is None: + return None + record["status"] = status + return deepcopy(record) + + def acquire_lease( + self, + *, + sandbox_id: str, + session_id: str, + user_id: str | None, + ttl: int | float, + now: float | None = None, + ) -> bool: + record = self._payload["sandboxes"].get(sandbox_id) + if record is None: + return False + current_time = time.time() if now is None else now + controller_session_id = record.get("controller_session_id") + lease_expires_at = record.get("lease_expires_at") + if ( + controller_session_id + and controller_session_id != session_id + and lease_expires_at + and lease_expires_at > current_time + ): + return False + record["controller_session_id"] = session_id + record["controller_user_id"] = user_id + record["lease_expires_at"] = current_time + ttl + return True + + def release_lease(self, sandbox_id: str) -> dict[str, Any] | None: + record = self._payload["sandboxes"].get(sandbox_id) + if record is None: + return None + record["controller_session_id"] = None + record["controller_user_id"] = None + record["lease_expires_at"] = None + return deepcopy(record) + + def takeover_lease( + self, + *, + sandbox_id: str, + session_id: str, + user_id: str | None, + ttl: int | float, + now: float | None = None, + ) -> dict[str, Any] | None: + record = self._payload["sandboxes"].get(sandbox_id) + if record is None: + return None + current_time = time.time() if now is None else now + record["controller_session_id"] = session_id + record["controller_user_id"] = user_id + record["lease_expires_at"] = current_time + ttl + return deepcopy(record) + + def reconcile_startup(self) -> None: + self._payload["session_current"] = {} + for record in self._payload["sandboxes"].values(): + record["controller_session_id"] = None + record["controller_user_id"] = None + record["lease_expires_at"] = None + if record.get("status") == "running": + record["status"] = "unknown" + + def load(self) -> None: + if not self.storage_path.exists(): + self._payload = _default_registry_payload() + return + try: + payload = json.loads(self.storage_path.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("Failed to load sandbox registry: %s", exc) + self._payload = _default_registry_payload() + return + self._payload = _default_registry_payload() + self._payload.update({key: payload.get(key) for key in self._payload}) + self._payload["default_sandbox_ids"] = dict( + self._payload.get("default_sandbox_ids") or {} + ) + self._payload["sandboxes"] = dict(self._payload.get("sandboxes") or {}) + self._payload["session_current"] = dict( + self._payload.get("session_current") or {} + ) + + def save(self) -> None: + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + self.storage_path.write_text( + json.dumps(self._payload, ensure_ascii=False, indent=2, sort_keys=True), + encoding="utf-8", + ) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index f495a8017b..a5f46e5d42 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -2,7 +2,6 @@ import os -from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG from astrbot.core.utils.astrbot_path import get_astrbot_data_path VERSION = "4.24.2" @@ -166,23 +165,7 @@ }, "computer_use_runtime": "none", "computer_use_require_admin": True, - "sandbox": { - "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, - "cua_image": CUA_DEFAULT_CONFIG["image"], - "cua_os_type": CUA_DEFAULT_CONFIG["os_type"], - "cua_ttl": CUA_DEFAULT_CONFIG["ttl"], - "cua_telemetry_enabled": CUA_DEFAULT_CONFIG["telemetry_enabled"], - "cua_local": CUA_DEFAULT_CONFIG["local"], - "cua_api_key": CUA_DEFAULT_CONFIG["api_key"], - }, + "sandbox": {"booter": ""}, "image_compress_enabled": True, "image_compress_options": { "max_size": 1280, @@ -3285,141 +3268,10 @@ "provider_settings.sandbox.booter": { "description": "沙箱环境驱动器", "type": "string", - "options": ["shipyard_neo", "shipyard", "cua"], - "labels": ["Shipyard Neo", "Shipyard", "CUA"], - "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 地址,默认 http://127.0.0.1:8114。", - "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": "Bay 的 API Key(sk-bay-...)。留空时自动从 credentials.json 发现。", - "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.cua_image": { - "description": "CUA Image", - "type": "string", - "hint": "CUA 沙箱镜像/系统类型,默认 linux。可填写 linux、macos、windows、android,具体取决于 CUA SDK 支持。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "cua", - }, - }, - "provider_settings.sandbox.cua_os_type": { - "description": "CUA OS Type", - "type": "string", - "options": ["linux", "macos", "windows", "android"], - "labels": ["Linux", "macOS", "Windows", "Android"], - "hint": "CUA 沙箱操作系统类型,默认 linux。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "cua", - }, - }, - "provider_settings.sandbox.cua_ttl": { - "description": "CUA Sandbox TTL", - "type": "int", - "hint": "CUA 沙箱生存时间(秒)。当前作为会话配置保存,具体生效取决于 CUA SDK。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "cua", - }, - }, - "provider_settings.sandbox.cua_telemetry_enabled": { - "description": "CUA Telemetry", - "type": "bool", - "hint": "是否允许 CUA SDK 发送遥测数据。默认关闭。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "cua", - }, - }, - "provider_settings.sandbox.cua_local": { - "description": "CUA Local Sandbox", - "type": "bool", - "hint": "是否优先使用 CUA 本地沙箱。默认开启,避免云端沙箱要求 CUA_API_KEY。关闭后可使用 CUA 云端沙箱。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "cua", - }, - }, - "provider_settings.sandbox.cua_api_key": { - "description": "CUA API Key", - "type": "string", - "hint": "CUA 云端沙箱 API Key。仅在关闭本地沙箱时需要。也可以通过 CUA_API_KEY 环境变量提供。", - "obvious_hint": True, - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "cua", - "provider_settings.sandbox.cua_local": False, - }, - }, - "provider_settings.sandbox.shipyard_endpoint": { - "description": "Shipyard API Endpoint", - "type": "string", - "hint": "Shipyard 服务的 API 访问地址。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "shipyard", - }, - "_special": "check_shipyard_connection", - }, - "provider_settings.sandbox.shipyard_access_token": { - "description": "Shipyard Access Token", - "type": "string", - "hint": "用于访问 Shipyard 服务的访问令牌。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "shipyard", - }, - }, - "provider_settings.sandbox.shipyard_ttl": { - "description": "Shipyard Session TTL", - "type": "int", - "hint": "Shipyard 会话的生存时间(秒)。", - "condition": { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "shipyard", - }, - }, - "provider_settings.sandbox.shipyard_max_sessions": { - "description": "Shipyard Max Sessions", - "type": "int", - "hint": "Shipyard 最大会话数量。", + "options": [], + "labels": [], "condition": { "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "shipyard", }, }, }, diff --git a/astrbot/core/tools/computer_tools/__init__.py b/astrbot/core/tools/computer_tools/__init__.py index f90c2e1de8..e141a99931 100644 --- a/astrbot/core/tools/computer_tools/__init__.py +++ b/astrbot/core/tools/computer_tools/__init__.py @@ -1,8 +1,3 @@ -from .cua import ( - CuaKeyboardTypeTool, - CuaMouseClickTool, - CuaScreenshotTool, -) from .fs import ( FileDownloadTool, FileEditTool, @@ -13,51 +8,18 @@ ) from .python import LocalPythonTool, PythonTool from .shell import ExecuteShellTool -from .shipyard_neo import ( - AnnotateExecutionTool, - BrowserBatchExecTool, - BrowserExecTool, - CreateSkillCandidateTool, - CreateSkillPayloadTool, - EvaluateSkillCandidateTool, - GetExecutionHistoryTool, - GetSkillPayloadTool, - ListSkillCandidatesTool, - ListSkillReleasesTool, - PromoteSkillCandidateTool, - RollbackSkillReleaseTool, - RunBrowserSkillTool, - SyncSkillReleaseTool, -) from .util import check_admin_permission, normalize_umo_for_workspace __all__ = [ - "AnnotateExecutionTool", - "BrowserBatchExecTool", - "BrowserExecTool", - "CreateSkillCandidateTool", - "CreateSkillPayloadTool", - "CuaKeyboardTypeTool", - "CuaMouseClickTool", - "CuaScreenshotTool", - "EvaluateSkillCandidateTool", "ExecuteShellTool", "FileDownloadTool", "FileEditTool", "FileReadTool", "FileUploadTool", "FileWriteTool", - "GetExecutionHistoryTool", - "GetSkillPayloadTool", "GrepTool", - "ListSkillCandidatesTool", - "ListSkillReleasesTool", "LocalPythonTool", - "PromoteSkillCandidateTool", "PythonTool", - "RollbackSkillReleaseTool", - "RunBrowserSkillTool", - "SyncSkillReleaseTool", "normalize_umo_for_workspace", "check_admin_permission", ] diff --git a/astrbot/core/tools/computer_tools/cua.py b/astrbot/core/tools/computer_tools/cua.py deleted file mode 100644 index 7b37a55086..0000000000 --- a/astrbot/core/tools/computer_tools/cua.py +++ /dev/null @@ -1,177 +0,0 @@ -from __future__ import annotations - -import json -import uuid -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import mcp - -from astrbot.api import FunctionTool -from astrbot.core.agent.run_context import ContextWrapper -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.message.message_event_result import MessageChain -from astrbot.core.tools.computer_tools.util import check_admin_permission -from astrbot.core.tools.registry import builtin_tool -from astrbot.core.utils.astrbot_path import get_astrbot_temp_path - -_CUA_TOOL_CONFIG = { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "cua", -} - - -def _to_json(data: Any) -> str: - return json.dumps(data, ensure_ascii=False, default=str) - - -def _exception_detail(error: Exception) -> str: - return str(error) or type(error).__name__ - - -async def _get_gui_component(context: ContextWrapper[AstrAgentContext]) -> Any: - booter = await get_booter( - context.context.context, - context.context.event.unified_msg_origin, - ) - gui = getattr(booter, "gui", None) - if gui is None: - raise RuntimeError( - "Current sandbox booter does not support CUA GUI capability. " - "Please switch sandbox booter to cua." - ) - return gui - - -@builtin_tool(config=_CUA_TOOL_CONFIG) -@dataclass -class CuaScreenshotTool(FunctionTool): - name: str = "astrbot_cua_screenshot" - description: str = ( - "Capture a screenshot from the CUA sandbox and optionally send it to the user." - ) - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "send_to_user": { - "type": "boolean", - "description": "Whether to send the screenshot image to the current conversation.", - "default": True, - }, - "return_image_to_llm": { - "type": "boolean", - "description": "Whether to include the screenshot image content in the tool result for model inspection.", - "default": True, - }, - }, - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - send_to_user: bool = True, - return_image_to_llm: bool = True, - ) -> ToolExecResult: - if err := check_admin_permission(context, "Taking CUA screenshots"): - return err - try: - gui = await _get_gui_component(context) - path = _new_screenshot_path(context.context.event.unified_msg_origin) - result = await gui.screenshot(path) - payload = {"success": True, **result, "path": path} - if send_to_user: - await context.context.event.send(MessageChain().file_image(path)) - payload["sent_to_user"] = True - image_data = payload.pop("base64", "") - content: list[mcp.types.TextContent | mcp.types.ImageContent] = [ - mcp.types.TextContent(type="text", text=_to_json(payload)) - ] - if return_image_to_llm: - content.append( - mcp.types.ImageContent( - type="image", - data=str(image_data), - mimeType=str(payload.get("mime_type", "image/png")), - ) - ) - return mcp.types.CallToolResult(content=content) - except Exception as e: - return f"Error taking CUA screenshot: {_exception_detail(e)}" - - -@builtin_tool(config=_CUA_TOOL_CONFIG) -@dataclass -class CuaMouseClickTool(FunctionTool): - name: str = "astrbot_cua_mouse_click" - description: str = "Click a coordinate in the CUA sandbox desktop." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "x": {"type": "integer", "description": "X coordinate."}, - "y": {"type": "integer", "description": "Y coordinate."}, - "button": { - "type": "string", - "description": "Mouse button, usually left, right, or middle.", - "default": "left", - }, - }, - "required": ["x", "y"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - x: int, - y: int, - button: str = "left", - ) -> ToolExecResult: - if err := check_admin_permission(context, "Using CUA mouse"): - return err - try: - gui = await _get_gui_component(context) - return _to_json(await gui.click(x, y, button=button)) - except Exception as e: - return f"Error clicking CUA desktop: {_exception_detail(e)}" - - -@builtin_tool(config=_CUA_TOOL_CONFIG) -@dataclass -class CuaKeyboardTypeTool(FunctionTool): - name: str = "astrbot_cua_keyboard_type" - description: str = "Type text into the CUA sandbox desktop." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "text": {"type": "string", "description": "Text to type."}, - }, - "required": ["text"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - text: str, - ) -> ToolExecResult: - if err := check_admin_permission(context, "Using CUA keyboard"): - return err - try: - gui = await _get_gui_component(context) - return _to_json(await gui.type_text(text)) - except Exception as e: - return f"Error typing in CUA desktop: {_exception_detail(e)}" - - -def _new_screenshot_path(umo: str) -> str: - safe_prefix = uuid.uuid5(uuid.NAMESPACE_DNS, umo).hex[:12] - screenshot_dir = Path(get_astrbot_temp_path()) / "cua_screenshots" - screenshot_dir.mkdir(parents=True, exist_ok=True) - return str(screenshot_dir / f"{safe_prefix}-{uuid.uuid4().hex}.png") diff --git a/astrbot/core/tools/computer_tools/shipyard_neo/__init__.py b/astrbot/core/tools/computer_tools/shipyard_neo/__init__.py deleted file mode 100644 index 9228c86354..0000000000 --- a/astrbot/core/tools/computer_tools/shipyard_neo/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -from .browser import BrowserBatchExecTool, BrowserExecTool, RunBrowserSkillTool -from .neo_skills import ( - AnnotateExecutionTool, - CreateSkillCandidateTool, - CreateSkillPayloadTool, - EvaluateSkillCandidateTool, - GetExecutionHistoryTool, - GetSkillPayloadTool, - ListSkillCandidatesTool, - ListSkillReleasesTool, - PromoteSkillCandidateTool, - RollbackSkillReleaseTool, - SyncSkillReleaseTool, -) - -__all__ = [ - "AnnotateExecutionTool", - "BrowserBatchExecTool", - "BrowserExecTool", - "CreateSkillCandidateTool", - "CreateSkillPayloadTool", - "EvaluateSkillCandidateTool", - "GetExecutionHistoryTool", - "GetSkillPayloadTool", - "ListSkillCandidatesTool", - "ListSkillReleasesTool", - "PromoteSkillCandidateTool", - "RollbackSkillReleaseTool", - "RunBrowserSkillTool", - "SyncSkillReleaseTool", -] diff --git a/astrbot/core/tools/computer_tools/shipyard_neo/browser.py b/astrbot/core/tools/computer_tools/shipyard_neo/browser.py deleted file mode 100644 index b4b7f4fd06..0000000000 --- a/astrbot/core/tools/computer_tools/shipyard_neo/browser.py +++ /dev/null @@ -1,204 +0,0 @@ -import json -from dataclasses import dataclass, field -from typing import Any - -from astrbot.api import FunctionTool -from astrbot.core.agent.run_context import ContextWrapper -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.tools.computer_tools.util import check_admin_permission -from astrbot.core.tools.registry import builtin_tool - -_SHIPYARD_NEO_TOOL_CONFIG = { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "shipyard_neo", -} - - -def _to_json(data: Any) -> str: - return json.dumps(data, ensure_ascii=False, default=str) - - -async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> Any: - booter = await get_booter( - context.context.context, - context.context.event.unified_msg_origin, - ) - browser = getattr(booter, "browser", None) - if browser is None: - raise RuntimeError( - "Current sandbox booter does not support browser capability. " - "Please switch to shipyard_neo." - ) - return browser - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class BrowserExecTool(FunctionTool): - name: str = "astrbot_execute_browser" - description: str = "Execute one browser automation command in the sandbox." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "cmd": {"type": "string", "description": "Browser command to execute."}, - "timeout": {"type": "integer", "default": 30}, - "description": { - "type": "string", - "description": "Optional execution description.", - }, - "tags": {"type": "string", "description": "Optional tags."}, - "learn": { - "type": "boolean", - "description": "Whether to mark execution as learn evidence.", - "default": False, - }, - "include_trace": { - "type": "boolean", - "description": "Whether to include trace_ref in response.", - "default": False, - }, - }, - "required": ["cmd"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - cmd: str, - timeout: int = 30, - description: str | None = None, - tags: str | None = None, - learn: bool = False, - include_trace: bool = False, - ) -> ToolExecResult: - if err := check_admin_permission(context, "Using browser tools"): - return err - try: - browser = await _get_browser_component(context) - result = await browser.exec( - cmd=cmd, - timeout=timeout, - description=description, - tags=tags, - learn=learn, - include_trace=include_trace, - ) - return _to_json(result) - except Exception as e: - return f"Error executing browser command: {str(e)}" - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class BrowserBatchExecTool(FunctionTool): - name: str = "astrbot_execute_browser_batch" - description: str = "Execute a browser command batch in the sandbox." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "commands": { - "type": "array", - "items": {"type": "string"}, - "description": "Ordered browser commands.", - }, - "timeout": {"type": "integer", "default": 60}, - "stop_on_error": {"type": "boolean", "default": True}, - "description": { - "type": "string", - "description": "Optional execution description.", - }, - "tags": {"type": "string", "description": "Optional tags."}, - "learn": { - "type": "boolean", - "description": "Whether to mark execution as learn evidence.", - "default": False, - }, - "include_trace": { - "type": "boolean", - "description": "Whether to include trace_ref in response.", - "default": False, - }, - }, - "required": ["commands"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - 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, - ) -> ToolExecResult: - if err := check_admin_permission(context, "Using browser tools"): - return err - try: - browser = await _get_browser_component(context) - result = await browser.exec_batch( - commands=commands, - timeout=timeout, - stop_on_error=stop_on_error, - description=description, - tags=tags, - learn=learn, - include_trace=include_trace, - ) - return _to_json(result) - except Exception as e: - return f"Error executing browser batch command: {str(e)}" - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class RunBrowserSkillTool(FunctionTool): - name: str = "astrbot_run_browser_skill" - description: str = "Run a released browser skill in the sandbox by skill_key." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "skill_key": {"type": "string"}, - "timeout": {"type": "integer", "default": 60}, - "stop_on_error": {"type": "boolean", "default": True}, - "include_trace": {"type": "boolean", "default": False}, - "description": {"type": "string"}, - "tags": {"type": "string"}, - }, - "required": ["skill_key"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - skill_key: str, - timeout: int = 60, - stop_on_error: bool = True, - include_trace: bool = False, - description: str | None = None, - tags: str | None = None, - ) -> ToolExecResult: - if err := check_admin_permission(context, "Using browser tools"): - return err - try: - browser = await _get_browser_component(context) - result = await browser.run_skill( - skill_key=skill_key, - timeout=timeout, - stop_on_error=stop_on_error, - include_trace=include_trace, - description=description, - tags=tags, - ) - return _to_json(result) - except Exception as e: - return f"Error running browser skill: {str(e)}" diff --git a/astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py b/astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py deleted file mode 100644 index e2c4f59093..0000000000 --- a/astrbot/core/tools/computer_tools/shipyard_neo/neo_skills.py +++ /dev/null @@ -1,556 +0,0 @@ -import json -from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field -from typing import Any - -from astrbot.api import FunctionTool -from astrbot.core.agent.run_context import ContextWrapper -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.skills.neo_skill_sync import NeoSkillSyncManager -from astrbot.core.tools.computer_tools.util import check_admin_permission -from astrbot.core.tools.registry import builtin_tool - -_SHIPYARD_NEO_TOOL_CONFIG = { - "provider_settings.computer_use_runtime": "sandbox", - "provider_settings.sandbox.booter": "shipyard_neo", -} - - -def _to_jsonable(model_like: Any) -> Any: - if isinstance(model_like, dict): - return model_like - if isinstance(model_like, list): - return [_to_jsonable(i) for i in model_like] - if hasattr(model_like, "model_dump"): - return _to_jsonable(model_like.model_dump()) - return model_like - - -def _to_json_text(data: Any) -> str: - return json.dumps(_to_jsonable(data), ensure_ascii=False, default=str) - - -async def _get_neo_context( - context: ContextWrapper[AstrAgentContext], -) -> tuple[Any, Any]: - booter = await get_booter( - context.context.context, - context.context.event.unified_msg_origin, - ) - client = getattr(booter, "bay_client", None) - sandbox = getattr(booter, "sandbox", None) - if client is None or sandbox is None: - raise RuntimeError( - "Current sandbox booter does not support Neo skill lifecycle APIs. " - "Please switch to shipyard_neo." - ) - return client, sandbox - - -@dataclass -class NeoSkillToolBase(FunctionTool): - error_prefix: str = "Error" - - async def _run( - self, - context: ContextWrapper[AstrAgentContext], - neo_call: Callable[[Any, Any], Awaitable[Any]], - error_action: str, - ) -> ToolExecResult: - if err := check_admin_permission(context, "Using skill lifecycle tools"): - return err - try: - client, sandbox = await _get_neo_context(context) - result = await neo_call(client, sandbox) - return _to_json_text(result) - except Exception as e: - return f"{self.error_prefix} {error_action}: {str(e)}" - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class GetExecutionHistoryTool(NeoSkillToolBase): - name: str = "astrbot_get_execution_history" - description: str = "Get execution history from current sandbox." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "exec_type": {"type": "string"}, - "success_only": {"type": "boolean", "default": False}, - "limit": {"type": "integer", "default": 100}, - "offset": {"type": "integer", "default": 0}, - "tags": {"type": "string"}, - "has_notes": {"type": "boolean", "default": False}, - "has_description": {"type": "boolean", "default": False}, - }, - "required": [], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - exec_type: str | None = None, - success_only: bool = False, - limit: int = 100, - offset: int = 0, - tags: str | None = None, - has_notes: bool = False, - has_description: bool = False, - ) -> ToolExecResult: - return await self._run( - context, - lambda _client, sandbox: sandbox.get_execution_history( - exec_type=exec_type, - success_only=success_only, - limit=limit, - offset=offset, - tags=tags, - has_notes=has_notes, - has_description=has_description, - ), - error_action="getting execution history", - ) - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class AnnotateExecutionTool(NeoSkillToolBase): - name: str = "astrbot_annotate_execution" - description: str = "Annotate one execution history record." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "execution_id": {"type": "string"}, - "description": {"type": "string"}, - "tags": {"type": "string"}, - "notes": {"type": "string"}, - }, - "required": ["execution_id"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - execution_id: str, - description: str | None = None, - tags: str | None = None, - notes: str | None = None, - ) -> ToolExecResult: - return await self._run( - context, - lambda _client, sandbox: sandbox.annotate_execution( - execution_id=execution_id, - description=description, - tags=tags, - notes=notes, - ), - error_action="annotating execution", - ) - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class CreateSkillPayloadTool(NeoSkillToolBase): - name: str = "astrbot_create_skill_payload" - description: str = ( - "Step 1/3 for Neo skill authoring: create immutable payload content and return payload_ref. " - "Use this to store skill_markdown and structured metadata; do NOT write local skill folders directly." - ) - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "payload": { - "anyOf": [ - {"type": "object"}, - {"type": "array", "items": {"type": "object"}}, - ], - "description": ( - "Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. " - "This only stores content and returns payload_ref; it does not create a candidate or release." - ), - }, - "kind": { - "type": "string", - "description": "Payload kind.", - "default": "astrbot_skill_v1", - }, - }, - "required": ["payload"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - payload: dict[str, Any] | list[Any], - kind: str = "astrbot_skill_v1", - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.create_payload( - payload=payload, - kind=kind, - ), - error_action="creating skill payload", - ) - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class GetSkillPayloadTool(NeoSkillToolBase): - name: str = "astrbot_get_skill_payload" - description: str = "Get one skill payload by payload_ref." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "payload_ref": {"type": "string"}, - }, - "required": ["payload_ref"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - payload_ref: str, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.get_payload(payload_ref), - error_action="getting skill payload", - ) - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class CreateSkillCandidateTool(NeoSkillToolBase): - name: str = "astrbot_create_skill_candidate" - description: str = ( - "Step 2/3 for Neo skill authoring: create a candidate by binding execution evidence " - "(source_execution_ids) with skill identity (skill_key) and optional payload_ref." - ) - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "skill_key": { - "type": "string", - "description": "Stable logical identifier, e.g. image-collage-9grid.", - }, - "source_execution_ids": { - "type": "array", - "items": {"type": "string"}, - "description": "Execution evidence IDs captured from sandbox history.", - }, - "scenario_key": { - "type": "string", - "description": "Optional scenario namespace for grouping candidates.", - }, - "payload_ref": { - "type": "string", - "description": "Optional payload reference created by astrbot_create_skill_payload.", - }, - }, - "required": ["skill_key", "source_execution_ids"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - skill_key: str, - source_execution_ids: list[str], - scenario_key: str | None = None, - payload_ref: str | None = None, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.create_candidate( - skill_key=skill_key, - source_execution_ids=source_execution_ids, - scenario_key=scenario_key, - payload_ref=payload_ref, - ), - error_action="creating skill candidate", - ) - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class ListSkillCandidatesTool(NeoSkillToolBase): - name: str = "astrbot_list_skill_candidates" - description: str = "List skill candidates." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "status": {"type": "string"}, - "skill_key": {"type": "string"}, - "limit": {"type": "integer", "default": 100}, - "offset": {"type": "integer", "default": 0}, - }, - "required": [], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - status: str | None = None, - skill_key: str | None = None, - limit: int = 100, - offset: int = 0, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.list_candidates( - status=status, - skill_key=skill_key, - limit=limit, - offset=offset, - ), - error_action="listing skill candidates", - ) - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class EvaluateSkillCandidateTool(NeoSkillToolBase): - name: str = "astrbot_evaluate_skill_candidate" - description: str = "Evaluate a skill candidate." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "candidate_id": {"type": "string"}, - "passed": {"type": "boolean"}, - "score": {"type": "number"}, - "benchmark_id": {"type": "string"}, - "report": {"type": "string"}, - }, - "required": ["candidate_id", "passed"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - candidate_id: str, - passed: bool, - score: float | None = None, - benchmark_id: str | None = None, - report: str | None = None, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.evaluate_candidate( - candidate_id, - passed=passed, - score=score, - benchmark_id=benchmark_id, - report=report, - ), - error_action="evaluating skill candidate", - ) - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class PromoteSkillCandidateTool(NeoSkillToolBase): - name: str = "astrbot_promote_skill_candidate" - description: str = ( - "Step 3/3 for Neo skill authoring: promote candidate to canary/stable release. " - "If stage=stable and sync_to_local=true, payload.skill_markdown is synced to local SKILL.md automatically." - ) - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "candidate_id": {"type": "string"}, - "stage": { - "type": "string", - "description": "Release stage: canary/stable", - "default": "canary", - }, - "sync_to_local": { - "type": "boolean", - "description": ( - "Only used with stage=stable. true means sync payload.skill_markdown to local SKILL.md; " - "false means release remains Neo-side only." - ), - "default": True, - }, - }, - "required": ["candidate_id"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - candidate_id: str, - stage: str = "canary", - sync_to_local: bool = True, - ) -> ToolExecResult: - if err := check_admin_permission(context, "Using skill lifecycle tools"): - return err - if stage not in {"canary", "stable"}: - return "Error promoting skill candidate: stage must be canary or stable." - - try: - client, _sandbox = await _get_neo_context(context) - sync_mgr = NeoSkillSyncManager() - result = await sync_mgr.promote_with_optional_sync( - client, - candidate_id=candidate_id, - stage=stage, - sync_to_local=sync_to_local, - ) - if result.get("sync_error"): - rollback_json = result.get("rollback") - if rollback_json: - return ( - "Error promoting skill candidate: stable release synced failed; " - f"auto rollback succeeded. sync_error={result['sync_error']}; " - f"rollback={_to_json_text(rollback_json)}" - ) - return _to_json_text( - { - "release": result.get("release"), - "sync": result.get("sync"), - "rollback": result.get("rollback"), - } - ) - except Exception as e: - return f"Error promoting skill candidate: {str(e)}" - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class ListSkillReleasesTool(NeoSkillToolBase): - name: str = "astrbot_list_skill_releases" - description: str = "List skill releases." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "skill_key": {"type": "string"}, - "active_only": {"type": "boolean", "default": False}, - "stage": {"type": "string"}, - "limit": {"type": "integer", "default": 100}, - "offset": {"type": "integer", "default": 0}, - }, - "required": [], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - skill_key: str | None = None, - active_only: bool = False, - stage: str | None = None, - limit: int = 100, - offset: int = 0, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.list_releases( - skill_key=skill_key, - active_only=active_only, - stage=stage, - limit=limit, - offset=offset, - ), - error_action="listing skill releases", - ) - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class RollbackSkillReleaseTool(NeoSkillToolBase): - name: str = "astrbot_rollback_skill_release" - description: str = "Rollback one skill release." - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "release_id": {"type": "string"}, - }, - "required": ["release_id"], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - release_id: str, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: client.skills.rollback_release(release_id), - error_action="rolling back skill release", - ) - - -@builtin_tool(config=_SHIPYARD_NEO_TOOL_CONFIG) -@dataclass -class SyncSkillReleaseTool(NeoSkillToolBase): - name: str = "astrbot_sync_skill_release" - description: str = ( - "Sync stable Neo release payload to local SKILL.md and update mapping metadata." - ) - parameters: dict = field( - default_factory=lambda: { - "type": "object", - "properties": { - "release_id": {"type": "string"}, - "skill_key": {"type": "string"}, - "require_stable": {"type": "boolean", "default": True}, - }, - "required": [], - } - ) - - async def call( - self, - context: ContextWrapper[AstrAgentContext], - release_id: str | None = None, - skill_key: str | None = None, - require_stable: bool = True, - ) -> ToolExecResult: - return await self._run( - context, - lambda client, _sandbox: _sync_release_to_dict( - client, - release_id=release_id, - skill_key=skill_key, - require_stable=require_stable, - ), - error_action="syncing skill release", - ) - - -async def _sync_release_to_dict( - client: Any, - *, - release_id: str | None, - skill_key: str | None, - require_stable: bool, -) -> dict[str, str]: - sync_mgr = NeoSkillSyncManager() - result = await sync_mgr.sync_release( - client, - release_id=release_id, - skill_key=skill_key, - require_stable=require_stable, - ) - return sync_mgr.sync_result_to_dict(result) diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py index fbbd0c7a08..512e2cf1a3 100644 --- a/astrbot/dashboard/routes/__init__.py +++ b/astrbot/dashboard/routes/__init__.py @@ -14,6 +14,7 @@ from .persona import PersonaRoute from .platform import PlatformRoute from .plugin import PluginRoute +from .sandbox import SandboxRoute from .session_management import SessionManagementRoute from .skills import SkillsRoute from .stat import StatRoute @@ -40,6 +41,7 @@ "PlatformRoute", "PluginRoute", "SessionManagementRoute", + "SandboxRoute", "StatRoute", "StaticFileRoute", "SubAgentRoute", diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 9ec24d254d..2cef1b1fb4 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -244,61 +244,6 @@ def _log_computer_config_changes(old_config: dict, new_config: dict) -> None: ) -async def _validate_neo_connectivity( - post_config: dict, -) -> str | None: - """Check if Bay is reachable when Shipyard Neo sandbox is configured. - - Returns a warning message string if Bay isn't reachable, or None if - everything looks fine (or Neo isn't configured). - """ - ps = post_config.get("provider_settings", {}) - runtime = ps.get("computer_use_runtime", "none") - sandbox = ps.get("sandbox", {}) - booter = sandbox.get("booter", "") - - # Only check when sandbox mode + shipyard_neo is selected - if runtime != "sandbox" or booter != "shipyard_neo": - return None - - endpoint = sandbox.get("shipyard_neo_endpoint", "").rstrip("/") - if not endpoint: - return "⚠️ Shipyard Neo endpoint 未设置" - - access_token = sandbox.get("shipyard_neo_access_token", "") - if not access_token: - # Try auto-discovery - from astrbot.core.computer.computer_client import _discover_bay_credentials - - access_token = _discover_bay_credentials(endpoint) - - if not access_token: - return ( - "⚠️ 未找到 Bay API Key。请填写访问令牌," - "或确保 Bay 的 credentials.json 可被自动发现。" - ) - - # Connectivity check - import aiohttp - - health_url = f"{endpoint}/health" - try: - async with aiohttp.ClientSession() as session: - async with session.get( - health_url, - timeout=aiohttp.ClientTimeout(total=5), - ) as resp: - if resp.status != 200: - return ( - f"⚠️ Bay 健康检查失败 (HTTP {resp.status})," - f"请确认 Bay 正在运行: {endpoint}" - ) - except Exception: - return f"⚠️ 无法连接 Bay ({endpoint}),请确认 Bay 已启动。" - - return None - - def save_config( post_config: dict, config: AstrBotConfig, is_core: bool = False ) -> None: @@ -1024,10 +969,6 @@ async def post_astrbot_configs(self): await self._save_astrbot_configs(config, conf_id) await self.core_lifecycle.reload_pipeline_scheduler(conf_id) - # Non-blocking Bay connectivity check - warning = await _validate_neo_connectivity(config) - if warning: - return Response().ok(None, f"保存成功。{warning}").__dict__ return Response().ok(None, "保存成功~").__dict__ except Exception as e: logger.error(traceback.format_exc()) diff --git a/astrbot/dashboard/routes/sandbox.py b/astrbot/dashboard/routes/sandbox.py new file mode 100644 index 0000000000..41ab7ef61e --- /dev/null +++ b/astrbot/dashboard/routes/sandbox.py @@ -0,0 +1,165 @@ +import traceback + +from quart import jsonify, request + +from astrbot.core import logger +from astrbot.core.computer import computer_client +from astrbot.core.core_lifecycle import AstrBotCoreLifecycle + +from .route import Response, Route, RouteContext + + +class SandboxRoute(Route): + def __init__( + self, + context: RouteContext, + core_lifecycle: AstrBotCoreLifecycle, + ) -> None: + super().__init__(context) + self.core_lifecycle = core_lifecycle + self.routes = [ + ("/sandbox/providers", ("GET", self.list_providers)), + ("/sandbox", ("GET", self.list_sandboxes)), + ("/sandbox/current", ("GET", self.get_current_sandbox)), + ("/sandbox/current", ("DELETE", self.release_current_sandbox)), + ("/sandbox", ("POST", self.create_sandbox)), + ("/sandbox//switch", ("POST", self.switch_sandbox)), + ("/sandbox//takeover", ("POST", self.takeover_sandbox)), + ("/sandbox/", ("PATCH", self.update_sandbox)), + ("/sandbox/", ("DELETE", self.destroy_sandbox)), + ] + self.register_routes() + + def _session_id(self) -> str: + return request.args.get("session_id") or "dashboard" + + async def list_providers(self): + try: + return jsonify( + Response() + .ok(data={"providers": computer_client.list_sandbox_providers()}) + .__dict__ + ) + except Exception as e: + logger.error(traceback.format_exc()) + return jsonify( + Response().error(f"Failed to list sandbox providers: {e!s}").__dict__ + ) + + async def list_sandboxes(self): + try: + return jsonify( + Response() + .ok( + data={"sandboxes": computer_client.sandbox_manager.list_sandboxes()} + ) + .__dict__ + ) + except Exception as e: + logger.error(traceback.format_exc()) + return jsonify( + Response().error(f"Failed to list sandboxes: {e!s}").__dict__ + ) + + async def get_current_sandbox(self): + try: + return jsonify( + Response() + .ok( + data=computer_client.sandbox_manager.get_current_sandbox( + self._session_id() + ) + ) + .__dict__ + ) + except Exception as e: + logger.error(traceback.format_exc()) + return jsonify( + Response().error(f"Failed to get current sandbox: {e!s}").__dict__ + ) + + async def create_sandbox(self): + try: + data = await request.get_json(silent=True) or {} + provider_id = str(data.get("provider_id") or "").strip() + if not provider_id: + return jsonify(Response().error("provider_id is required").__dict__) + sandbox = await computer_client.sandbox_manager.create_sandbox( + self.core_lifecycle.star_context, + self._session_id(), + provider_id, + sandbox_name=data.get("sandbox_name"), + ) + return jsonify(Response().ok(data={"sandbox": sandbox}).__dict__) + except Exception as e: + logger.error(traceback.format_exc()) + return jsonify( + Response().error(f"Failed to create sandbox: {e!s}").__dict__ + ) + + async def switch_sandbox(self, sandbox_id: str): + try: + sandbox = ( + await computer_client.sandbox_manager.switch_current_sandbox_checked( + self._session_id(), sandbox_id + ) + ) + return jsonify(Response().ok(data={"sandbox": sandbox}).__dict__) + except Exception as e: + logger.error(traceback.format_exc()) + return jsonify( + Response().error(f"Failed to switch sandbox: {e!s}").__dict__ + ) + + async def release_current_sandbox(self): + try: + sandbox = computer_client.sandbox_manager.release_current_sandbox( + self._session_id(), request.args.get("sandbox_id") + ) + return jsonify(Response().ok(data={"sandbox": sandbox}).__dict__) + except Exception as e: + logger.error(traceback.format_exc()) + return jsonify( + Response().error(f"Failed to release sandbox: {e!s}").__dict__ + ) + + async def takeover_sandbox(self, sandbox_id: str): + try: + sandbox = computer_client.sandbox_manager.takeover_sandbox( + self._session_id(), sandbox_id + ) + return jsonify(Response().ok(data={"sandbox": sandbox}).__dict__) + except Exception as e: + logger.error(traceback.format_exc()) + return jsonify( + Response().error(f"Failed to takeover sandbox: {e!s}").__dict__ + ) + + async def update_sandbox(self, sandbox_id: str): + try: + data = await request.get_json(silent=True) or {} + sandbox = computer_client.sandbox_manager.update_sandbox_config( + sandbox_id, + sandbox_name=data.get("sandbox_name"), + idle_timeout=data.get("idle_timeout"), + expires_at=data.get("expires_at"), + retention_policy=data.get("retention_policy", "temporary"), + ) + return jsonify(Response().ok(data={"sandbox": sandbox}).__dict__) + except Exception as e: + logger.error(traceback.format_exc()) + return jsonify( + Response().error(f"Failed to update sandbox: {e!s}").__dict__ + ) + + async def destroy_sandbox(self, sandbox_id: str): + try: + sandbox = await computer_client.sandbox_manager.destroy_sandbox( + self._session_id(), sandbox_id + ) + return jsonify(Response().ok(data={"sandbox": sandbox}).__dict__) + except Exception as e: + logger.error(traceback.format_exc()) + return jsonify( + Response().error(f"Failed to destroy sandbox: {e!s}").__dict__ + ) diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py index c86598212e..9a395a2255 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -2,44 +2,17 @@ import re import shutil import traceback -from collections.abc import Awaitable, Callable from pathlib import Path -from typing import Any from quart import request, send_file from astrbot.core import DEMO_MODE, logger -from astrbot.core.computer.computer_client import ( - _discover_bay_credentials, - sync_skills_to_active_sandboxes, -) -from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager +from astrbot.core.computer.computer_client import sync_skills_to_active_sandboxes from astrbot.core.skills.skill_manager import SkillManager from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from .route import Response, Route, RouteContext - -def _to_jsonable(value: Any) -> Any: - if isinstance(value, dict): - return {k: _to_jsonable(v) for k, v in value.items()} - if isinstance(value, list): - return [_to_jsonable(v) for v in value] - if hasattr(value, "model_dump"): - return _to_jsonable(value.model_dump()) - return value - - -def _to_bool(value: Any, default: bool = False) -> bool: - if value is None: - return default - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "y", "on"} - return bool(value) - - _SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$") _SKILL_FILE_MAX_BYTES = 512 * 1024 _EDITABLE_SKILL_FILE_SUFFIXES = { @@ -87,15 +60,6 @@ def __init__(self, context: RouteContext, core_lifecycle) -> None: ], "/skills/update": ("POST", self.update_skill), "/skills/delete": ("POST", self.delete_skill), - "/skills/neo/candidates": ("GET", self.get_neo_candidates), - "/skills/neo/releases": ("GET", self.get_neo_releases), - "/skills/neo/payload": ("GET", self.get_neo_payload), - "/skills/neo/evaluate": ("POST", self.evaluate_neo_candidate), - "/skills/neo/promote": ("POST", self.promote_neo_candidate), - "/skills/neo/rollback": ("POST", self.rollback_neo_release), - "/skills/neo/sync": ("POST", self.sync_neo_release), - "/skills/neo/delete-candidate": ("POST", self.delete_neo_candidate), - "/skills/neo/delete-release": ("POST", self.delete_neo_release), } self.register_routes() @@ -179,58 +143,6 @@ def _serialize_skill_file_entry( ), } - def _get_neo_client_config(self) -> tuple[str, str]: - provider_settings = self.core_lifecycle.astrbot_config.get( - "provider_settings", - {}, - ) - sandbox = provider_settings.get("sandbox", {}) - endpoint = sandbox.get("shipyard_neo_endpoint", "") - access_token = sandbox.get("shipyard_neo_access_token", "") - - # Auto-discover token from Bay's credentials.json if not configured - if not access_token and endpoint: - access_token = _discover_bay_credentials(endpoint) - - if not endpoint or not access_token: - raise ValueError( - "Shipyard Neo endpoint or access token not configured. " - "Set them in Dashboard or ensure Bay's credentials.json is accessible." - ) - return endpoint, access_token - - async def _delete_neo_release( - self, client: Any, release_id: str, reason: str | None - ): - return await client.skills.delete_release(release_id, reason=reason) - - async def _delete_neo_candidate( - self, client: Any, candidate_id: str, reason: str | None - ): - return await client.skills.delete_candidate(candidate_id, reason=reason) - - async def _with_neo_client( - self, - operation: Callable[[Any], Awaitable[dict]], - ) -> dict: - try: - endpoint, access_token = self._get_neo_client_config() - - from shipyard_neo import BayClient - - async with BayClient( - endpoint_url=endpoint, - access_token=access_token, - ) as client: - return await operation(client) - except ValueError as e: - # Config not ready — expected when Neo isn't set up yet - logger.debug("[Neo] %s", e) - return Response().error(str(e)).__dict__ - except Exception as e: - logger.error(traceback.format_exc()) - return Response().error(str(e)).__dict__ - async def get_skills(self): try: provider_settings = self.core_lifecycle.astrbot_config.get( @@ -707,254 +619,3 @@ async def delete_skill(self): except Exception as e: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ - - async def get_neo_candidates(self): - logger.info("[Neo] GET /skills/neo/candidates requested.") - status = request.args.get("status") - skill_key = request.args.get("skill_key") - limit = int(request.args.get("limit", 100)) - offset = int(request.args.get("offset", 0)) - - async def _do(client): - candidates = await client.skills.list_candidates( - status=status, - skill_key=skill_key, - limit=limit, - offset=offset, - ) - result = _to_jsonable(candidates) - total = result.get("total", "?") if isinstance(result, dict) else "?" - logger.info(f"[Neo] Candidates fetched: total={total}") - return Response().ok(result).__dict__ - - return await self._with_neo_client(_do) - - async def get_neo_releases(self): - logger.info("[Neo] GET /skills/neo/releases requested.") - skill_key = request.args.get("skill_key") - stage = request.args.get("stage") - active_only = _to_bool(request.args.get("active_only"), False) - limit = int(request.args.get("limit", 100)) - offset = int(request.args.get("offset", 0)) - - async def _do(client): - releases = await client.skills.list_releases( - skill_key=skill_key, - active_only=active_only, - stage=stage, - limit=limit, - offset=offset, - ) - result = _to_jsonable(releases) - total = result.get("total", "?") if isinstance(result, dict) else "?" - logger.info(f"[Neo] Releases fetched: total={total}") - return Response().ok(result).__dict__ - - return await self._with_neo_client(_do) - - async def get_neo_payload(self): - logger.info("[Neo] GET /skills/neo/payload requested.") - payload_ref = request.args.get("payload_ref", "") - if not payload_ref: - return Response().error("Missing payload_ref").__dict__ - - async def _do(client): - payload = await client.skills.get_payload(payload_ref) - logger.info(f"[Neo] Payload fetched: ref={payload_ref}") - return Response().ok(_to_jsonable(payload)).__dict__ - - return await self._with_neo_client(_do) - - async def evaluate_neo_candidate(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/evaluate requested.") - data = await request.get_json() - candidate_id = data.get("candidate_id") - passed_value = data.get("passed") - if not candidate_id or passed_value is None: - return Response().error("Missing candidate_id or passed").__dict__ - passed = _to_bool(passed_value, False) - - async def _do(client): - result = await client.skills.evaluate_candidate( - candidate_id, - passed=passed, - score=data.get("score"), - benchmark_id=data.get("benchmark_id"), - report=data.get("report"), - ) - logger.info( - f"[Neo] Candidate evaluated: id={candidate_id}, passed={passed}" - ) - return Response().ok(_to_jsonable(result)).__dict__ - - return await self._with_neo_client(_do) - - async def promote_neo_candidate(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/promote requested.") - data = await request.get_json() - candidate_id = data.get("candidate_id") - stage = data.get("stage", "canary") - sync_to_local = _to_bool(data.get("sync_to_local"), True) - if not candidate_id: - return Response().error("Missing candidate_id").__dict__ - if stage not in {"canary", "stable"}: - return Response().error("Invalid stage, must be canary/stable").__dict__ - - async def _do(client): - sync_mgr = NeoSkillSyncManager() - result = await sync_mgr.promote_with_optional_sync( - client, - candidate_id=candidate_id, - stage=stage, - sync_to_local=sync_to_local, - ) - release_json = result.get("release") - logger.info(f"[Neo] Candidate promoted: id={candidate_id}, stage={stage}") - - sync_json = result.get("sync") - did_sync_to_local = bool(sync_json) - if did_sync_to_local: - logger.info( - f"[Neo] Stable release synced to local: skill={sync_json.get('local_skill_name', '')}" - ) - - if result.get("sync_error"): - resp = Response().error( - "Stable promote synced failed and has been rolled back. " - f"sync_error={result['sync_error']}" - ) - resp.data = { - "release": release_json, - "rollback": result.get("rollback"), - } - return resp.__dict__ - - # Try to push latest local skills to all active sandboxes. - if not did_sync_to_local: - try: - await sync_skills_to_active_sandboxes() - except Exception: - logger.warning("Failed to sync skills to active sandboxes.") - - return Response().ok({"release": release_json, "sync": sync_json}).__dict__ - - return await self._with_neo_client(_do) - - async def rollback_neo_release(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/rollback requested.") - data = await request.get_json() - release_id = data.get("release_id") - if not release_id: - return Response().error("Missing release_id").__dict__ - - async def _do(client): - result = await client.skills.rollback_release(release_id) - logger.info(f"[Neo] Release rolled back: id={release_id}") - return Response().ok(_to_jsonable(result)).__dict__ - - return await self._with_neo_client(_do) - - async def sync_neo_release(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/sync requested.") - data = await request.get_json() - release_id = data.get("release_id") - skill_key = data.get("skill_key") - require_stable = _to_bool(data.get("require_stable"), True) - if not release_id and not skill_key: - return Response().error("Missing release_id or skill_key").__dict__ - - async def _do(client): - sync_mgr = NeoSkillSyncManager() - result = await sync_mgr.sync_release( - client, - release_id=release_id, - skill_key=skill_key, - require_stable=require_stable, - ) - logger.info( - f"[Neo] Release synced to local: skill={result.local_skill_name}, " - f"release_id={result.release_id}" - ) - return ( - Response() - .ok( - { - "skill_key": result.skill_key, - "local_skill_name": result.local_skill_name, - "release_id": result.release_id, - "candidate_id": result.candidate_id, - "payload_ref": result.payload_ref, - "map_path": result.map_path, - "synced_at": result.synced_at, - } - ) - .__dict__ - ) - - return await self._with_neo_client(_do) - - async def delete_neo_candidate(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/delete-candidate requested.") - data = await request.get_json() - candidate_id = data.get("candidate_id") - reason = data.get("reason") - if not candidate_id: - return Response().error("Missing candidate_id").__dict__ - - async def _do(client): - result = await self._delete_neo_candidate(client, candidate_id, reason) - logger.info(f"[Neo] Candidate deleted: id={candidate_id}") - return Response().ok(_to_jsonable(result)).__dict__ - - return await self._with_neo_client(_do) - - async def delete_neo_release(self): - if DEMO_MODE: - return ( - Response() - .error("You are not permitted to do this operation in demo mode") - .__dict__ - ) - logger.info("[Neo] POST /skills/neo/delete-release requested.") - data = await request.get_json() - release_id = data.get("release_id") - reason = data.get("reason") - if not release_id: - return Response().error("Missing release_id").__dict__ - - async def _do(client): - result = await self._delete_neo_release(client, release_id, reason) - logger.info(f"[Neo] Release deleted: id={release_id}") - return Response().ok(_to_jsonable(result)).__dict__ - - return await self._with_neo_client(_do) diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py index d926cb06de..9da600713e 100644 --- a/astrbot/dashboard/server.py +++ b/astrbot/dashboard/server.py @@ -33,6 +33,7 @@ from .routes.live_chat import LiveChatRoute from .routes.platform import PlatformRoute from .routes.route import Response, RouteContext +from .routes.sandbox import SandboxRoute from .routes.session_management import SessionManagementRoute from .routes.subagent import SubAgentRoute from .routes.t2i import T2iRoute @@ -173,6 +174,7 @@ def __init__( db, core_lifecycle, ) + self.sandbox_route = SandboxRoute(self.context, core_lifecycle) self.persona_route = PersonaRoute(self.context, db, core_lifecycle) self.cron_route = CronRoute(self.context, core_lifecycle) self.t2i_route = T2iRoute(self.context, core_lifecycle) diff --git a/tests/test_computer_config.py b/tests/test_computer_config.py index 26f72991c3..e0af81c3aa 100644 --- a/tests/test_computer_config.py +++ b/tests/test_computer_config.py @@ -1,176 +1,12 @@ -"""Tests for _discover_bay_credentials() auto-discovery and _log_computer_config_changes().""" +"""Tests for _log_computer_config_changes().""" from __future__ import annotations -import json -import logging -from pathlib import Path from unittest.mock import patch -import pytest - -from astrbot.core.computer.computer_client import _discover_bay_credentials from astrbot.dashboard.routes.config import _log_computer_config_changes -# ═══════════════════════════════════════════════════════════════ -# _discover_bay_credentials -# ═══════════════════════════════════════════════════════════════ - - -class TestDiscoverBayCredentials: - """Test Bay API key auto-discovery from credentials.json.""" - - def _write_creds( - self, - path: Path, - api_key: str = "sk-bay-abc123", - endpoint: str = "http://127.0.0.1:8114", - ) -> None: - """Helper: write a credentials.json file.""" - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps( - { - "api_key": api_key, - "endpoint": endpoint, - "generated_at": "2026-02-17T00:00:00+00:00", - } - ) - ) - - def test_discover_from_bay_data_dir_env( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """BAY_DATA_DIR env var takes highest priority.""" - data_dir = tmp_path / "bay_data" - cred_file = data_dir / "credentials.json" - self._write_creds(cred_file, api_key="sk-bay-from-env-dir") - monkeypatch.setenv("BAY_DATA_DIR", str(data_dir)) - - result = _discover_bay_credentials("http://127.0.0.1:8114") - assert result == "sk-bay-from-env-dir" - - def test_discover_from_cwd( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Falls back to current working directory.""" - cred_file = tmp_path / "credentials.json" - self._write_creds(cred_file, api_key="sk-bay-from-cwd") - monkeypatch.chdir(tmp_path) - monkeypatch.delenv("BAY_DATA_DIR", raising=False) - - result = _discover_bay_credentials("http://127.0.0.1:8114") - assert result == "sk-bay-from-cwd" - - def test_returns_empty_when_no_credentials_found( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Returns empty string when no credentials.json exists anywhere.""" - monkeypatch.chdir(tmp_path) - monkeypatch.delenv("BAY_DATA_DIR", raising=False) - - result = _discover_bay_credentials("http://127.0.0.1:8114") - assert result == "" - - def test_skips_empty_api_key( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Skips credentials.json when api_key is empty.""" - cred_file = tmp_path / "credentials.json" - self._write_creds(cred_file, api_key="") - monkeypatch.chdir(tmp_path) - monkeypatch.delenv("BAY_DATA_DIR", raising=False) - - result = _discover_bay_credentials("http://127.0.0.1:8114") - assert result == "" - - def test_skips_malformed_json( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Handles malformed JSON gracefully.""" - cred_file = tmp_path / "credentials.json" - cred_file.parent.mkdir(parents=True, exist_ok=True) - cred_file.write_text("not valid json {{{") - monkeypatch.chdir(tmp_path) - monkeypatch.delenv("BAY_DATA_DIR", raising=False) - - result = _discover_bay_credentials("http://127.0.0.1:8114") - assert result == "" - - @patch("astrbot.core.computer.computer_client.logger") - def test_endpoint_mismatch_still_returns_key( - self, mock_logger, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Returns key even if endpoint doesn't match, but logs a warning.""" - data_dir = tmp_path / "bay_data" - cred_file = data_dir / "credentials.json" - self._write_creds( - cred_file, api_key="sk-bay-mismatch", endpoint="http://other-host:9000" - ) - monkeypatch.setenv("BAY_DATA_DIR", str(data_dir)) - - result = _discover_bay_credentials("http://127.0.0.1:8114") - - assert result == "sk-bay-mismatch" - mock_logger.warning.assert_called_once() - warning_msg = mock_logger.warning.call_args[0][0] - assert "endpoint mismatch" in warning_msg - - def test_endpoint_match_no_warning( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """No warning when endpoints match.""" - data_dir = tmp_path / "bay_data" - cred_file = data_dir / "credentials.json" - self._write_creds( - cred_file, api_key="sk-bay-match", endpoint="http://127.0.0.1:8114" - ) - monkeypatch.setenv("BAY_DATA_DIR", str(data_dir)) - - with patch("astrbot.core.computer.computer_client.logger") as mock_logger: - result = _discover_bay_credentials("http://127.0.0.1:8114") - - assert result == "sk-bay-match" - mock_logger.warning.assert_not_called() - - def test_bay_data_dir_priority_over_cwd( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """BAY_DATA_DIR takes priority over cwd.""" - env_dir = tmp_path / "env_dir" - cwd_dir = tmp_path / "cwd_dir" - self._write_creds(env_dir / "credentials.json", api_key="sk-bay-env-wins") - self._write_creds(cwd_dir / "credentials.json", api_key="sk-bay-cwd-loses") - monkeypatch.setenv("BAY_DATA_DIR", str(env_dir)) - monkeypatch.chdir(cwd_dir) - - result = _discover_bay_credentials("http://127.0.0.1:8114") - assert result == "sk-bay-env-wins" - - def test_trailing_slash_normalization( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Trailing slashes on endpoints are normalized before comparison.""" - data_dir = tmp_path / "bay_data" - cred_file = data_dir / "credentials.json" - self._write_creds( - cred_file, api_key="sk-bay-slash", endpoint="http://127.0.0.1:8114/" - ) - monkeypatch.setenv("BAY_DATA_DIR", str(data_dir)) - - with patch("astrbot.core.computer.computer_client.logger") as mock_logger: - result = _discover_bay_credentials("http://127.0.0.1:8114") - - assert result == "sk-bay-slash" - mock_logger.warning.assert_not_called() - - -# ═══════════════════════════════════════════════════════════════ -# _log_computer_config_changes -# ═══════════════════════════════════════════════════════════════ - - class TestLogComputerConfigChanges: """Test config change detection and logging.""" @@ -184,7 +20,10 @@ def test_logs_runtime_change(self, mock_logger) -> None: mock_logger.info.assert_called() call_args = [str(c) for c in mock_logger.info.call_args_list] - assert any("computer_use_runtime" in c and "none" in c and "sandbox" in c for c in call_args) + assert any( + "computer_use_runtime" in c and "none" in c and "sandbox" in c + for c in call_args + ) @patch("astrbot.dashboard.routes.config.logger") def test_no_log_when_runtime_unchanged(self, mock_logger) -> None: @@ -199,8 +38,8 @@ def test_no_log_when_runtime_unchanged(self, mock_logger) -> None: @patch("astrbot.dashboard.routes.config.logger") def test_logs_sandbox_key_change(self, mock_logger) -> None: """Detects sandbox sub-key change.""" - old = {"provider_settings": {"sandbox": {"booter": "shipyard"}}} - new = {"provider_settings": {"sandbox": {"booter": "shipyard_neo"}}} + old = {"provider_settings": {"sandbox": {"booter": "provider_a"}}} + new = {"provider_settings": {"sandbox": {"booter": "provider_b"}}} _log_computer_config_changes(old, new) @@ -210,20 +49,20 @@ def test_logs_sandbox_key_change(self, mock_logger) -> None: for call in mock_logger.info.call_args_list: args = call[0] # positional args: (fmt, key, old_val, new_val) if len(args) >= 4 and args[1] == "booter": - assert args[2] == "shipyard" - assert args[3] == "shipyard_neo" + assert args[2] == "provider_a" + assert args[3] == "provider_b" found = True break - assert found, f"Expected booter change in log calls: {mock_logger.info.call_args_list}" + assert found, ( + f"Expected booter change in log calls: {mock_logger.info.call_args_list}" + ) @patch("astrbot.dashboard.routes.config.logger") def test_masks_token_values(self, mock_logger) -> None: """Token/secret values are masked in log output.""" - old = {"provider_settings": {"sandbox": {"shipyard_neo_access_token": ""}}} + old = {"provider_settings": {"sandbox": {"sandbox_access_token": ""}}} new = { - "provider_settings": { - "sandbox": {"shipyard_neo_access_token": "sk-bay-secret123"} - } + "provider_settings": {"sandbox": {"sandbox_access_token": "sk-secret123"}} } _log_computer_config_changes(old, new) @@ -231,17 +70,13 @@ def test_masks_token_values(self, mock_logger) -> None: mock_logger.info.assert_called() call_args_str = str(mock_logger.info.call_args_list) assert "***" in call_args_str - assert "sk-bay-secret123" not in call_args_str + assert "sk-secret123" not in call_args_str @patch("astrbot.dashboard.routes.config.logger") def test_masks_empty_token_as_empty_label(self, mock_logger) -> None: """Empty token values show as '(empty)' not '***'.""" - old = { - "provider_settings": { - "sandbox": {"shipyard_neo_access_token": "old-key"} - } - } - new = {"provider_settings": {"sandbox": {"shipyard_neo_access_token": ""}}} + old = {"provider_settings": {"sandbox": {"sandbox_access_token": "old-key"}}} + new = {"provider_settings": {"sandbox": {"sandbox_access_token": ""}}} _log_computer_config_changes(old, new) @@ -256,8 +91,8 @@ def test_no_log_when_nothing_changed(self, mock_logger) -> None: "provider_settings": { "computer_use_runtime": "sandbox", "sandbox": { - "booter": "shipyard_neo", - "shipyard_neo_endpoint": "http://127.0.0.1:8114", + "booter": "provider_a", + "sandbox_endpoint": "http://127.0.0.1:8114", }, } } @@ -283,7 +118,7 @@ def test_detects_new_sandbox_key(self, mock_logger) -> None: old = {"provider_settings": {"sandbox": {}}} new = { "provider_settings": { - "sandbox": {"shipyard_neo_endpoint": "http://127.0.0.1:8114"} + "sandbox": {"sandbox_endpoint": "http://127.0.0.1:8114"} } } @@ -291,14 +126,14 @@ def test_detects_new_sandbox_key(self, mock_logger) -> None: mock_logger.info.assert_called() call_args_str = str(mock_logger.info.call_args_list) - assert "shipyard_neo_endpoint" in call_args_str + assert "sandbox_endpoint" in call_args_str @patch("astrbot.dashboard.routes.config.logger") def test_detects_removed_sandbox_key(self, mock_logger) -> None: """Detects a removed sandbox key.""" old = { "provider_settings": { - "sandbox": {"shipyard_neo_endpoint": "http://127.0.0.1:8114"} + "sandbox": {"sandbox_endpoint": "http://127.0.0.1:8114"} } } new = {"provider_settings": {"sandbox": {}}} @@ -307,15 +142,13 @@ def test_detects_removed_sandbox_key(self, mock_logger) -> None: mock_logger.info.assert_called() call_args_str = str(mock_logger.info.call_args_list) - assert "shipyard_neo_endpoint" in call_args_str + assert "sandbox_endpoint" in call_args_str @patch("astrbot.dashboard.routes.config.logger") def test_secret_key_masked(self, mock_logger) -> None: """Any key containing 'secret' is also masked.""" old = {"provider_settings": {"sandbox": {"my_secret_key": ""}}} - new = { - "provider_settings": {"sandbox": {"my_secret_key": "very-secret-value"}} - } + new = {"provider_settings": {"sandbox": {"my_secret_key": "very-secret-value"}}} _log_computer_config_changes(old, new) diff --git a/tests/test_computer_tool_permissions.py b/tests/test_computer_tool_permissions.py deleted file mode 100644 index 07f7983da3..0000000000 --- a/tests/test_computer_tool_permissions.py +++ /dev/null @@ -1,100 +0,0 @@ -import json -from types import SimpleNamespace - -import pytest - -from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.tools.computer_tools.shipyard_neo.browser import BrowserExecTool -from astrbot.core.tools.computer_tools.shipyard_neo.neo_skills import ( - GetExecutionHistoryTool, -) - - -class _FakeBrowser: - async def exec(self, **kwargs): - return { - "ok": True, - "cmd": kwargs["cmd"], - } - - -class _FakeSandbox: - async def get_execution_history(self, **kwargs): - return { - "items": [], - "limit": kwargs["limit"], - } - - -def _make_run_context(require_admin: bool, role: str = "member") -> ContextWrapper: - config_holder = SimpleNamespace( - get_config=lambda umo: { # noqa: ARG005 - "provider_settings": { - "computer_use_require_admin": require_admin, - } - } - ) - event = SimpleNamespace( - role=role, - unified_msg_origin="qq_official:friend:user-1", - get_sender_id=lambda: "user-1", - ) - astr_ctx = SimpleNamespace(context=config_holder, event=event) - return ContextWrapper(context=astr_ctx) - - -@pytest.mark.asyncio -async def test_browser_tool_allows_non_admin_when_admin_requirement_disabled( - monkeypatch, -): - async def _fake_get_booter(_ctx, _session_id): - return SimpleNamespace(browser=_FakeBrowser()) - - monkeypatch.setattr( - "astrbot.core.tools.computer_tools.shipyard_neo.browser.get_booter", - _fake_get_booter, - ) - - result = await BrowserExecTool().call( - _make_run_context(require_admin=False), - cmd="open https://example.com", - ) - - assert json.loads(result)["ok"] is True - - -@pytest.mark.asyncio -async def test_neo_skill_tool_allows_non_admin_when_admin_requirement_disabled( - monkeypatch, -): - async def _fake_get_booter(_ctx, _session_id): - return SimpleNamespace( - bay_client=object(), - sandbox=_FakeSandbox(), - ) - - monkeypatch.setattr( - "astrbot.core.tools.computer_tools.shipyard_neo.neo_skills.get_booter", - _fake_get_booter, - ) - - result = await GetExecutionHistoryTool().call( - _make_run_context(require_admin=False), - limit=5, - ) - - payload = json.loads(result) - assert payload["items"] == [] - assert payload["limit"] == 5 - - -@pytest.mark.asyncio -async def test_browser_tool_still_denies_non_admin_when_admin_requirement_enabled(): - result = await BrowserExecTool().call( - _make_run_context(require_admin=True), - cmd="open https://example.com", - ) - - assert "Permission denied" in result - assert "Using browser tools is only allowed for admin users" in result - assert "User's ID is: user-1" in result diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 016bd58aa3..427da5c395 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -4,7 +4,6 @@ import os import re import shutil -import sys import uuid import zipfile from datetime import datetime @@ -32,6 +31,31 @@ create_mock_updater_update, ) + +class FakeSandboxProvider: + provider_id = "dashboard-generic" + capabilities = {"shell", "filesystem"} + tool_names = {"dashboard_generic_tool"} + + def build_create_config(self, context, session_id): + return {} + + def build_connect_info(self, sandbox_name, config): + return {"name": sandbox_name} + + def update_connect_info(self, record, *, sandbox_name): + return {"name": sandbox_name} + + def get_idle_timeout(self, context, session_id): + return 0 + + async def create_booter(self, context, session_id, sandbox_id, config): + return SimpleNamespace(available=lambda: True, shutdown=lambda: None) + + async def destroy_booter(self, booter, record): + return None + + PLUGIN_PAGE_DEMO_NAME = "astrbot_plugin_page_demo" PLUGIN_PAGE_DEMO_PAGE_NAME = "bridge-demo" @@ -218,6 +242,79 @@ async def test_auth_login( assert "Secure" not in jwt_cookie_header +@pytest.mark.asyncio +async def test_sandbox_dashboard_lists_generic_providers( + app: Quart, + authenticated_header: dict, + monkeypatch: pytest.MonkeyPatch, +): + from astrbot.core.computer import computer_client + from astrbot.core.computer.sandbox_manager import SandboxManager + from astrbot.core.computer.sandbox_registry import SandboxRegistry + + provider = FakeSandboxProvider() + manager = SandboxManager(registry=SandboxRegistry(), providers={}) + monkeypatch.setattr(computer_client, "sandbox_manager", manager) + monkeypatch.setattr(computer_client, "sandbox_registry", manager.registry) + computer_client.register_sandbox_provider(provider) + + test_client = app.test_client() + response = await test_client.get( + "/api/sandbox/providers", headers=authenticated_header + ) + data = await response.get_json() + + assert response.status_code == 200 + assert data["status"] == "ok" + assert data["data"]["providers"] == [ + { + "provider_id": "dashboard-generic", + "capabilities": ["filesystem", "shell"], + "tool_names": ["dashboard_generic_tool"], + } + ] + + +@pytest.mark.asyncio +async def test_sandbox_dashboard_lists_managed_sandboxes( + app: Quart, + authenticated_header: dict, + monkeypatch: pytest.MonkeyPatch, +): + from astrbot.core.computer import computer_client + from astrbot.core.computer.sandbox_manager import SandboxManager + from astrbot.core.computer.sandbox_registry import SandboxRegistry + + provider = FakeSandboxProvider() + manager = SandboxManager( + registry=SandboxRegistry(), providers={provider.provider_id: provider} + ) + monkeypatch.setattr(computer_client, "sandbox_manager", manager) + manager.registry.upsert_sandbox( + sandbox_id="sandbox-1", + sandbox_name="Sandbox 1", + booter_type=provider.provider_id, + provider=provider.provider_id, + managed=True, + created_by_astrbot=True, + owner_user_id="session-a", + owner_session_id="session-a", + connect_info={"name": "Sandbox 1"}, + ) + + test_client = app.test_client() + response = await test_client.get("/api/sandbox", headers=authenticated_header) + data = await response.get_json() + + assert response.status_code == 200 + assert data["status"] == "ok" + assert data["data"]["sandboxes"][0]["sandbox_id"] == "sandbox-1" + assert data["data"]["sandboxes"][0]["capabilities"] == [ + "filesystem", + "shell", + ] + + @pytest.mark.asyncio async def test_auth_login_secure_cookie_override( app: Quart, @@ -1332,185 +1429,13 @@ async def mock_pip_install(*args, **kwargs): assert data["message"] == "install failed" -class _FakeNeoSkills: - async def list_candidates(self, **kwargs): - _ = kwargs - return [ - { - "id": "cand-1", - "skill_key": "neo.demo", - "status": "evaluated_pass", - "payload_ref": "pref-1", - } - ] - - async def list_releases(self, **kwargs): - _ = kwargs - return [ - { - "id": "rel-1", - "skill_key": "neo.demo", - "candidate_id": "cand-1", - "stage": "stable", - "active": True, - } - ] - - async def get_payload(self, payload_ref: str): - return { - "payload_ref": payload_ref, - "payload": {"skill_markdown": "# Demo"}, - } - - async def evaluate_candidate(self, candidate_id: str, **kwargs): - return {"candidate_id": candidate_id, **kwargs} - - async def promote_candidate(self, candidate_id: str, stage: str = "canary"): - return { - "id": "rel-2", - "skill_key": "neo.demo", - "candidate_id": candidate_id, - "stage": stage, - } - - async def rollback_release(self, release_id: str): - return {"id": "rb-1", "rolled_back_release_id": release_id} - - -class _FakeNeoBayClient: - def __init__(self, endpoint_url: str, access_token: str): - self.endpoint_url = endpoint_url - self.access_token = access_token - self.skills = _FakeNeoSkills() - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - _ = exc_type, exc, tb - return False - - @pytest.mark.asyncio -async def test_neo_skills_routes( +async def test_core_dashboard_does_not_ship_neo_skill_routes( app: Quart, - authenticated_header: dict, - core_lifecycle_td: AstrBotCoreLifecycle, - monkeypatch, ): - provider_settings = core_lifecycle_td.astrbot_config.setdefault( - "provider_settings", {} - ) - sandbox = provider_settings.setdefault("sandbox", {}) - sandbox["shipyard_neo_endpoint"] = "http://neo.test" - sandbox["shipyard_neo_access_token"] = "neo-token" - - fake_shipyard_neo_module = SimpleNamespace(BayClient=_FakeNeoBayClient) - monkeypatch.setitem(sys.modules, "shipyard_neo", fake_shipyard_neo_module) - - async def _fake_sync_release(self, client, **kwargs): - _ = self, client, kwargs - return SimpleNamespace( - skill_key="neo.demo", - local_skill_name="neo_demo", - release_id="rel-2", - candidate_id="cand-1", - payload_ref="pref-1", - map_path="data/skills/neo_skill_map.json", - synced_at="2026-01-01T00:00:00Z", - ) - - async def _fake_sync_skills_to_active_sandboxes(): - return - - monkeypatch.setattr( - "astrbot.dashboard.routes.skills.NeoSkillSyncManager.sync_release", - _fake_sync_release, - ) - monkeypatch.setattr( - "astrbot.dashboard.routes.skills.sync_skills_to_active_sandboxes", - _fake_sync_skills_to_active_sandboxes, - ) - - test_client = app.test_client() - - response = await test_client.get( - "/api/skills/neo/candidates", headers=authenticated_header - ) - assert response.status_code == 200 - data = await response.get_json() - assert data["status"] == "ok" - assert isinstance(data["data"], list) - assert data["data"][0]["id"] == "cand-1" - - response = await test_client.get( - "/api/skills/neo/releases", headers=authenticated_header - ) - assert response.status_code == 200 - data = await response.get_json() - assert data["status"] == "ok" - assert isinstance(data["data"], list) - assert data["data"][0]["id"] == "rel-1" - - response = await test_client.get( - "/api/skills/neo/payload?payload_ref=pref-1", headers=authenticated_header - ) - assert response.status_code == 200 - data = await response.get_json() - assert data["status"] == "ok" - assert data["data"]["payload_ref"] == "pref-1" - - response = await test_client.post( - "/api/skills/neo/evaluate", - json={"candidate_id": "cand-1", "passed": True, "score": 0.95}, - headers=authenticated_header, - ) - assert response.status_code == 200 - data = await response.get_json() - assert data["status"] == "ok" - assert data["data"]["candidate_id"] == "cand-1" - assert data["data"]["passed"] is True - - response = await test_client.post( - "/api/skills/neo/evaluate", - json={"candidate_id": "cand-1", "passed": "false", "score": 0.0}, - headers=authenticated_header, - ) - assert response.status_code == 200 - data = await response.get_json() - assert data["status"] == "ok" - assert data["data"]["passed"] is False - - response = await test_client.post( - "/api/skills/neo/promote", - json={"candidate_id": "cand-1", "stage": "stable"}, - headers=authenticated_header, - ) - assert response.status_code == 200 - data = await response.get_json() - assert data["status"] == "ok" - assert data["data"]["release"]["id"] == "rel-2" - assert data["data"]["sync"]["local_skill_name"] == "neo_demo" - - response = await test_client.post( - "/api/skills/neo/rollback", - json={"release_id": "rel-2"}, - headers=authenticated_header, - ) - assert response.status_code == 200 - data = await response.get_json() - assert data["status"] == "ok" - assert data["data"]["rolled_back_release_id"] == "rel-2" - - response = await test_client.post( - "/api/skills/neo/sync", - json={"release_id": "rel-2"}, - headers=authenticated_header, - ) - assert response.status_code == 200 - data = await response.get_json() - assert data["status"] == "ok" - assert data["data"]["skill_key"] == "neo.demo" + assert "/api/skills/neo/candidates" not in { + rule.rule for rule in app.url_map.iter_rules() + } @pytest.mark.asyncio diff --git a/tests/test_neo_skill_tools.py b/tests/test_neo_skill_tools.py deleted file mode 100644 index 076da00945..0000000000 --- a/tests/test_neo_skill_tools.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations - -import asyncio -from types import SimpleNamespace - -from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.tools.computer_tools.shipyard_neo.neo_skills import ( - PromoteSkillCandidateTool, -) - - -class _FakeSkills: - def __init__(self): - self.rollback_called_with = None - - async def promote_candidate(self, candidate_id: str, stage: str = "canary"): - assert candidate_id == "cand-1" - assert stage == "stable" - return { - "id": "sr-1", - "skill_key": "k1", - "candidate_id": candidate_id, - "stage": stage, - } - - async def rollback_release(self, release_id: str): - self.rollback_called_with = release_id - return {"id": "rb-1", "rollback_of": release_id} - - -class _FakeClient: - def __init__(self): - self.skills = _FakeSkills() - - -class _FakeBooter: - def __init__(self): - self.bay_client = _FakeClient() - self.sandbox = object() - - -def test_promote_stable_sync_failure_auto_rolls_back(monkeypatch): - async def _fake_get_booter(_ctx, _session_id): - return _FakeBooter() - - async def _fake_sync_release(self, client, **kwargs): - _ = self, client, kwargs - raise ValueError("sync failed") - - monkeypatch.setattr( - "astrbot.core.tools.computer_tools.shipyard_neo.neo_skills.get_booter", - _fake_get_booter, - ) - monkeypatch.setattr( - "astrbot.core.tools.computer_tools.shipyard_neo.neo_skills.NeoSkillSyncManager.sync_release", - _fake_sync_release, - ) - - event = SimpleNamespace( - role="admin", - unified_msg_origin="session-1", - get_sender_id=lambda: "admin-user", - ) - astr_ctx = SimpleNamespace( - context=SimpleNamespace( - get_config=lambda umo: { # noqa: ARG005 - "provider_settings": { - "computer_use_require_admin": True, - } - } - ), - event=event, - ) - run_ctx = ContextWrapper(context=astr_ctx) - - tool = PromoteSkillCandidateTool() - result = asyncio.run( - tool.call( - run_ctx, - candidate_id="cand-1", - stage="stable", - sync_to_local=True, - ) - ) - - assert isinstance(result, str) - assert "auto rollback succeeded" in result - assert "sync failed" in result diff --git a/tests/test_profile_aware_tools.py b/tests/test_profile_aware_tools.py deleted file mode 100644 index e8c2954380..0000000000 --- a/tests/test_profile_aware_tools.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Tests for profile-aware sandbox selection and conditional tool registration.""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import patch - -import pytest - - -# ═══════════════════════════════════════════════════════════════ -# ShipyardNeoBooter.capabilities -# ═══════════════════════════════════════════════════════════════ - - -class TestShipyardNeoBooterCapabilities: - """Test capabilities property on ShipyardNeoBooter.""" - - def _make_booter(self, sandbox_caps: list[str] | None = None): - from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter - - booter = ShipyardNeoBooter( - endpoint_url="http://localhost:8114", - access_token="sk-bay-test", - ) - if sandbox_caps is not None: - booter._sandbox = SimpleNamespace(capabilities=sandbox_caps) - return booter - - def test_none_before_boot(self): - booter = self._make_booter() - assert booter.capabilities is None - - def test_returns_tuple_after_boot(self): - booter = self._make_booter(["python", "shell", "filesystem"]) - assert booter.capabilities == ("python", "shell", "filesystem") - assert isinstance(booter.capabilities, tuple) - - def test_includes_browser_when_present(self): - booter = self._make_booter(["python", "shell", "filesystem", "browser"]) - assert "browser" in booter.capabilities - - def test_no_browser_when_absent(self): - booter = self._make_booter(["python", "shell", "filesystem"]) - assert "browser" not in booter.capabilities - - def test_returns_immutable(self): - """Verify capabilities returns an immutable tuple.""" - booter = self._make_booter(["python"]) - caps = booter.capabilities - assert isinstance(caps, tuple) - with pytest.raises(AttributeError): - caps.append("mutated") # type: ignore[attr-defined] - - -# ═══════════════════════════════════════════════════════════════ -# _apply_sandbox_tools — conditional browser tool registration -# ═══════════════════════════════════════════════════════════════ - - -def _make_config(booter_type: str = "shipyard_neo"): - return SimpleNamespace( - sandbox_cfg={"booter": booter_type}, - ) - - -def _make_req(): - return SimpleNamespace(func_tool=None, system_prompt="") - - -def _import_apply_sandbox_tools(): - """Import _apply_sandbox_tools, skipping if circular-import fails.""" - try: - from astrbot.core.astr_main_agent import _apply_sandbox_tools - - return _apply_sandbox_tools - except ImportError: - pytest.skip("Cannot import _apply_sandbox_tools (circular import in test env)") - - -class TestApplySandboxToolsConditional: - """Verify browser tools are conditionally registered.""" - - def _tool_names(self, req) -> set[str]: - """Extract tool names from a request's func_tool.""" - if req.func_tool is None: - return set() - return {t.name for t in req.func_tool.tools} - - def test_no_session_registers_all(self): - """First request (no booted session) → all tools including browser.""" - fn = _import_apply_sandbox_tools() - config = _make_config("shipyard_neo") - req = _make_req() - - with patch( - "astrbot.core.computer.computer_client.session_booter", {} - ): - fn(config, req, "session-1") - - names = self._tool_names(req) - assert "astrbot_execute_browser" in names - assert "astrbot_execute_browser_batch" in names - assert "astrbot_run_browser_skill" in names - - def test_with_browser_capability(self): - """Booted session with browser capability → browser tools registered.""" - fn = _import_apply_sandbox_tools() - config = _make_config("shipyard_neo") - req = _make_req() - fake_booter = SimpleNamespace( - capabilities=["python", "shell", "filesystem", "browser"] - ) - - with patch( - "astrbot.core.computer.computer_client.session_booter", - {"session-1": fake_booter}, - ): - fn(config, req, "session-1") - - names = self._tool_names(req) - assert "astrbot_execute_browser" in names - - def test_without_browser_capability(self): - """Booted session WITHOUT browser capability → browser tools NOT registered.""" - fn = _import_apply_sandbox_tools() - config = _make_config("shipyard_neo") - req = _make_req() - fake_booter = SimpleNamespace( - capabilities=["python", "shell", "filesystem"] - ) - - with patch( - "astrbot.core.computer.computer_client.session_booter", - {"session-1": fake_booter}, - ): - fn(config, req, "session-1") - - names = self._tool_names(req) - assert "astrbot_execute_browser" not in names - assert "astrbot_execute_browser_batch" not in names - assert "astrbot_run_browser_skill" not in names - # Skill tools should still be registered - assert "astrbot_get_execution_history" in names - - def test_skill_tools_always_registered(self): - """Skill lifecycle tools are registered regardless of capabilities.""" - fn = _import_apply_sandbox_tools() - config = _make_config("shipyard_neo") - req = _make_req() - fake_booter = SimpleNamespace(capabilities=["python"]) - - with patch( - "astrbot.core.computer.computer_client.session_booter", - {"session-1": fake_booter}, - ): - fn(config, req, "session-1") - - names = self._tool_names(req) - assert "astrbot_create_skill_candidate" in names - assert "astrbot_promote_skill_candidate" in names - - -# ═══════════════════════════════════════════════════════════════ -# _resolve_profile -# ═══════════════════════════════════════════════════════════════ - - -class TestResolveProfile: - """Test smart profile selection logic.""" - - def _make_booter(self, profile: str = "python-default"): - from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter - - return ShipyardNeoBooter( - endpoint_url="http://localhost:8114", - access_token="sk-bay-test", - profile=profile, - ) - - @pytest.mark.asyncio - async def test_user_specified_profile_honoured(self): - """User explicitly sets a non-default profile → use it directly.""" - booter = self._make_booter(profile="browser-python") - client = SimpleNamespace() # list_profiles should NOT be called - result = await booter._resolve_profile(client) - assert result == "browser-python" - - @pytest.mark.asyncio - async def test_selects_browser_profile(self): - """When multiple profiles available, prefer one with browser.""" - - async def _mock_list_profiles(): - return SimpleNamespace( - items=[ - SimpleNamespace( - id="python-default", - capabilities=["python", "shell", "filesystem"], - ), - SimpleNamespace( - id="browser-python", - capabilities=["python", "shell", "filesystem", "browser"], - ), - ] - ) - - booter = self._make_booter() - client = SimpleNamespace(list_profiles=_mock_list_profiles) - result = await booter._resolve_profile(client) - assert result == "browser-python" - - @pytest.mark.asyncio - async def test_falls_back_to_default_on_api_error(self): - """API error → graceful fallback to python-default.""" - - async def _failing_list_profiles(): - raise ConnectionError("Bay unreachable") - - booter = self._make_booter() - client = SimpleNamespace(list_profiles=_failing_list_profiles) - result = await booter._resolve_profile(client) - assert result == "python-default" - - @pytest.mark.asyncio - async def test_falls_back_on_empty_profiles(self): - """Empty profile list → python-default.""" - - async def _empty_list_profiles(): - return SimpleNamespace(items=[]) - - booter = self._make_booter() - client = SimpleNamespace(list_profiles=_empty_list_profiles) - result = await booter._resolve_profile(client) - assert result == "python-default" - - @pytest.mark.asyncio - async def test_single_profile_selected(self): - """Only one profile available → use it.""" - - async def _single_profile(): - return SimpleNamespace( - items=[ - SimpleNamespace( - id="python-data", - capabilities=["python", "shell", "filesystem"], - ), - ] - ) - - booter = self._make_booter() - client = SimpleNamespace(list_profiles=_single_profile) - result = await booter._resolve_profile(client) - assert result == "python-data" - - @pytest.mark.asyncio - async def test_auth_error_not_silenced(self): - """UnauthorizedError must propagate, not be downgraded to fallback.""" - from shipyard_neo.errors import UnauthorizedError - - async def _unauthorized_list_profiles(): - raise UnauthorizedError("bad token") - - booter = self._make_booter() - client = SimpleNamespace(list_profiles=_unauthorized_list_profiles) - with pytest.raises(UnauthorizedError): - await booter._resolve_profile(client) - - -# ═══════════════════════════════════════════════════════════════ -# ComputerBooter base class -# ═══════════════════════════════════════════════════════════════ - - -class TestBaseComputerBooter: - """Verify base class defaults.""" - - def test_capabilities_default_none(self): - from astrbot.core.computer.booters.base import ComputerBooter - - booter = ComputerBooter() - assert booter.capabilities is None - - def test_browser_default_none(self): - from astrbot.core.computer.booters.base import ComputerBooter - - booter = ComputerBooter() - assert booter.browser is None diff --git a/tests/test_shipyard_neo_booter.py b/tests/test_shipyard_neo_booter.py deleted file mode 100644 index b0d7ecc01d..0000000000 --- a/tests/test_shipyard_neo_booter.py +++ /dev/null @@ -1,344 +0,0 @@ -"""Tests for ShipyardNeoBooter — readiness gate, shutdown cleanup, and rebuild recovery.""" - -from __future__ import annotations - -import asyncio -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -import pytest - - -# ═══════════════════════════════════════════════════════════════ -# _wait_until_ready -# ═══════════════════════════════════════════════════════════════ - - -def _make_sandbox_mock(statuses: list[str], *, delete_side_effect=None): - """Build a sandbox mock that returns *statuses* in order on refresh(). - - After the list is exhausted subsequent refresh() calls return the last status. - """ - call_count = 0 - - async def _refresh(): - nonlocal call_count - idx = min(call_count, len(statuses) - 1) - call_count += 1 - s = statuses[idx] - sandbox.status = SimpleNamespace(value=s) - - sandbox = SimpleNamespace( - id="sandbox-test-1", - profile="python-default", - status=SimpleNamespace(value=statuses[0]), - refresh=_refresh, - delete=AsyncMock(side_effect=delete_side_effect), - ) - return sandbox - - -class TestWaitUntilReady: - def _make_booter(self): - from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter - - return ShipyardNeoBooter( - endpoint_url="http://localhost:8114", - access_token="sk-bay-test", - ) - - @pytest.mark.asyncio - async def test_already_ready_returns_immediately(self): - """Sandbox is READY on first poll → instant return (warm hit).""" - booter = self._make_booter() - sandbox = _make_sandbox_mock(["ready"]) - - await booter._wait_until_ready(sandbox) - - sandbox.delete.assert_not_called() - - @pytest.mark.asyncio - async def test_starting_then_ready(self): - """Sandbox transitions STARTING → READY within timeout.""" - booter = self._make_booter() - sandbox = _make_sandbox_mock(["starting", "starting", "ready"]) - - await booter._wait_until_ready(sandbox) - - sandbox.delete.assert_not_called() - - @pytest.mark.asyncio - async def test_failed_deletes_and_raises(self): - """Sandbox reaches FAILED → delete called → RuntimeError raised.""" - booter = self._make_booter() - sandbox = _make_sandbox_mock(["starting", "failed"]) - - with pytest.raises(RuntimeError, match="terminal state"): - await booter._wait_until_ready(sandbox) - - sandbox.delete.assert_awaited_once() - - @pytest.mark.asyncio - async def test_expired_deletes_and_raises(self): - """Sandbox reaches EXPIRED → delete called → RuntimeError raised.""" - booter = self._make_booter() - sandbox = _make_sandbox_mock(["starting", "expired"]) - - with pytest.raises(RuntimeError, match="terminal state"): - await booter._wait_until_ready(sandbox) - - sandbox.delete.assert_awaited_once() - - @pytest.mark.asyncio - async def test_timeout_deletes_and_raises(self): - """Sandbox never reaches READY → delete called → TimeoutError raised.""" - booter = self._make_booter() - # Return 'idle' every time to simulate a stuck sandbox - sandbox = _make_sandbox_mock(["idle"]) - - # Override the deadline so we don't actually sleep 180s - original_time = asyncio.get_running_loop().time - - call_idx = 0 - - def _fake_time(): - nonlocal call_idx - # After one tick, jump past the deadline - if call_idx == 0: - call_idx += 1 - return original_time() - # Return a value beyond the 180s timeout - return original_time() + 200 - - with patch( - "astrbot.core.computer.booters.shipyard_neo.asyncio.get_running_loop" - ) as mock_loop: - mock_loop.return_value.time = _fake_time - - with pytest.raises(TimeoutError, match="did not become ready"): - await booter._wait_until_ready(sandbox) - - sandbox.delete.assert_awaited_once() - - @pytest.mark.asyncio - async def test_delete_failure_during_cleanup_is_safe(self): - """If sandbox.delete() itself throws, the original error is still raised.""" - booter = self._make_booter() - sandbox = _make_sandbox_mock( - ["failed"], - delete_side_effect=RuntimeError("Bay unreachable"), - ) - - with pytest.raises(RuntimeError, match="terminal state"): - await booter._wait_until_ready(sandbox) - - sandbox.delete.assert_awaited_once() - - -# ═══════════════════════════════════════════════════════════════ -# shutdown -# ═══════════════════════════════════════════════════════════════ - - -class TestShutdown: - def _make_booter(self): - from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter - - return ShipyardNeoBooter( - endpoint_url="http://localhost:8114", - access_token="sk-bay-test", - ) - - @pytest.mark.asyncio - async def test_delete_sandbox_true_calls_delete(self): - """delete_sandbox=True → sandbox.delete() called, then client closed.""" - booter = self._make_booter() - sandbox = SimpleNamespace( - id="sandbox-test-1", - delete=AsyncMock(), - ) - client = SimpleNamespace( - __aexit__=AsyncMock(), - ) - booter._sandbox = sandbox # type: ignore[assignment] - booter._client = client # type: ignore[assignment] - - await booter.shutdown(delete_sandbox=True) - - sandbox.delete.assert_awaited_once() - client.__aexit__.assert_awaited_once() - assert booter._client is None - assert booter._sandbox is None - - @pytest.mark.asyncio - async def test_delete_sandbox_false_does_not_call_delete(self): - """delete_sandbox=False (default) → sandbox.delete() NOT called.""" - booter = self._make_booter() - sandbox = SimpleNamespace( - id="sandbox-test-1", - delete=AsyncMock(), - ) - client = SimpleNamespace( - __aexit__=AsyncMock(), - ) - booter._sandbox = sandbox # type: ignore[assignment] - booter._client = client # type: ignore[assignment] - - await booter.shutdown() # default delete_sandbox=False - - sandbox.delete.assert_not_called() - client.__aexit__.assert_awaited_once() - assert booter._client is None - assert booter._sandbox is None - - @pytest.mark.asyncio - async def test_delete_failure_still_closes_client(self): - """If sandbox.delete() throws, HTTP client is still torn down.""" - booter = self._make_booter() - sandbox = SimpleNamespace( - id="sandbox-test-1", - delete=AsyncMock(side_effect=RuntimeError("Bay gone")), - ) - client = SimpleNamespace( - __aexit__=AsyncMock(), - ) - booter._sandbox = sandbox # type: ignore[assignment] - booter._client = client # type: ignore[assignment] - - # Should not raise — delete failure is logged but swallowed - await booter.shutdown(delete_sandbox=True) - - sandbox.delete.assert_awaited_once() - client.__aexit__.assert_awaited_once() - assert booter._client is None - assert booter._sandbox is None - - @pytest.mark.asyncio - async def test_no_client_is_noop(self): - """shutdown() on an uninitialised booter is a no-op.""" - booter = self._make_booter() - # _client is None by default - await booter.shutdown(delete_sandbox=True) - # No exception → ok - - -# ═══════════════════════════════════════════════════════════════ -# get_booter rebuild path -# ═══════════════════════════════════════════════════════════════ - - -class TestGetBooterRebuild: - """Verify that stale ShipyardNeoBooter instances are cleaned up on rebuild.""" - - def _make_fake_context(self, booter_type: str = "shipyard_neo"): - """Build a context-like object for get_booter().""" - _cfg = { - "provider_settings": { - "computer_use_runtime": "sandbox", - "sandbox": { - "booter": booter_type, - "shipyard_neo_endpoint": "http://bay:8114", - "shipyard_neo_access_token": "sk-test", - "shipyard_neo_ttl": 3600, - "shipyard_neo_profile": "python-default", - }, - } - } - return SimpleNamespace( - get_config=lambda umo=None: _cfg, - ) - - @pytest.mark.asyncio - async def test_stale_neo_booter_calls_shutdown_with_delete(self, monkeypatch): - """A stale ShipyardNeoBooter gets shutdown(delete_sandbox=True) on eviction.""" - from astrbot.core.computer import computer_client - from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter - - ctx = self._make_fake_context() - - stale = ShipyardNeoBooter( - endpoint_url="http://bay:8114", access_token="sk-test" - ) - stale._sandbox = SimpleNamespace(id="stale-sandbox") # type: ignore[assignment] - stale._client = SimpleNamespace(__aexit__=AsyncMock()) # type: ignore[assignment] - stale._sandbox.refresh = AsyncMock(side_effect=RuntimeError("sandbox gone")) # type: ignore[union-attr] - # available() will return False because refresh() throws - stale.shutdown = AsyncMock() - - monkeypatch.setitem(computer_client.session_booter, "session-1", stale) - - from astrbot.core.computer.computer_client import get_booter - - # get_booter should evict stale and rebuild. - # We need to mock the entire rebuild path so it doesn't actually - # try to connect to Bay. - async def _fake_boot(_self, _sid): - _self._sandbox = SimpleNamespace( # type: ignore[assignment] - id="new-sandbox", - refresh=AsyncMock(), - status=SimpleNamespace(value="ready"), - capabilities=["python", "shell", "filesystem"], - ) - _self._client = SimpleNamespace() # type: ignore[assignment] - _self._shell = SimpleNamespace() # type: ignore[assignment] - _self._fs = SimpleNamespace() # type: ignore[assignment] - _self._python = SimpleNamespace() # type: ignore[assignment] - - with patch.object( - ShipyardNeoBooter, "boot", _fake_boot - ), patch( - "astrbot.core.computer.computer_client._sync_skills_to_sandbox", - AsyncMock(), - ): - await get_booter(ctx, "session-1") - - stale.shutdown.assert_awaited_once_with(delete_sandbox=True) - # Old entry should be replaced - new_booter = computer_client.session_booter.get("session-1") - assert new_booter is not None - assert new_booter is not stale - - @pytest.mark.asyncio - async def test_stale_non_neo_booter_calls_plain_shutdown(self, monkeypatch): - """Non-neo booter (e.g. shipyard) → plain shutdown() without delete_sandbox.""" - from astrbot.core.computer import computer_client - - ctx = self._make_fake_context(booter_type="shipyard") - - stale = SimpleNamespace(shutdown=AsyncMock()) - stale.available = AsyncMock(return_value=False) - - monkeypatch.setitem(computer_client.session_booter, "session-1", stale) - - # Patch ShipyardBooter entirely to skip its __init__ validation - class _FakeShipyardBooter: - def __init__(self, **kwargs): - pass - - async def boot(self, _sid): - self._sandbox = SimpleNamespace( # type: ignore[assignment] - refresh=AsyncMock(), - status=SimpleNamespace(value="ready"), - ) - self._shell = SimpleNamespace() # type: ignore[assignment] - self._fs = SimpleNamespace() # type: ignore[assignment] - self._python = SimpleNamespace() # type: ignore[assignment] - - async def shutdown(self, **kwargs): - pass - - with patch( - "astrbot.core.computer.booters.shipyard.ShipyardBooter", - _FakeShipyardBooter, - ), patch( - "astrbot.core.computer.computer_client._sync_skills_to_sandbox", - AsyncMock(), - ): - from astrbot.core.computer.computer_client import get_booter - - await get_booter(ctx, "session-1") - - stale.shutdown.assert_awaited_once() - # No delete_sandbox kwarg for non-neo booters - call_kwargs = stale.shutdown.call_args.kwargs - assert call_kwargs == {} diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 1ba2f3a2a2..25aa9cdda6 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -1,6 +1,5 @@ """Tests for astr_main_agent module.""" -import os from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -1641,100 +1640,6 @@ def test_apply_sandbox_tools_adds_sandbox_prompt(self, mock_context): assert "sandboxed environment" in req.system_prompt - def test_apply_sandbox_tools_with_cua_adds_gui_guidance(self, mock_context): - """Test that CUA sandbox guidance nudges reliable GUI workflows.""" - module = ama - config = module.MainAgentBuildConfig( - tool_call_timeout=60, - computer_use_runtime="sandbox", - sandbox_cfg={"booter": "cua"}, - ) - req = ProviderRequest(prompt="Test", system_prompt="Original prompt") - - module._apply_sandbox_tools(config, req, "session-123") - - assert req.func_tool is not None - tool_names = req.func_tool.names() - assert "astrbot_cua_screenshot" in tool_names - assert "astrbot_cua_mouse_click" in tool_names - assert "astrbot_cua_keyboard_type" in tool_names - assert "astrbot_cua_key_press" not in tool_names - - assert "Firefox" in req.system_prompt - assert "background=true" in req.system_prompt - assert 'firefox "https://example.com"' in req.system_prompt - assert "astrbot_cua_screenshot" in req.system_prompt - assert "astrbot_cua_key_press" not in req.system_prompt - assert "return_image_to_llm" in req.system_prompt - assert "astrbot_execute_shell" in req.system_prompt - assert "\\n" in req.system_prompt - assert "send_to_user=true" in req.system_prompt - assert "focused and empty or safe to append" in req.system_prompt - - def test_apply_sandbox_tools_with_shipyard_booter(self, monkeypatch, mock_context): - """Test sandbox tools with shipyard booter configuration.""" - module = ama - config = module.MainAgentBuildConfig( - tool_call_timeout=60, - computer_use_runtime="sandbox", - sandbox_cfg={ - "booter": "shipyard", - "shipyard_endpoint": "https://shipyard.example.com", - "shipyard_access_token": "test-token", - }, - ) - req = ProviderRequest(prompt="Test", func_tool=None) - - monkeypatch.delenv("SHIPYARD_ENDPOINT", raising=False) - monkeypatch.delenv("SHIPYARD_ACCESS_TOKEN", raising=False) - - module._apply_sandbox_tools(config, req, "session-123") - - assert os.environ.get("SHIPYARD_ENDPOINT") == "https://shipyard.example.com" - assert os.environ.get("SHIPYARD_ACCESS_TOKEN") == "test-token" - - def test_apply_sandbox_tools_shipyard_missing_endpoint(self, mock_context): - """Test that shipyard config is skipped when endpoint is missing.""" - module = ama - config = module.MainAgentBuildConfig( - tool_call_timeout=60, - computer_use_runtime="sandbox", - sandbox_cfg={ - "booter": "shipyard", - "shipyard_endpoint": "", - "shipyard_access_token": "test-token", - }, - ) - req = ProviderRequest(prompt="Test", func_tool=None) - - with patch("astrbot.core.astr_main_agent.logger") as mock_logger: - module._apply_sandbox_tools(config, req, "session-123") - - mock_logger.error.assert_called_once() - assert ( - "Shipyard sandbox configuration is incomplete" - in mock_logger.error.call_args[0][0] - ) - - def test_apply_sandbox_tools_shipyard_missing_access_token(self, mock_context): - """Test that shipyard config is skipped when access token is missing.""" - module = ama - config = module.MainAgentBuildConfig( - tool_call_timeout=60, - computer_use_runtime="sandbox", - sandbox_cfg={ - "booter": "shipyard", - "shipyard_endpoint": "https://shipyard.example.com", - "shipyard_access_token": "", - }, - ) - req = ProviderRequest(prompt="Test", func_tool=None) - - with patch("astrbot.core.astr_main_agent.logger") as mock_logger: - module._apply_sandbox_tools(config, req, "session-123") - - mock_logger.error.assert_called_once() - def test_apply_sandbox_tools_preserves_existing_toolset(self, mock_context): """Test that existing tools are preserved when adding sandbox tools.""" module = ama diff --git a/tests/unit/test_computer.py b/tests/unit/test_computer.py index 71b31a301a..4a8c921c95 100644 --- a/tests/unit/test_computer.py +++ b/tests/unit/test_computer.py @@ -1,10 +1,9 @@ """Tests for astrbot/core/computer module. -This module tests the ComputerClient, Booter implementations (local, shipyard, boxlite), +This module tests the ComputerClient, local booter implementation, filesystem operations, Python execution, shell execution, and security restrictions. """ -import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -372,118 +371,6 @@ def test_base_class_is_protocol(self): assert hasattr(booter, "available") -class TestShipyardBooter: - """Tests for ShipyardBooter.""" - - @pytest.mark.asyncio - async def test_shipyard_booter_init(self): - """Test ShipyardBooter initialization.""" - with patch("astrbot.core.computer.booters.shipyard.ShipyardClient"): - from astrbot.core.computer.booters.shipyard import ShipyardBooter - - booter = ShipyardBooter( - endpoint_url="http://localhost:8080", - access_token="test_token", - ttl=3600, - session_num=10, - ) - assert booter._ttl == 3600 - assert booter._session_num == 10 - - @pytest.mark.asyncio - async def test_shipyard_booter_boot(self): - """Test ShipyardBooter boot method.""" - mock_ship = MagicMock() - mock_ship.id = "test-ship-id" - mock_ship.fs = MagicMock() - mock_ship.python = MagicMock() - mock_ship.shell = MagicMock() - - mock_client = MagicMock() - mock_client.create_ship = AsyncMock(return_value=mock_ship) - - with patch( - "astrbot.core.computer.booters.shipyard.ShipyardClient", - return_value=mock_client, - ): - from astrbot.core.computer.booters.shipyard import ShipyardBooter - - booter = ShipyardBooter( - endpoint_url="http://localhost:8080", - access_token="test_token", - ) - await booter.boot("test-session") - assert booter._ship == mock_ship - - @pytest.mark.asyncio - async def test_shipyard_available_healthy(self): - """Test ShipyardBooter available when healthy.""" - mock_ship = MagicMock() - mock_ship.id = "test-ship-id" - - mock_client = MagicMock() - mock_client.get_ship = AsyncMock(return_value={"status": 1}) - - with patch( - "astrbot.core.computer.booters.shipyard.ShipyardClient", - return_value=mock_client, - ): - from astrbot.core.computer.booters.shipyard import ShipyardBooter - - booter = ShipyardBooter( - endpoint_url="http://localhost:8080", - access_token="test_token", - ) - booter._ship = mock_ship - booter._sandbox_client = mock_client - - result = await booter.available() - assert result is True - - @pytest.mark.asyncio - async def test_shipyard_available_unhealthy(self): - """Test ShipyardBooter available when unhealthy.""" - mock_ship = MagicMock() - mock_ship.id = "test-ship-id" - - mock_client = MagicMock() - mock_client.get_ship = AsyncMock(return_value={"status": 0}) - - with patch( - "astrbot.core.computer.booters.shipyard.ShipyardClient", - return_value=mock_client, - ): - from astrbot.core.computer.booters.shipyard import ShipyardBooter - - booter = ShipyardBooter( - endpoint_url="http://localhost:8080", - access_token="test_token", - ) - booter._ship = mock_ship - booter._sandbox_client = mock_client - - result = await booter.available() - assert result is False - - -class TestBoxliteBooter: - """Tests for BoxliteBooter.""" - - @pytest.mark.asyncio - async def test_boxlite_booter_init(self): - """Test BoxliteBooter can be instantiated via __new__.""" - # Need to mock boxlite module before importing - mock_boxlite = MagicMock() - mock_boxlite.SimpleBox = MagicMock() - - with patch.dict(sys.modules, {"boxlite": mock_boxlite}): - from astrbot.core.computer.booters.boxlite import BoxliteBooter - - # Just verify class exists and can be instantiated (boot is async) - booter = BoxliteBooter.__new__(BoxliteBooter) - assert booter is not None - - class TestComputerClient: """Tests for computer_client module functions.""" @@ -503,60 +390,6 @@ def test_get_local_booter(self): # Reset for other tests computer_client.local_booter = None - @pytest.mark.asyncio - async def test_get_booter_shipyard(self): - """Test get_booter with shipyard type.""" - from astrbot.core.computer import computer_client - from astrbot.core.computer.booters.shipyard import ShipyardBooter - - # Clear session booter - computer_client.session_booter.clear() - - mock_context = MagicMock() - mock_config = MagicMock() - mock_config.get = lambda key, default=None: { - "provider_settings": { - "computer_use_runtime": "sandbox", - "sandbox": { - "booter": "shipyard", - "shipyard_endpoint": "http://localhost:8080", - "shipyard_access_token": "test_token", - "shipyard_ttl": 3600, - "shipyard_max_sessions": 10, - }, - } - }.get(key, default) - mock_context.get_config = MagicMock(return_value=mock_config) - - # Mock the ShipyardBooter - mock_ship = MagicMock() - mock_ship.id = "test-ship-id" - mock_ship.fs = MagicMock() - mock_ship.python = MagicMock() - mock_ship.shell = MagicMock() - - mock_booter = MagicMock() - mock_booter.boot = AsyncMock() - mock_booter.available = AsyncMock(return_value=True) - mock_booter.shell = MagicMock() - mock_booter.upload_file = AsyncMock(return_value={"success": True}) - - with ( - patch.object(ShipyardBooter, "boot", new=AsyncMock()), - patch( - "astrbot.core.computer.computer_client._sync_skills_to_sandbox", - AsyncMock(), - ), - ): - # Directly set the booter in the session - computer_client.session_booter["test-session-id"] = mock_booter - - booter = await computer_client.get_booter(mock_context, "test-session-id") - assert booter is mock_booter - - # Cleanup - computer_client.session_booter.clear() - @pytest.mark.asyncio async def test_get_booter_unknown_type(self): """Test get_booter with unknown booter type raises ValueError.""" @@ -580,113 +413,12 @@ async def test_get_booter_unknown_type(self): await computer_client.get_booter(mock_context, "test-session-id") assert "Unknown booter type" in str(exc_info.value) - @pytest.mark.asyncio - async def test_get_booter_reuses_existing(self): - """Test get_booter reuses existing booter for same session.""" - from astrbot.core.computer import computer_client - from astrbot.core.computer.booters.shipyard import ShipyardBooter - - computer_client.session_booter.clear() - - mock_context = MagicMock() - mock_config = MagicMock() - mock_config.get = lambda key, default=None: { - "provider_settings": { - "computer_use_runtime": "sandbox", - "sandbox": { - "booter": "shipyard", - "shipyard_endpoint": "http://localhost:8080", - "shipyard_access_token": "test_token", - }, - } - }.get(key, default) - mock_context.get_config = MagicMock(return_value=mock_config) - - mock_booter = MagicMock() - mock_booter.boot = AsyncMock() - mock_booter.available = AsyncMock(return_value=True) - mock_booter.shell = MagicMock() - mock_booter.upload_file = AsyncMock(return_value={"success": True}) - - with ( - patch.object(ShipyardBooter, "boot", new=AsyncMock()), - patch( - "astrbot.core.computer.computer_client._sync_skills_to_sandbox", - AsyncMock(), - ), - ): - # Pre-set the booter - computer_client.session_booter["test-session"] = mock_booter - - booter1 = await computer_client.get_booter(mock_context, "test-session") - booter2 = await computer_client.get_booter(mock_context, "test-session") - assert booter1 is booter2 - - # Cleanup - computer_client.session_booter.clear() - - @pytest.mark.asyncio - async def test_get_booter_rebuild_unavailable(self): - """Test get_booter rebuilds when existing booter is unavailable.""" - from astrbot.core.computer import computer_client - from astrbot.core.computer.booters.shipyard import ShipyardBooter - - computer_client.session_booter.clear() - - mock_context = MagicMock() - mock_config = MagicMock() - mock_config.get = lambda key, default=None: { - "provider_settings": { - "computer_use_runtime": "sandbox", - "sandbox": { - "booter": "shipyard", - "shipyard_endpoint": "http://localhost:8080", - "shipyard_access_token": "test_token", - }, - } - }.get(key, default) - mock_context.get_config = MagicMock(return_value=mock_config) - - mock_unavailable_booter = MagicMock(spec=ShipyardBooter) - mock_unavailable_booter.available = AsyncMock(return_value=False) - - mock_new_booter = MagicMock(spec=ShipyardBooter) - mock_new_booter.boot = AsyncMock() - - with ( - patch( - "astrbot.core.computer.booters.shipyard.ShipyardBooter", - return_value=mock_new_booter, - ) as mock_booter_cls, - patch( - "astrbot.core.computer.computer_client._sync_skills_to_sandbox", - AsyncMock(), - ), - ): - session_id = "test-session-rebuild" - # Pre-set the unavailable booter - computer_client.session_booter[session_id] = mock_unavailable_booter - - # get_booter should detect the booter is unavailable and create a new one - new_booter_instance = await computer_client.get_booter( - mock_context, session_id - ) - - # Assert that a new booter was created and is now in the session - mock_booter_cls.assert_called_once() - mock_new_booter.boot.assert_awaited_once() - assert new_booter_instance is mock_new_booter - assert computer_client.session_booter[session_id] is mock_new_booter - - # Cleanup - computer_client.session_booter.clear() - class TestSyncSkillsToSandbox: """Tests for _sync_skills_to_sandbox function.""" @pytest.mark.asyncio - async def test_sync_skills_no_skills_dir(self): + async def test_sync_skills_no_skills_dir(self, tmp_path): """Test sync does nothing when skills directory doesn't exist.""" from astrbot.core.computer import computer_client @@ -694,21 +426,15 @@ async def test_sync_skills_no_skills_dir(self): mock_booter.shell.exec = AsyncMock() mock_booter.upload_file = AsyncMock(return_value={"success": True}) - with ( - patch( - "astrbot.core.computer.computer_client.get_astrbot_skills_path", - return_value="/nonexistent/path", - ), - patch( - "astrbot.core.computer.computer_client.os.path.isdir", - return_value=False, - ), + with patch( + "astrbot.core.computer.computer_client.get_astrbot_skills_path", + return_value=str(tmp_path / "missing"), ): await computer_client._sync_skills_to_sandbox(mock_booter) mock_booter.upload_file.assert_not_called() @pytest.mark.asyncio - async def test_sync_skills_empty_dir(self): + async def test_sync_skills_empty_dir(self, tmp_path): """Test sync does nothing when skills directory is empty.""" from astrbot.core.computer import computer_client @@ -716,25 +442,18 @@ async def test_sync_skills_empty_dir(self): mock_booter.shell.exec = AsyncMock() mock_booter.upload_file = AsyncMock(return_value={"success": True}) - with ( - patch( - "astrbot.core.computer.computer_client.get_astrbot_skills_path", - return_value="/tmp/empty", - ), - patch( - "astrbot.core.computer.computer_client.os.path.isdir", - return_value=True, - ), - patch( - "astrbot.core.computer.computer_client.Path.iterdir", - return_value=iter([]), - ), + empty_skills = tmp_path / "empty" + empty_skills.mkdir() + + with patch( + "astrbot.core.computer.computer_client.get_astrbot_skills_path", + return_value=str(empty_skills), ): await computer_client._sync_skills_to_sandbox(mock_booter) mock_booter.upload_file.assert_not_called() @pytest.mark.asyncio - async def test_sync_skills_success(self): + async def test_sync_skills_success(self, tmp_path): """Test successful skills sync.""" from astrbot.core.computer import computer_client @@ -742,36 +461,21 @@ async def test_sync_skills_success(self): mock_booter.shell.exec = AsyncMock(return_value={"exit_code": 0}) mock_booter.upload_file = AsyncMock(return_value={"success": True}) - mock_skill_file = MagicMock() - mock_skill_file.name = "skill.py" - mock_skill_file.__str__ = lambda: "/tmp/skills/skill.py" + skills_dir = tmp_path / "skills" + demo_skill = skills_dir / "demo_skill" + demo_skill.mkdir(parents=True) + (demo_skill / "SKILL.md").write_text("# Demo", encoding="utf-8") + temp_dir = tmp_path / "temp" + temp_dir.mkdir() with ( patch( "astrbot.core.computer.computer_client.get_astrbot_skills_path", - return_value="/tmp/skills", - ), - patch( - "astrbot.core.computer.computer_client.os.path.isdir", - return_value=True, - ), - patch( - "astrbot.core.computer.computer_client.Path.iterdir", - return_value=iter([mock_skill_file]), + return_value=str(skills_dir), ), patch( "astrbot.core.computer.computer_client.get_astrbot_temp_path", - return_value="/tmp", - ), - patch( - "astrbot.core.computer.computer_client.shutil.make_archive", - ), - patch( - "astrbot.core.computer.computer_client.os.path.exists", - return_value=True, - ), - patch( - "astrbot.core.computer.computer_client.os.remove", + return_value=str(temp_dir), ), ): # Should not raise diff --git a/tests/unit/test_cua_computer_use.py b/tests/unit/test_cua_computer_use.py deleted file mode 100644 index dc8bb6aa3e..0000000000 --- a/tests/unit/test_cua_computer_use.py +++ /dev/null @@ -1,1458 +0,0 @@ -import asyncio -import base64 -import json -import shlex -from pathlib import Path - -import mcp -import pytest - -from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor -from astrbot.core.config.default import CONFIG_METADATA_3 -from astrbot.core.provider.func_tool_manager import FunctionToolManager - - -class FakeContext: - def __init__(self, config: dict): - self._config = config - - def get_config(self, umo: str | None = None): - return self._config - - -class FakeShell: - def __init__(self): - self.commands = [] - - async def run(self, command: str, **kwargs): - self.commands.append((command, kwargs)) - return {"stdout": "ok", "stderr": "", "exit_code": 0} - - -class ProcessShapeShell: - async def run(self, command: str, **kwargs): - return {"output": "shape-ok", "returncode": 0} - - -class CommandResultShapeShell: - def __init__(self, stdout: str = "shape-ok", stderr: str = "", returncode: int = 0): - self.commands = [] - self.stdout = stdout - self.stderr = stderr - self.returncode = returncode - - @property - def success(self): - return self.returncode == 0 - - async def run(self, command: str, **kwargs): - self.commands.append((command, kwargs)) - return self - - -class FakePython: - async def run(self, code: str, **kwargs): - return {"output": "42", "error": ""} - - -class FakeFilesystem: - def __init__(self): - self.files = {} - - async def write_file(self, path: str, content: str): - self.files[path] = content - - async def read_file(self, path: str): - return self.files[path] - - async def delete(self, path: str): - self.files.pop(path, None) - - async def list_dir(self, path: str): - return [path] - - -class FakeMouse: - def __init__(self): - self.clicks = [] - - async def click(self, x: int, y: int, button: str = "left"): - self.clicks.append((x, y, button)) - return {"success": True} - - -class FakeKeyboard: - def __init__(self): - self.typed = [] - self.pressed = [] - - async def type(self, text: str): - self.typed.append(text) - return {"success": True} - - async def press(self, key: str): - self.pressed.append(key) - return {"success": True} - - -class FakeSandbox: - def __init__(self): - self.shell = FakeShell() - self.python = FakePython() - self.filesystem = FakeFilesystem() - self.mouse = FakeMouse() - self.keyboard = FakeKeyboard() - - async def screenshot(self): - return b"fake-png" - - -class SyncShell: - def __init__(self, stdout: str = "ok"): - self.commands = [] - self.stdout = stdout - - def run(self, command: str, **kwargs): - self.commands.append((command, kwargs)) - return {"stdout": self.stdout, "stderr": "", "exit_code": 0} - - -class FailingShell: - def __init__(self): - self.commands = [] - - async def run(self, command: str, **kwargs): - self.commands.append((command, kwargs)) - return { - "stdout": "", - "stderr": "python3: command not found", - "exit_code": 127, - "success": False, - } - - -class SandboxWithoutFilesystem: - def __init__(self): - self.shell = FakeShell() - self.python = FakePython() - - -class SyncPython: - def run(self, code: str, **kwargs): - return {"output": "sync", "error": ""} - - -def _agent_computer_use_items(): - return CONFIG_METADATA_3["ai_group"]["metadata"]["agent_computer_use"]["items"] - - -@pytest.mark.asyncio -async def test_get_booter_creates_cua_booter(monkeypatch): - from astrbot.core.computer import computer_client - - created = [] - - class FakeCuaBooter: - def __init__( - self, - image: str, - os_type: str, - ttl: int, - telemetry_enabled: bool, - local: bool, - api_key: str, - ): - created.append((image, os_type, ttl, telemetry_enabled, local, api_key)) - - async def boot(self, session_id: str): - self.session_id = session_id - - async def available(self): - return True - - monkeypatch.setattr( - computer_client, "_sync_skills_to_sandbox", lambda booter: asyncio.sleep(0) - ) - monkeypatch.setitem(computer_client.session_booter, "cua-test", None) - computer_client.session_booter.pop("cua-test", None) - monkeypatch.setattr( - "astrbot.core.computer.booters.cua.CuaBooter", - FakeCuaBooter, - raising=False, - ) - - ctx = FakeContext( - { - "provider_settings": { - "computer_use_runtime": "sandbox", - "sandbox": { - "booter": "cua", - "cua_image": "linux", - "cua_os_type": "linux", - "cua_ttl": 120, - "cua_telemetry_enabled": False, - "cua_local": True, - "cua_api_key": "", - }, - } - } - ) - - booter = await computer_client.get_booter(ctx, "cua-test") - - assert isinstance(booter, FakeCuaBooter) - assert created == [("linux", "linux", 120, False, True, "")] - - -def test_cua_ephemeral_kwargs_include_local_when_supported(): - from astrbot.core.computer.booters.cua import CuaBooter - - def ephemeral(image, ttl=None, telemetry_enabled=None, local=None): - return image, ttl, telemetry_enabled, local - - kwargs = CuaBooter( - ttl=120, telemetry_enabled=False, local=True - )._build_ephemeral_kwargs(ephemeral) - - assert kwargs == {"ttl": 120, "telemetry_enabled": False, "local": True} - - -def test_cua_ephemeral_kwargs_include_api_key_for_cloud_when_supported(): - from astrbot.core.computer.booters.cua import CuaBooter - - def ephemeral(image, local=None, api_key=None): - return image, local, api_key - - kwargs = CuaBooter(local=False, api_key="sk-test")._build_ephemeral_kwargs( - ephemeral - ) - - assert kwargs == {"local": False, "api_key": "sk-test"} - - -def test_cua_default_config_matches_booter_defaults(): - from astrbot.core.computer.booters.cua import CUA_DEFAULT_CONFIG, CuaBooter - from astrbot.core.config.default import DEFAULT_CONFIG - - booter = CuaBooter() - sandbox_defaults = DEFAULT_CONFIG["provider_settings"]["sandbox"] - - assert booter.image == CUA_DEFAULT_CONFIG["image"] - assert booter.os_type == CUA_DEFAULT_CONFIG["os_type"] - assert booter.ttl == CUA_DEFAULT_CONFIG["ttl"] - assert booter.telemetry_enabled == CUA_DEFAULT_CONFIG["telemetry_enabled"] - assert booter.local == CUA_DEFAULT_CONFIG["local"] - assert booter.api_key == CUA_DEFAULT_CONFIG["api_key"] - assert sandbox_defaults["cua_image"] == CUA_DEFAULT_CONFIG["image"] - assert sandbox_defaults["cua_os_type"] == CUA_DEFAULT_CONFIG["os_type"] - assert sandbox_defaults["cua_ttl"] == CUA_DEFAULT_CONFIG["ttl"] - assert ( - sandbox_defaults["cua_telemetry_enabled"] - == CUA_DEFAULT_CONFIG["telemetry_enabled"] - ) - assert sandbox_defaults["cua_local"] == CUA_DEFAULT_CONFIG["local"] - assert sandbox_defaults["cua_api_key"] == CUA_DEFAULT_CONFIG["api_key"] - - -@pytest.mark.asyncio -async def test_cua_config_log_does_not_include_api_key(monkeypatch): - from astrbot.core.computer import computer_client - - log_messages = [] - - class FakeCuaBooter: - def __init__(self, **kwargs): - self.kwargs = kwargs - - async def boot(self, session_id: str): - self.session_id = session_id - - async def available(self): - return True - - monkeypatch.setattr( - computer_client, "_sync_skills_to_sandbox", lambda booter: asyncio.sleep(0) - ) - monkeypatch.setitem(computer_client.session_booter, "cua-log-test", None) - computer_client.session_booter.pop("cua-log-test", None) - monkeypatch.setattr( - "astrbot.core.computer.booters.cua.CuaBooter", - FakeCuaBooter, - raising=False, - ) - monkeypatch.setattr(computer_client.logger, "info", log_messages.append) - - ctx = FakeContext( - { - "provider_settings": { - "computer_use_runtime": "sandbox", - "sandbox": { - "booter": "cua", - "cua_local": False, - "cua_api_key": "sk-secret-value", - }, - } - } - ) - - await computer_client.get_booter(ctx, "cua-log-test") - - assert log_messages - assert all("sk-secret-value" not in message for message in log_messages) - assert all("api_key" not in message for message in log_messages) - - -@pytest.mark.asyncio -async def test_get_booter_shuts_down_client_when_skill_sync_fails(monkeypatch): - from astrbot.core.computer import computer_client - - shutdowns = [] - - class FakeCuaBooter: - def __init__(self, **kwargs): - self.kwargs = kwargs - - async def boot(self, session_id: str): - self.session_id = session_id - - async def shutdown(self): - shutdowns.append(self.session_id) - - async def fail_sync(booter): - raise RuntimeError("sync failed") - - monkeypatch.setattr(computer_client, "_sync_skills_to_sandbox", fail_sync) - monkeypatch.setitem(computer_client.session_booter, "cua-sync-fail", None) - computer_client.session_booter.pop("cua-sync-fail", None) - monkeypatch.setattr( - "astrbot.core.computer.booters.cua.CuaBooter", - FakeCuaBooter, - raising=False, - ) - - ctx = FakeContext( - { - "provider_settings": { - "computer_use_runtime": "sandbox", - "sandbox": {"booter": "cua"}, - } - } - ) - - with pytest.raises(RuntimeError, match="sync failed"): - await computer_client.get_booter(ctx, "cua-sync-fail") - - assert len(shutdowns) == 1 - assert "cua-sync-fail" not in computer_client.session_booter - - -@pytest.mark.asyncio -async def test_cua_components_map_sdk_results(tmp_path): - from astrbot.core.computer.booters.cua import ( - CuaFileSystemComponent, - CuaGUIComponent, - CuaPythonComponent, - CuaShellComponent, - ) - - sandbox = FakeSandbox() - - shell_result = await CuaShellComponent(sandbox).exec("echo ok", cwd="/workspace") - python_result = await CuaPythonComponent(sandbox).exec("print(42)") - fs = CuaFileSystemComponent(sandbox) - await fs.write_file("hello.txt", "hello") - read_result = await fs.read_file("hello.txt") - screenshot_path = tmp_path / "screen.png" - gui = CuaGUIComponent(sandbox) - screenshot_result = await gui.screenshot(str(screenshot_path)) - click_result = await gui.click(10, 20, button="right") - type_result = await gui.type_text("hello") - press_result = await gui.press_key("Enter") - - assert shell_result["stdout"] == "ok" - assert python_result["data"]["output"]["text"] == "42" - assert read_result["content"] == "hello" - assert screenshot_path.read_bytes() == b"fake-png" - assert screenshot_result["mime_type"] == "image/png" - assert click_result["success"] is True - assert type_result["success"] is True - assert press_result["success"] is True - assert sandbox.mouse.clicks == [(10, 20, "right")] - assert sandbox.keyboard.typed == ["hello"] - assert sandbox.keyboard.pressed == ["Enter"] - - -@pytest.mark.asyncio -async def test_cua_list_dir_returns_entries_list_for_shell_fallback(): - from astrbot.core.computer.booters.cua import CuaFileSystemComponent - - sandbox = FakeSandbox() - delattr(sandbox, "filesystem") - - result = await CuaFileSystemComponent(sandbox).list_dir(".") - - assert result["success"] is True - assert result["entries"] == ["ok"] - assert sandbox.shell.commands[0][0] == "ls -1 ." - - -@pytest.mark.asyncio -async def test_cua_shell_filesystem_fallback_shell_quotes_paths(): - from astrbot.core.computer.booters.cua import CuaFileSystemComponent - - path = "folder/it's file.txt" - sandbox = FakeSandbox() - delattr(sandbox, "filesystem") - fs = CuaFileSystemComponent(sandbox) - - await fs.read_file(path) - await fs.delete_file(path) - await fs.list_dir(path) - - assert sandbox.shell.commands[0][0] == f"cat {shlex.quote(path)}" - assert sandbox.shell.commands[1][0] == f"rm -rf {shlex.quote(path)}" - assert sandbox.shell.commands[2][0] == f"ls -1 {shlex.quote(path)}" - - -@pytest.mark.asyncio -async def test_cua_write_file_shell_fallback_uses_python_base64_decoder(): - from astrbot.core.computer.booters.cua import CuaFileSystemComponent - - sandbox = FakeSandbox() - delattr(sandbox, "filesystem") - - await CuaFileSystemComponent(sandbox).write_file("hello.txt", "hello") - - command = sandbox.shell.commands[0][0] - assert "python3 -c" in command - assert "base64 -d" not in command - - -@pytest.mark.asyncio -async def test_cua_create_file_reports_mode_as_informational(): - from astrbot.core.computer.booters.cua import CuaFileSystemComponent - - sandbox = FakeSandbox() - - result = await CuaFileSystemComponent(sandbox).create_file("hello.txt", mode=0o600) - - assert result["success"] is True - assert result["mode"] == 0o600 - assert result["mode_applied"] is False - - -@pytest.mark.asyncio -async def test_cua_write_file_shell_fallback_propagates_shell_failure(): - from astrbot.core.computer.booters.cua import CuaFileSystemComponent - - sandbox = FakeSandbox() - sandbox.shell = FailingShell() - delattr(sandbox, "filesystem") - - result = await CuaFileSystemComponent(sandbox).write_file("hello.txt", "hello") - - assert result["success"] is False - assert "requires python3" in result["stderr"] - assert "python3: command not found" in result["stderr"] - assert result["path"] == "hello.txt" - - -@pytest.mark.asyncio -async def test_cua_edit_file_propagates_write_failure(): - from astrbot.core.computer.booters.cua import CuaFileSystemComponent - - class ReadableButFailingWriteShell: - def __init__(self): - self.commands = [] - - async def run(self, command: str, **kwargs): - self.commands.append((command, kwargs)) - if command.startswith("cat "): - return {"stdout": "hello old", "stderr": "", "exit_code": 0} - return { - "stdout": "", - "stderr": "permission denied", - "exit_code": 1, - "success": False, - } - - sandbox = FakeSandbox() - sandbox.shell = ReadableButFailingWriteShell() - delattr(sandbox, "filesystem") - - result = await CuaFileSystemComponent(sandbox).edit_file("hello.txt", "old", "new") - - assert result["success"] is False - assert result["stderr"] == "permission denied" - assert result["path"] == "hello.txt" - - -@pytest.mark.asyncio -async def test_cua_list_dir_shell_fallback_returns_filename_only_entries(): - from astrbot.core.computer.booters.cua import CuaFileSystemComponent - - sandbox = FakeSandbox() - sandbox.shell = SyncShell("alpha.txt\nfolder\n") - delattr(sandbox, "filesystem") - - result = await CuaFileSystemComponent(sandbox).list_dir(".", show_hidden=True) - - assert result["entries"] == ["alpha.txt", "folder"] - assert sandbox.shell.commands[0][0] == "ls -1A ." - - -@pytest.mark.asyncio -async def test_cua_shell_filesystem_fallback_rejects_non_posix_os_type(): - from astrbot.core.computer.booters.cua import CuaFileSystemComponent - - sandbox = SandboxWithoutFilesystem() - fs = CuaFileSystemComponent(sandbox, os_type="windows") - - read_result = await fs.read_file("hello.txt") - write_result = await fs.write_file("hello.txt", "hello") - delete_result = await fs.delete_file("hello.txt") - list_result = await fs.list_dir(".") - - for result in (read_result, write_result, delete_result, list_result): - assert result["success"] is False - assert ( - "filesystem shell fallback is only supported for POSIX" in result["error"] - ) - assert sandbox.shell.commands == [] - - -@pytest.mark.asyncio -async def test_cua_shell_and_python_accept_sync_sdk_methods(): - from astrbot.core.computer.booters.cua import CuaPythonComponent, CuaShellComponent - - sandbox = FakeSandbox() - sandbox.shell = SyncShell() - sandbox.python = SyncPython() - - shell_result = await CuaShellComponent(sandbox).exec("echo ok") - python_result = await CuaPythonComponent(sandbox).exec("print('ok')") - - assert shell_result["stdout"] == "ok" - assert python_result["data"]["output"]["text"] == "sync" - - -@pytest.mark.asyncio -async def test_cua_shell_normalizes_output_returncode_shape(): - from astrbot.core.computer.booters.cua import CuaShellComponent - - sandbox = FakeSandbox() - sandbox.shell = ProcessShapeShell() - - result = await CuaShellComponent(sandbox).exec("echo ok") - - assert result == { - "stdout": "shape-ok", - "stderr": "", - "exit_code": 0, - "success": True, - } - - -@pytest.mark.asyncio -async def test_cua_shell_normalizes_command_result_object_shape(): - from astrbot.core.computer.booters.cua import CuaShellComponent - - sandbox = FakeSandbox() - sandbox.shell = CommandResultShapeShell(stdout="hello\n", returncode=0) - - result = await CuaShellComponent(sandbox).exec("echo hello") - - assert result == { - "stdout": "hello\n", - "stderr": "", - "exit_code": 0, - "success": True, - } - - -@pytest.mark.asyncio -async def test_cua_shell_prefers_returncode_when_exit_code_is_none(): - from astrbot.core.computer.booters.cua import CuaShellComponent - - class ShellWithMixedExitCode: - async def run(self, command: str, **kwargs): - return { - "stdout": "", - "stderr": "", - "exit_code": None, - "returncode": 1, - } - - sandbox = FakeSandbox() - sandbox.shell = ShellWithMixedExitCode() - - result = await CuaShellComponent(sandbox).exec("false") - - assert result["exit_code"] == 1 - assert result["success"] is False - - -@pytest.mark.asyncio -async def test_cua_python_fallback_preserves_shell_command_result_stdout(): - from astrbot.core.computer.booters.cua import CuaPythonComponent - - sandbox = SandboxWithoutFilesystem() - sandbox.shell = CommandResultShapeShell(stdout="from python fallback\n") - delattr(sandbox, "python") - - result = await CuaPythonComponent(sandbox).exec("print('from python fallback')") - - assert result["success"] is True - assert result["output"] == "from python fallback\n" - assert result["data"]["output"]["text"] == "from python fallback\n" - - -@pytest.mark.asyncio -async def test_cua_shell_background_wrapper_detaches_via_python_subprocess(): - from astrbot.core.computer.booters.cua import CuaShellComponent - - sandbox = FakeSandbox() - - await CuaShellComponent(sandbox).exec( - "chromium https://example.com", background=True - ) - - command = sandbox.shell.commands[0][0] - assert command.startswith("python3 -c ") - assert "subprocess.Popen" in command - assert "start_new_session=True" in command - assert "p.pid" in command - assert "stdout=subprocess.DEVNULL" in command - assert "stderr=subprocess.DEVNULL" in command - assert "time.sleep(0.2)" in command - assert "'chromium https://example.com'" in command - assert "&" not in command - - -@pytest.mark.asyncio -async def test_cua_shell_background_rejects_non_posix_os_type(): - from astrbot.core.computer.booters.cua import CuaShellComponent - - sandbox = FakeSandbox() - - result = await CuaShellComponent(sandbox, os_type="windows").exec( - "start notepad", background=True - ) - - assert result == { - "stdout": "", - "stderr": "error: background shell execution is only supported for POSIX CUA images.", - "exit_code": 2, - "success": False, - } - assert sandbox.shell.commands == [] - - -@pytest.mark.asyncio -async def test_cua_upload_file_fallback_rejects_non_posix_os_type(tmp_path): - from astrbot.core.computer.booters.cua import ( - CuaBooter, - CuaFileSystemComponent, - CuaGUIComponent, - CuaPythonComponent, - CuaShellComponent, - _CuaRuntime, - ) - - local_file = tmp_path / "upload.txt" - local_file.write_text("hello", encoding="utf-8") - sandbox = SandboxWithoutFilesystem() - booter = CuaBooter(os_type="windows") - booter._runtime = _CuaRuntime( - sandbox_cm=object(), - sandbox=sandbox, - shell=CuaShellComponent(sandbox, os_type="windows"), - python=CuaPythonComponent(sandbox, os_type="windows"), - fs=CuaFileSystemComponent(sandbox, os_type="windows"), - gui=CuaGUIComponent(sandbox), - ) - - result = await booter.upload_file(str(local_file), "remote.txt") - - assert result["success"] is False - assert "filesystem shell fallback is only supported for POSIX" in result["error"] - assert sandbox.shell.commands == [] - - -@pytest.mark.asyncio -async def test_cua_download_file_shell_quotes_remote_path(tmp_path): - from astrbot.core.computer.booters.cua import ( - CuaBooter, - CuaFileSystemComponent, - CuaGUIComponent, - CuaPythonComponent, - CuaShellComponent, - _CuaRuntime, - ) - - class Base64Shell(FakeShell): - async def run(self, command: str, **kwargs): - self.commands.append((command, kwargs)) - return { - "stdout": base64.b64encode(b"hello").decode(), - "stderr": "", - "exit_code": 0, - } - - sandbox = SandboxWithoutFilesystem() - sandbox.shell = Base64Shell() - booter = CuaBooter() - booter._runtime = _CuaRuntime( - sandbox_cm=object(), - sandbox=sandbox, - shell=CuaShellComponent(sandbox), - python=CuaPythonComponent(sandbox), - fs=CuaFileSystemComponent(sandbox), - gui=CuaGUIComponent(sandbox), - ) - remote_path = "folder/it's file.txt" - local_path = tmp_path / "download.txt" - - await booter.download_file(remote_path, str(local_path)) - - assert sandbox.shell.commands[0][0] == f"base64 {shlex.quote(remote_path)}" - assert local_path.read_bytes() == b"hello" - - -@pytest.mark.asyncio -async def test_cua_download_file_fallback_rejects_non_posix_os_type(tmp_path): - from astrbot.core.computer.booters.cua import ( - CuaBooter, - CuaFileSystemComponent, - CuaGUIComponent, - CuaPythonComponent, - CuaShellComponent, - _CuaRuntime, - ) - - sandbox = SandboxWithoutFilesystem() - booter = CuaBooter(os_type="windows") - booter._runtime = _CuaRuntime( - sandbox_cm=object(), - sandbox=sandbox, - shell=CuaShellComponent(sandbox, os_type="windows"), - python=CuaPythonComponent(sandbox, os_type="windows"), - fs=CuaFileSystemComponent(sandbox, os_type="windows"), - gui=CuaGUIComponent(sandbox), - ) - - with pytest.raises(RuntimeError, match="filesystem shell fallback"): - await booter.download_file("remote.txt", str(tmp_path / "download.txt")) - - assert sandbox.shell.commands == [] - - -@pytest.mark.asyncio -async def test_cua_boot_cleans_up_sandbox_when_component_setup_fails(monkeypatch): - from astrbot.core.computer.booters import cua as cua_booter - - closed = [] - - class FakeSandboxContext: - async def __aenter__(self): - return FakeSandbox() - - async def __aexit__(self, exc_type, exc, tb): - closed.append((exc_type, exc, tb)) - - class FakeImage: - @staticmethod - def linux(): - return "linux-image" - - class FakeSandboxFactory: - @staticmethod - def ephemeral(image, **kwargs): - return FakeSandboxContext() - - class BrokenShellComponent: - def __init__(self, sandbox, os_type="linux"): - raise RuntimeError("component setup failed") - - original_import = __import__ - - def fake_import(name, globals=None, locals=None, fromlist=(), level=0): - if name == "cua": - - class FakeCuaModule: - Image = FakeImage - Sandbox = FakeSandboxFactory - - return FakeCuaModule() - return original_import(name, globals, locals, fromlist, level) - - monkeypatch.setattr("builtins.__import__", fake_import) - monkeypatch.setattr(cua_booter, "CuaShellComponent", BrokenShellComponent) - - booter = cua_booter.CuaBooter() - - with pytest.raises(RuntimeError, match="component setup failed"): - await booter.boot("session") - - assert len(closed) == 1 - assert booter._runtime is None - - -@pytest.mark.asyncio -async def test_cua_shell_background_reports_missing_python3_requirement(): - from astrbot.core.computer.booters.cua import CuaShellComponent - - sandbox = FakeSandbox() - sandbox.shell = FailingShell() - - result = await CuaShellComponent(sandbox).exec("firefox", background=True) - - assert result["success"] is False - assert "requires python3" in result["stderr"] - assert "python3: command not found" in result["stderr"] - - -@pytest.mark.asyncio -async def test_cua_python_fallback_reports_missing_python3_requirement(): - from astrbot.core.computer.booters.cua import CuaPythonComponent - - sandbox = SandboxWithoutFilesystem() - sandbox.shell = FailingShell() - delattr(sandbox, "python") - - result = await CuaPythonComponent(sandbox).exec("print('hello')") - - assert result["success"] is False - assert "requires python3" in result["error"] - assert "python3: command not found" in result["error"] - - -@pytest.mark.asyncio -async def test_cua_gui_reports_missing_mouse_or_keyboard(): - from astrbot.core.computer.booters.cua import CuaGUIComponent - - class SandboxWithoutGuiDevices: - async def screenshot(self): - return b"fake-png" - - gui = CuaGUIComponent(SandboxWithoutGuiDevices()) - - with pytest.raises(RuntimeError, match="mouse.*click"): - await gui.click(1, 2) - - with pytest.raises(RuntimeError, match="keyboard.*type"): - await gui.type_text("hello") - - with pytest.raises(RuntimeError, match="keyboard.*press"): - await gui.press_key("Enter") - - -@pytest.mark.asyncio -async def test_cua_gui_press_error_lists_probed_methods(): - from astrbot.core.computer.booters.cua import CuaGUIComponent - - class SandboxWithoutPress: - keyboard = object() - - gui = CuaGUIComponent(SandboxWithoutPress()) - - with pytest.raises(RuntimeError) as exc_info: - await gui.press_key("Enter") - - message = str(exc_info.value) - assert "keyboard.press" in message - assert "keyboard.key_press" in message - assert "keyboard.press_key" in message - - -@pytest.mark.asyncio -async def test_cua_gui_caches_component_methods_after_initialization(): - from astrbot.core.computer.booters.cua import CuaGUIComponent - - class CountingMouse: - def __init__(self): - self.click_lookups = 0 - self.clicks = [] - - def __getattribute__(self, name): - if name == "click": - object.__getattribute__(self, "__dict__")["click_lookups"] += 1 - return object.__getattribute__(self, name) - - async def click(self, x: int, y: int, button: str = "left"): - self.clicks.append((x, y, button)) - return {"success": True} - - class Sandbox: - def __init__(self): - self.mouse = CountingMouse() - - sandbox = Sandbox() - gui = CuaGUIComponent(sandbox) - - await gui.click(1, 2) - await gui.click(3, 4, button="right") - - assert sandbox.mouse.click_lookups == 1 - assert sandbox.mouse.clicks == [(1, 2, "left"), (3, 4, "right")] - - -def test_cua_capabilities_reflect_initialized_sandbox_gui_devices(): - from astrbot.core.computer.booters.cua import ( - CuaBooter, - CuaFileSystemComponent, - CuaGUIComponent, - CuaPythonComponent, - CuaShellComponent, - _CuaRuntime, - ) - - def set_runtime(booter, sandbox): - shell = CuaShellComponent(sandbox) - booter._runtime = _CuaRuntime( - sandbox_cm=object(), - sandbox=sandbox, - shell=shell, - python=CuaPythonComponent(sandbox), - fs=CuaFileSystemComponent(sandbox), - gui=CuaGUIComponent(sandbox), - ) - - booter = CuaBooter() - set_runtime(booter, FakeSandbox()) - - assert booter.capabilities == ( - "python", - "shell", - "filesystem", - "gui", - "screenshot", - "mouse", - "keyboard", - ) - - class ScreenshotOnlySandbox: - shell = FakeShell() - - async def screenshot(self): - return b"fake-png" - - set_runtime(booter, ScreenshotOnlySandbox()) - - assert booter.capabilities == ("python", "shell", "filesystem", "gui", "screenshot") - - -@pytest.mark.asyncio -async def test_cua_shutdown_clears_cached_components(): - from astrbot.core.computer.booters.cua import ( - CuaBooter, - CuaFileSystemComponent, - CuaGUIComponent, - CuaPythonComponent, - CuaShellComponent, - _CuaRuntime, - ) - - closed = [] - - class FakeSandboxContext: - async def __aexit__(self, exc_type, exc, tb): - closed.append(True) - - booter = CuaBooter() - sandbox = FakeSandbox() - booter._runtime = _CuaRuntime( - sandbox_cm=FakeSandboxContext(), - sandbox=sandbox, - shell=CuaShellComponent(sandbox), - python=CuaPythonComponent(sandbox), - fs=CuaFileSystemComponent(sandbox), - gui=CuaGUIComponent(sandbox), - ) - - await booter.shutdown() - - assert closed == [True] - assert await booter.available() is False - assert booter._runtime is None - - -def test_cua_tools_are_registered_as_builtin_tools(): - from astrbot.core.tools.computer_tools.cua import ( - CuaKeyboardTypeTool, - CuaMouseClickTool, - CuaScreenshotTool, - ) - - manager = FunctionToolManager() - - assert manager.get_builtin_tool(CuaScreenshotTool).name == "astrbot_cua_screenshot" - assert manager.get_builtin_tool(CuaMouseClickTool).name == "astrbot_cua_mouse_click" - assert ( - manager.get_builtin_tool(CuaKeyboardTypeTool).name - == "astrbot_cua_keyboard_type" - ) - - -def test_cua_runtime_tools_are_available_to_handoffs(): - manager = FunctionToolManager() - - tools = FunctionToolExecutor._get_runtime_computer_tools("sandbox", manager, "cua") - - assert "astrbot_cua_screenshot" in tools - assert "astrbot_cua_mouse_click" in tools - assert "astrbot_cua_keyboard_type" in tools - assert "astrbot_cua_key_press" not in tools - - -def test_runtime_tool_selection_treats_none_booter_as_empty(): - manager = FunctionToolManager() - - tools = FunctionToolExecutor._get_runtime_computer_tools("sandbox", manager, None) - - assert "astrbot_execute_shell" in tools - assert "astrbot_cua_screenshot" not in tools - - -def test_runtime_tool_selection_normalizes_cua_booter_case(): - manager = FunctionToolManager() - - tools = FunctionToolExecutor._get_runtime_computer_tools("sandbox", manager, "CUA") - - assert "astrbot_cua_screenshot" in tools - - -def test_cua_is_exposed_in_sandbox_config_metadata(): - items = _agent_computer_use_items() - booter = items["provider_settings.sandbox.booter"] - - assert "cua" in booter["options"] - assert "CUA" in booter["labels"] - assert "provider_settings.sandbox.cua_image" in items - assert "provider_settings.sandbox.cua_os_type" in items - assert "provider_settings.sandbox.cua_ttl" in items - assert "provider_settings.sandbox.cua_telemetry_enabled" in items - assert "provider_settings.sandbox.cua_local" in items - assert "provider_settings.sandbox.cua_api_key" in items - assert ( - items["provider_settings.sandbox.cua_api_key"]["condition"][ - "provider_settings.sandbox.cua_local" - ] - is False - ) - - -_PNG_BYTES = base64.b64decode( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" -) - - -@pytest.mark.asyncio -async def test_screenshot_tool_returns_image_and_sends_file(monkeypatch, tmp_path): - from astrbot.core.tools.computer_tools import cua as cua_tools - from astrbot.core.tools.computer_tools.cua import CuaScreenshotTool - - sent_messages = [] - - class FakeEvent: - unified_msg_origin = "umo" - role = "admin" - - async def send(self, message): - sent_messages.append(message) - - class FakeAstrContext: - event = FakeEvent() - context = FakeContext( - { - "provider_settings": { - "computer_use_runtime": "sandbox", - "computer_use_require_admin": True, - "sandbox": {"booter": "cua"}, - } - } - ) - - class FakeWrapper: - context = FakeAstrContext() - - class FakeGUI: - async def screenshot(self, path: str): - Path(path).write_bytes(b"fake-png") - return { - "success": True, - "path": path, - "mime_type": "image/png", - "base64": base64.b64encode(b"fake-png").decode(), - } - - class FakeBooter: - gui = FakeGUI() - - async def fake_get_booter(context, session_id): - return FakeBooter() - - monkeypatch.setattr(cua_tools, "get_booter", fake_get_booter) - monkeypatch.setattr(cua_tools, "get_astrbot_temp_path", lambda: str(tmp_path)) - - result = await CuaScreenshotTool().call(FakeWrapper(), send_to_user=True) - - assert isinstance(result, mcp.types.CallToolResult) - image_parts = [part for part in result.content if part.type == "image"] - text_parts = [part for part in result.content if part.type == "text"] - payload = json.loads(text_parts[0].text) - assert image_parts[0].data == base64.b64encode(b"fake-png").decode() - assert "base64" not in payload - assert Path(payload["path"]).exists() - assert sent_messages - - -@pytest.mark.parametrize( - "screenshot_shape", - [ - "data_url", - "path_string", - "save_object", - "base64_dict", - ], -) -@pytest.mark.asyncio -async def test_screenshot_tool_normalizes_supported_screenshot_shapes( - monkeypatch, - tmp_path, - screenshot_shape, -): - from astrbot.core.computer.booters.cua import CuaGUIComponent - from astrbot.core.tools.computer_tools import cua as cua_tools - from astrbot.core.tools.computer_tools.cua import CuaScreenshotTool - - sent_messages = [] - - class FakeEvent: - unified_msg_origin = "umo" - role = "admin" - - async def send(self, message): - sent_messages.append(message) - - class FakeAstrContext: - event = FakeEvent() - context = FakeContext( - { - "provider_settings": { - "computer_use_runtime": "sandbox", - "computer_use_require_admin": True, - "sandbox": {"booter": "cua"}, - } - } - ) - - class FakeWrapper: - context = FakeAstrContext() - - class SaveObject: - def save(self, output, format): - assert format == "PNG" - output.write(_PNG_BYTES) - - class FakeSandbox: - async def screenshot(self): - if screenshot_shape == "data_url": - encoded = base64.b64encode(_PNG_BYTES).decode() - return f"data:image/png;base64,{encoded}" - if screenshot_shape == "path_string": - source_path = tmp_path / "source.png" - source_path.write_bytes(_PNG_BYTES) - return str(source_path) - if screenshot_shape == "save_object": - return SaveObject() - return {"base64": base64.b64encode(_PNG_BYTES).decode()} - - class FakeBooter: - gui = CuaGUIComponent(FakeSandbox()) - - async def fake_get_booter(context, session_id): - return FakeBooter() - - monkeypatch.setattr(cua_tools, "get_booter", fake_get_booter) - monkeypatch.setattr(cua_tools, "get_astrbot_temp_path", lambda: str(tmp_path)) - - result = await CuaScreenshotTool().call(FakeWrapper(), send_to_user=True) - - assert isinstance(result, mcp.types.CallToolResult) - image_parts = [part for part in result.content if part.type == "image"] - text_parts = [part for part in result.content if part.type == "text"] - payload = json.loads(text_parts[0].text) - assert "base64" not in payload - assert payload["mime_type"] == "image/png" - assert Path(payload["path"]).read_bytes() == _PNG_BYTES - assert base64.b64decode(image_parts[0].data) == _PNG_BYTES - assert sent_messages - - -@pytest.mark.asyncio -async def test_screenshot_tool_can_opt_in_to_llm_image_content(monkeypatch, tmp_path): - from astrbot.core.tools.computer_tools import cua as cua_tools - from astrbot.core.tools.computer_tools.cua import CuaScreenshotTool - - class FakeEvent: - unified_msg_origin = "umo" - role = "admin" - - async def send(self, message): - pass - - class FakeAstrContext: - event = FakeEvent() - context = FakeContext( - {"provider_settings": {"computer_use_require_admin": True}} - ) - - class FakeWrapper: - context = FakeAstrContext() - - class FakeGUI: - async def screenshot(self, path: str): - Path(path).write_bytes(b"fake-png") - return { - "success": True, - "path": path, - "mime_type": "image/png", - "base64": base64.b64encode(b"fake-png").decode(), - } - - class FakeBooter: - gui = FakeGUI() - - async def fake_get_booter(context, session_id): - return FakeBooter() - - monkeypatch.setattr(cua_tools, "get_booter", fake_get_booter) - monkeypatch.setattr(cua_tools, "get_astrbot_temp_path", lambda: str(tmp_path)) - - result = await CuaScreenshotTool().call( - FakeWrapper(), send_to_user=False, return_image_to_llm=True - ) - - image_parts = [part for part in result.content if part.type == "image"] - text_parts = [part for part in result.content if part.type == "text"] - payload = json.loads(text_parts[0].text) - assert image_parts[0].data == base64.b64encode(b"fake-png").decode() - assert "base64" not in payload - - -@pytest.mark.asyncio -async def test_screenshot_tool_can_opt_out_of_llm_image_content(monkeypatch, tmp_path): - from astrbot.core.tools.computer_tools import cua as cua_tools - from astrbot.core.tools.computer_tools.cua import CuaScreenshotTool - - class FakeEvent: - unified_msg_origin = "umo" - role = "admin" - - async def send(self, message): - pass - - class FakeAstrContext: - event = FakeEvent() - context = FakeContext( - {"provider_settings": {"computer_use_require_admin": True}} - ) - - class FakeWrapper: - context = FakeAstrContext() - - class FakeGUI: - async def screenshot(self, path: str): - Path(path).write_bytes(b"fake-png") - return { - "success": True, - "path": path, - "mime_type": "image/png", - "base64": base64.b64encode(b"fake-png").decode(), - } - - class FakeBooter: - gui = FakeGUI() - - async def fake_get_booter(context, session_id): - return FakeBooter() - - monkeypatch.setattr(cua_tools, "get_booter", fake_get_booter) - monkeypatch.setattr(cua_tools, "get_astrbot_temp_path", lambda: str(tmp_path)) - - result = await CuaScreenshotTool().call( - FakeWrapper(), send_to_user=False, return_image_to_llm=False - ) - - image_parts = [part for part in result.content if part.type == "image"] - text_parts = [part for part in result.content if part.type == "text"] - payload = json.loads(text_parts[0].text) - assert image_parts == [] - assert "base64" not in payload - - -@pytest.mark.asyncio -async def test_cua_tools_return_permission_error_without_gui_lookup(monkeypatch): - from astrbot.core.tools.computer_tools import cua as cua_tools - from astrbot.core.tools.computer_tools.cua import ( - CuaKeyboardTypeTool, - CuaMouseClickTool, - CuaScreenshotTool, - ) - - sent_messages = [] - - class FakeEvent: - unified_msg_origin = "umo" - role = "member" - - async def send(self, message): - sent_messages.append(message) - - class FakeAstrContext: - event = FakeEvent() - context = FakeContext({"provider_settings": {}}) - - class FakeWrapper: - context = FakeAstrContext() - - async def fail_gui_lookup(context): - raise AssertionError("GUI lookup should not run after permission failure") - - monkeypatch.setattr(cua_tools, "check_admin_permission", lambda *args: "denied") - monkeypatch.setattr(cua_tools, "_get_gui_component", fail_gui_lookup) - - assert await CuaScreenshotTool().call(FakeWrapper()) == "denied" - assert await CuaMouseClickTool().call(FakeWrapper(), x=1, y=2) == "denied" - assert await CuaKeyboardTypeTool().call(FakeWrapper(), text="hello") == "denied" - assert sent_messages == [] - - -@pytest.mark.asyncio -async def test_cua_tools_include_exception_type_for_blank_error(monkeypatch): - from astrbot.core.tools.computer_tools import cua as cua_tools - from astrbot.core.tools.computer_tools.cua import CuaMouseClickTool - - class BlankError(Exception): - def __str__(self): - return "" - - class FakeEvent: - unified_msg_origin = "umo" - role = "admin" - - class FakeAstrContext: - event = FakeEvent() - context = FakeContext( - {"provider_settings": {"computer_use_require_admin": True}} - ) - - class FakeWrapper: - context = FakeAstrContext() - - async def fail_gui_lookup(context): - raise BlankError() - - monkeypatch.setattr(cua_tools, "_get_gui_component", fail_gui_lookup) - - assert await CuaMouseClickTool().call(FakeWrapper(), x=1, y=2) == ( - "Error clicking CUA desktop: BlankError" - ) - - -@pytest.mark.asyncio -async def test_cua_mouse_click_tool_happy_path_forwards_args_and_serializes_json( - monkeypatch, -): - from astrbot.core.tools.computer_tools import cua as cua_tools - from astrbot.core.tools.computer_tools.cua import CuaMouseClickTool - - class FakeEvent: - unified_msg_origin = "umo" - role = "admin" - - class FakeAstrContext: - event = FakeEvent() - context = FakeContext( - {"provider_settings": {"computer_use_require_admin": True}} - ) - - class FakeWrapper: - context = FakeAstrContext() - - class FakeGui: - def __init__(self): - self.clicked_args = None - - async def click(self, x: int, y: int, button: str = "left"): - self.clicked_args = (x, y, button) - return {"status": "ok", "x": x, "y": y, "button": button} - - fake_gui = FakeGui() - get_gui_called = {"value": False} - wrapper = FakeWrapper() - - async def fake_get_gui_component(context): - get_gui_called["value"] = True - assert context is wrapper - return fake_gui - - monkeypatch.setattr(cua_tools, "_get_gui_component", fake_get_gui_component) - - result = await CuaMouseClickTool().call(wrapper, x=10, y=20, button="right") - - assert get_gui_called["value"] is True - assert fake_gui.clicked_args == (10, 20, "right") - assert json.loads(result) == { - "status": "ok", - "x": 10, - "y": 20, - "button": "right", - } - - -@pytest.mark.asyncio -async def test_cua_keyboard_type_tool_happy_path_forwards_args_and_serializes_json( - monkeypatch, -): - from astrbot.core.tools.computer_tools import cua as cua_tools - from astrbot.core.tools.computer_tools.cua import CuaKeyboardTypeTool - - class FakeEvent: - unified_msg_origin = "umo" - role = "admin" - - class FakeAstrContext: - event = FakeEvent() - context = FakeContext( - {"provider_settings": {"computer_use_require_admin": True}} - ) - - class FakeWrapper: - context = FakeAstrContext() - - class FakeGui: - def __init__(self): - self.typed_text_args = None - - async def type_text(self, text: str): - self.typed_text_args = (text,) - return {"status": "ok", "text": text} - - fake_gui = FakeGui() - get_gui_called = {"value": False} - wrapper = FakeWrapper() - - async def fake_get_gui_component(context): - get_gui_called["value"] = True - assert context is wrapper - return fake_gui - - monkeypatch.setattr(cua_tools, "_get_gui_component", fake_get_gui_component) - - result = await CuaKeyboardTypeTool().call(wrapper, text="Hello CUA") - - assert get_gui_called["value"] is True - assert fake_gui.typed_text_args == ("Hello CUA",) - assert json.loads(result) == {"status": "ok", "text": "Hello CUA"} diff --git a/tests/unit/test_cua_extracted_from_core.py b/tests/unit/test_cua_extracted_from_core.py new file mode 100644 index 0000000000..94798ab846 --- /dev/null +++ b/tests/unit/test_cua_extracted_from_core.py @@ -0,0 +1,57 @@ +import importlib.util + +from astrbot.core.config.default import DEFAULT_CONFIG + + +def test_core_no_longer_ships_concrete_sandbox_runtime_modules(): + assert importlib.util.find_spec("astrbot.core.computer.booters.cua") is None + assert ( + importlib.util.find_spec("astrbot.core.computer.booters.cua_defaults") is None + ) + assert importlib.util.find_spec("astrbot.core.tools.computer_tools.cua") is None + assert importlib.util.find_spec("astrbot.core.computer.booters.shipyard") is None + assert ( + importlib.util.find_spec("astrbot.core.computer.booters.shipyard_neo") is None + ) + assert importlib.util.find_spec("astrbot.core.computer.booters.boxlite") is None + assert importlib.util.find_spec("astrbot.core.computer.booters.bay_manager") is None + assert ( + importlib.util.find_spec("astrbot.core.computer.booters.shell_background") + is None + ) + assert ( + importlib.util.find_spec( + "astrbot.core.computer.booters.shipyard_search_file_util" + ) + is None + ) + assert ( + importlib.util.find_spec("astrbot.core.tools.computer_tools.shipyard_neo") + is None + ) + + +def test_core_default_config_does_not_include_runtime_specific_settings(): + sandbox = DEFAULT_CONFIG["provider_settings"]["sandbox"] + + assert sandbox == {"booter": ""} + assert "cua_image" not in sandbox + assert "cua_os_type" not in sandbox + assert "cua_ttl" not in sandbox + assert "cua_telemetry_enabled" not in sandbox + assert "cua_local" not in sandbox + assert "cua_api_key" not in sandbox + assert "shipyard_endpoint" not in sandbox + assert "shipyard_neo_endpoint" not in sandbox + assert "shipyard_neo_profile" not in sandbox + + +def test_core_sandbox_config_metadata_is_provider_agnostic(): + from astrbot.core.config.default import CONFIG_METADATA_3 + + items = CONFIG_METADATA_3["ai_group"]["metadata"]["agent_computer_use"]["items"] + booter = items["provider_settings.sandbox.booter"] + + assert booter["options"] == [] + assert booter["labels"] == [] + assert not any("shipyard" in key or "cua" in key for key in items) diff --git a/tests/unit/test_sandbox_computer_client.py b/tests/unit/test_sandbox_computer_client.py new file mode 100644 index 0000000000..45cde3cf21 --- /dev/null +++ b/tests/unit/test_sandbox_computer_client.py @@ -0,0 +1,218 @@ +import pytest + + +class FakeBooter: + async def available(self): + return True + + async def shutdown(self): + pass + + +class FakeProvider: + provider_id = "generic" + capabilities = {"shell"} + tool_names = {"generic_tool"} + + def build_create_config(self, context, session_id): + return {} + + def build_connect_info(self, sandbox_name, config): + return {"name": sandbox_name} + + def update_connect_info(self, record, *, sandbox_name): + return {"name": sandbox_name} + + def get_idle_timeout(self, context, session_id): + return 0 + + async def create_booter(self, context, session_id, sandbox_id, config): + return FakeBooter() + + async def destroy_booter(self, booter, record): + await booter.shutdown() + + +class OtherFakeProvider(FakeProvider): + provider_id = "other" + capabilities = {"filesystem", "python"} + + +class FakeContext: + def get_config(self, umo=None): + return { + "provider_settings": { + "computer_use_runtime": "sandbox", + "sandbox": {"booter": "generic"}, + } + } + + +@pytest.mark.asyncio +async def test_registered_generic_provider_handles_booter(monkeypatch, tmp_path): + from astrbot.core.computer import computer_client + from astrbot.core.computer.sandbox_manager import SandboxManager + from astrbot.core.computer.sandbox_registry import SandboxRegistry + + provider = FakeProvider() + manager = SandboxManager( + registry=SandboxRegistry(tmp_path / "sandbox_registry.json"), + providers={}, + ) + monkeypatch.setattr(computer_client, "sandbox_manager", manager) + monkeypatch.setattr(computer_client, "sandbox_registry", manager.registry) + monkeypatch.setattr(computer_client, "_sync_skills_to_sandbox", lambda booter: None) + + computer_client.register_sandbox_provider(provider) + booter = await computer_client.get_booter(FakeContext(), "session-a") + + assert isinstance(booter, FakeBooter) + assert computer_client.list_sandbox_providers() == [ + { + "provider_id": "generic", + "capabilities": ["shell"], + "tool_names": ["generic_tool"], + } + ] + + +def test_register_provider_rejects_duplicate_by_default(monkeypatch, tmp_path): + from astrbot.core.computer import computer_client + from astrbot.core.computer.sandbox_manager import SandboxManager + from astrbot.core.computer.sandbox_registry import SandboxRegistry + + manager = SandboxManager( + registry=SandboxRegistry(tmp_path / "sandbox_registry.json"), + providers={}, + ) + monkeypatch.setattr(computer_client, "sandbox_manager", manager) + + computer_client.register_sandbox_provider(FakeProvider()) + + with pytest.raises(RuntimeError, match="already registered"): + computer_client.register_sandbox_provider(FakeProvider()) + + +def test_register_provider_can_replace_when_requested(monkeypatch, tmp_path): + from astrbot.core.computer import computer_client + from astrbot.core.computer.sandbox_manager import SandboxManager + from astrbot.core.computer.sandbox_registry import SandboxRegistry + + manager = SandboxManager( + registry=SandboxRegistry(tmp_path / "sandbox_registry.json"), + providers={}, + ) + monkeypatch.setattr(computer_client, "sandbox_manager", manager) + replacement = FakeProvider() + replacement.capabilities = {"keyboard", "mouse"} + + computer_client.register_sandbox_provider(FakeProvider()) + computer_client.register_sandbox_provider(replacement, replace=True) + + assert computer_client.get_sandbox_provider_info("generic") == { + "provider_id": "generic", + "capabilities": ["keyboard", "mouse"], + "tool_names": ["generic_tool"], + } + + +def test_unregister_provider_rejects_active_managed_sandboxes(monkeypatch, tmp_path): + from astrbot.core.computer import computer_client + from astrbot.core.computer.sandbox_manager import SandboxManager + from astrbot.core.computer.sandbox_registry import SandboxRegistry + + manager = SandboxManager( + registry=SandboxRegistry(tmp_path / "sandbox_registry.json"), + providers={}, + ) + monkeypatch.setattr(computer_client, "sandbox_manager", manager) + computer_client.register_sandbox_provider(FakeProvider()) + manager.registry.upsert_sandbox( + sandbox_id="generic-1", + sandbox_name="Generic 1", + booter_type="generic", + provider="generic", + managed=True, + created_by_astrbot=True, + owner_user_id="session-a", + owner_session_id="session-a", + connect_info={}, + ) + + with pytest.raises(RuntimeError, match="active managed sandboxes"): + computer_client.unregister_sandbox_provider("generic") + + +def test_unregister_provider_allows_force(monkeypatch, tmp_path): + from astrbot.core.computer import computer_client + from astrbot.core.computer.sandbox_manager import SandboxManager + from astrbot.core.computer.sandbox_registry import SandboxRegistry + + manager = SandboxManager( + registry=SandboxRegistry(tmp_path / "sandbox_registry.json"), + providers={}, + ) + monkeypatch.setattr(computer_client, "sandbox_manager", manager) + computer_client.register_sandbox_provider(FakeProvider()) + manager.registry.upsert_sandbox( + sandbox_id="generic-1", + sandbox_name="Generic 1", + booter_type="generic", + provider="generic", + managed=True, + created_by_astrbot=True, + owner_user_id="session-a", + owner_session_id="session-a", + connect_info={}, + ) + + computer_client.unregister_sandbox_provider("generic", force=True) + + assert computer_client.get_sandbox_provider_info("generic") is None + + +def test_list_sandbox_providers_is_sorted(monkeypatch, tmp_path): + from astrbot.core.computer import computer_client + from astrbot.core.computer.sandbox_manager import SandboxManager + from astrbot.core.computer.sandbox_registry import SandboxRegistry + + manager = SandboxManager( + registry=SandboxRegistry(tmp_path / "sandbox_registry.json"), + providers={}, + ) + monkeypatch.setattr(computer_client, "sandbox_manager", manager) + + computer_client.register_sandbox_provider(OtherFakeProvider()) + computer_client.register_sandbox_provider(FakeProvider()) + + assert computer_client.list_sandbox_providers() == [ + { + "provider_id": "generic", + "capabilities": ["shell"], + "tool_names": ["generic_tool"], + }, + { + "provider_id": "other", + "capabilities": ["filesystem", "python"], + "tool_names": ["generic_tool"], + }, + ] + + +@pytest.mark.asyncio +async def test_cleanup_registered_sandbox_manager(monkeypatch, tmp_path): + from astrbot.core.computer import computer_client + from astrbot.core.computer.sandbox_manager import SandboxManager + from astrbot.core.computer.sandbox_registry import SandboxRegistry + + provider = FakeProvider() + manager = SandboxManager( + registry=SandboxRegistry(tmp_path / "sandbox_registry.json"), + providers={provider.provider_id: provider}, + ) + monkeypatch.setattr(computer_client, "sandbox_manager", manager) + + await manager.create_sandbox(None, "session-a", "generic") + await computer_client.cleanup_managed_sandboxes() + + assert manager.list_sandboxes() == [] diff --git a/tests/unit/test_sandbox_manager.py b/tests/unit/test_sandbox_manager.py new file mode 100644 index 0000000000..ef31fe11c4 --- /dev/null +++ b/tests/unit/test_sandbox_manager.py @@ -0,0 +1,147 @@ +import asyncio + +import pytest + +from astrbot.core.computer.sandbox_manager import SandboxManager +from astrbot.core.computer.sandbox_registry import SandboxRegistry + + +class FakeBooter: + def __init__(self): + self.shutdown_calls = 0 + self.available_result = True + + async def available(self): + return self.available_result + + async def shutdown(self): + self.shutdown_calls += 1 + + +class FakeProvider: + provider_id = "generic" + capabilities = {"shell", "python", "filesystem", "screenshot", "mouse", "keyboard"} + tool_names = {"astrbot_generic_screenshot"} + + def __init__(self): + self.created = [] + self.destroyed = [] + self.idle_timeout = 0 + + def build_create_config(self, context, session_id): + return {"session_id": session_id} + + def build_connect_info(self, sandbox_name, config): + return {"name": sandbox_name, **config} + + def update_connect_info(self, record, *, sandbox_name): + info = dict(record.get("connect_info") or {}) + info["name"] = sandbox_name + return info + + def get_idle_timeout(self, context, session_id): + return self.idle_timeout + + async def create_booter(self, context, session_id, sandbox_id, config): + booter = FakeBooter() + self.created.append((session_id, sandbox_id, booter, config)) + return booter + + async def destroy_booter(self, booter, record): + self.destroyed.append((booter, record["sandbox_id"])) + await booter.shutdown() + + +def _manager(tmp_path, provider=None): + provider = provider or FakeProvider() + manager = SandboxManager( + registry=SandboxRegistry(tmp_path / "sandbox_registry.json"), + providers={provider.provider_id: provider}, + ) + return manager, provider + + +@pytest.mark.asyncio +async def test_manager_creates_default_sandbox_and_reuses_available_booter(tmp_path): + manager, provider = _manager(tmp_path) + + first = await manager.get_or_create_booter(None, "session-a", "generic") + second = await manager.get_or_create_booter(None, "session-a", "generic") + + assert first is second + assert len(provider.created) == 1 + sandboxes = manager.list_sandboxes() + assert len(sandboxes) == 1 + assert sandboxes[0]["provider"] == "generic" + assert sandboxes[0]["capabilities"] == sorted(provider.capabilities) + assert sandboxes[0]["tool_names"] == sorted(provider.tool_names) + + +@pytest.mark.asyncio +async def test_manager_creates_new_sandbox_when_default_busy(tmp_path): + manager, provider = _manager(tmp_path) + + await manager.get_or_create_booter(None, "session-a", "generic") + await manager.get_or_create_booter(None, "session-b", "generic") + + assert len(provider.created) == 2 + assert len(manager.list_sandboxes()) == 2 + + +@pytest.mark.asyncio +async def test_manager_switches_releases_takes_over_and_destroys(tmp_path): + manager, provider = _manager(tmp_path) + created = await manager.create_sandbox(None, "session-a", "generic", "Named") + + assert ( + manager.get_current_sandbox("session-a")["current_sandbox_id"] + == created["sandbox_id"] + ) + released = manager.release_current_sandbox("session-a") + assert released["controller_session_id"] is None + + switched = await manager.switch_current_sandbox_checked( + "session-a", created["sandbox_id"] + ) + assert switched["controller_session_id"] == "session-a" + + taken = manager.takeover_sandbox("session-b", created["sandbox_id"]) + assert taken["controller_session_id"] == "session-b" + + destroyed = await manager.destroy_sandbox("session-b", created["sandbox_id"]) + assert destroyed["sandbox_id"] == created["sandbox_id"] + assert provider.destroyed[0][1] == created["sandbox_id"] + assert manager.list_sandboxes() == [] + + +@pytest.mark.asyncio +async def test_manager_idle_cleanup_removes_temporary_sandbox(tmp_path): + provider = FakeProvider() + provider.idle_timeout = 0.01 + manager, provider = _manager(tmp_path, provider) + + await manager.get_or_create_booter(None, "session-a", "generic") + sandbox_id = manager.list_sandboxes()[0]["sandbox_id"] + manager.release_current_sandbox("session-a", sandbox_id) + + await asyncio.sleep(0.05) + + assert manager.registry.get_sandbox(sandbox_id) is None + assert provider.destroyed[0][1] == sandbox_id + + +@pytest.mark.asyncio +async def test_manager_cleanup_preserves_persistent_sandbox_records(tmp_path): + manager, provider = _manager(tmp_path) + created = await manager.create_sandbox(None, "session-a", "generic") + manager.update_sandbox_config( + created["sandbox_id"], + idle_timeout=None, + expires_at=None, + retention_policy="persistent", + ) + + await manager.cleanup_managed_sandboxes() + + assert manager.registry.get_sandbox(created["sandbox_id"])["status"] == "running" + assert provider.destroyed == [] diff --git a/tests/unit/test_sandbox_models.py b/tests/unit/test_sandbox_models.py new file mode 100644 index 0000000000..9957b8abf5 --- /dev/null +++ b/tests/unit/test_sandbox_models.py @@ -0,0 +1,61 @@ +import time + +import pytest + +from astrbot.core.computer.sandbox_models import SandboxRecord + + +def test_sandbox_record_round_trips_generic_capabilities_sorted(): + record = SandboxRecord.from_dict( + { + "sandbox_id": "sandbox-1", + "sandbox_name": "General Sandbox", + "booter_type": "generic-provider", + "provider": "generic-provider", + "managed": True, + "created_by_astrbot": True, + "capabilities": ["keyboard", "shell", "filesystem", "shell"], + } + ) + + assert record.capabilities == ["filesystem", "keyboard", "shell", "shell"] + payload = record.to_dict() + assert payload["sandbox_id"] == "sandbox-1" + assert payload["retention_policy"] == "temporary" + assert payload["status"] == "running" + assert payload["capabilities"] == ["filesystem", "keyboard", "shell", "shell"] + + +def test_sandbox_record_validates_required_strings(): + with pytest.raises(ValueError, match="sandbox_id"): + SandboxRecord.from_dict( + { + "sandbox_id": "", + "sandbox_name": "General Sandbox", + "booter_type": "generic-provider", + "provider": "generic-provider", + "managed": True, + "created_by_astrbot": True, + } + ) + + +def test_sandbox_record_reports_active_control_lease(): + now = time.time() + record = SandboxRecord.from_dict( + { + "sandbox_id": "sandbox-1", + "sandbox_name": "General Sandbox", + "booter_type": "generic-provider", + "provider": "generic-provider", + "managed": True, + "created_by_astrbot": True, + "controller_session_id": "session-a", + "lease_expires_at": now + 60, + } + ) + + assert record.has_active_lease(now=now) + assert record.is_controlled_by("session-a", now=now) + assert not record.is_controlled_by("session-b", now=now) + assert not record.has_active_lease(now=now + 120) diff --git a/tests/unit/test_sandbox_provider.py b/tests/unit/test_sandbox_provider.py new file mode 100644 index 0000000000..2c0201052e --- /dev/null +++ b/tests/unit/test_sandbox_provider.py @@ -0,0 +1,15 @@ +from typing import get_type_hints + +from astrbot.core.computer.sandbox_provider import SandboxProvider + + +def test_sandbox_provider_protocol_exposes_generic_runtime_contract(): + protocol_hints = get_type_hints(SandboxProvider) + assert protocol_hints["provider_id"] is str + assert protocol_hints["capabilities"] == set[str] + assert protocol_hints["tool_names"] == set[str] + + hints = get_type_hints(SandboxProvider.create_booter) + assert "context" in hints + assert hints["session_id"] is str + assert hints["sandbox_id"] is str diff --git a/tests/unit/test_sandbox_registry.py b/tests/unit/test_sandbox_registry.py new file mode 100644 index 0000000000..7175e95dc5 --- /dev/null +++ b/tests/unit/test_sandbox_registry.py @@ -0,0 +1,102 @@ +import json + +from astrbot.core.computer.sandbox_registry import SandboxRegistry + + +def _registry(tmp_path): + return SandboxRegistry(tmp_path / "sandbox_registry.json") + + +def _upsert(registry, sandbox_id="generic-1", provider="generic"): + return registry.upsert_sandbox( + sandbox_id=sandbox_id, + sandbox_name=f"Sandbox {sandbox_id}", + booter_type=provider, + provider=provider, + managed=True, + created_by_astrbot=True, + owner_user_id="user-1", + owner_session_id="session-1", + connect_info={"name": sandbox_id}, + capabilities={"shell", "python", "filesystem"}, + ) + + +def test_registry_upserts_lists_and_deletes_sandboxes(tmp_path): + registry = _registry(tmp_path) + + record = _upsert(registry) + + assert record["sandbox_id"] == "generic-1" + assert record["capabilities"] == ["filesystem", "python", "shell"] + assert registry.get_sandbox("generic-1")["sandbox_name"] == "Sandbox generic-1" + assert [item["sandbox_id"] for item in registry.list_sandboxes()] == ["generic-1"] + + registry.delete_sandbox("generic-1") + + assert registry.get_sandbox("generic-1") is None + assert registry.list_sandboxes() == [] + + +def test_registry_tracks_provider_defaults_and_current_session(tmp_path): + registry = _registry(tmp_path) + _upsert(registry, "generic-1", provider="generic") + _upsert(registry, "other-1", provider="other") + + registry.set_default_sandbox_id("generic-1") + registry.set_current_sandbox_id("session-a", "generic-1") + + assert registry.get_default_sandbox_id("generic") == "generic-1" + assert registry.get_default_sandbox_id("other") is None + assert registry.get_current_sandbox_id("session-a") == "generic-1" + + registry.delete_sandbox("generic-1") + + assert registry.get_current_sandbox_id("session-a") is None + + +def test_registry_acquires_releases_and_takes_over_leases(tmp_path): + registry = _registry(tmp_path) + _upsert(registry) + + assert registry.acquire_lease( + sandbox_id="generic-1", session_id="session-a", user_id="user-a", ttl=60, now=10 + ) + assert not registry.acquire_lease( + sandbox_id="generic-1", session_id="session-b", user_id="user-b", ttl=60, now=20 + ) + + released = registry.release_lease("generic-1") + assert released["controller_session_id"] is None + assert registry.acquire_lease( + sandbox_id="generic-1", session_id="session-b", user_id="user-b", ttl=60, now=20 + ) + + taken = registry.takeover_lease( + sandbox_id="generic-1", session_id="session-c", user_id="user-c", ttl=60, now=30 + ) + assert taken["controller_session_id"] == "session-c" + + +def test_registry_saves_loads_and_reconciles_runtime_state(tmp_path): + registry = _registry(tmp_path) + _upsert(registry) + registry.acquire_lease( + sandbox_id="generic-1", session_id="session-a", user_id="user-a", ttl=60, now=10 + ) + registry.set_current_sandbox_id("session-a", "generic-1") + registry.save() + + loaded = _registry(tmp_path) + loaded.load() + assert loaded.get_sandbox("generic-1")["controller_session_id"] == "session-a" + + loaded.reconcile_startup() + + record = loaded.get_sandbox("generic-1") + assert record["status"] == "unknown" + assert record["controller_session_id"] is None + assert loaded.get_current_sandbox_id("session-a") is None + + payload = json.loads((tmp_path / "sandbox_registry.json").read_text()) + assert "sandboxes" in payload From 252ccd0d91782efaf0cf24b5bd8d35cef61cc559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sat, 9 May 2026 13:05:00 +0900 Subject: [PATCH 002/155] fix(dashboard): restore sandbox management UI --- astrbot/dashboard/routes/config.py | 30 +- .../mdi-subset/materialdesignicons-subset.css | 6 +- .../materialdesignicons-webfont-subset.woff | Bin 18600 -> 18632 bytes .../materialdesignicons-webfont-subset.woff2 | Bin 14984 -> 14992 bytes .../i18n/locales/en-US/core/navigation.json | 1 + .../i18n/locales/en-US/features/sandbox.json | 105 +++ .../i18n/locales/ru-RU/core/navigation.json | 3 +- .../i18n/locales/ru-RU/features/sandbox.json | 105 +++ .../i18n/locales/zh-CN/core/navigation.json | 1 + .../i18n/locales/zh-CN/features/sandbox.json | 105 +++ dashboard/src/i18n/translations.ts | 6 + .../full/vertical-sidebar/sidebarItem.ts | 5 + dashboard/src/router/MainRoutes.ts | 5 + dashboard/src/views/SandboxManagementPage.vue | 892 ++++++++++++++++++ tests/test_dashboard.py | 29 + tests/test_sandbox_frontend_contract.py | 24 + 16 files changed, 1312 insertions(+), 5 deletions(-) create mode 100644 dashboard/src/i18n/locales/en-US/features/sandbox.json create mode 100644 dashboard/src/i18n/locales/ru-RU/features/sandbox.json create mode 100644 dashboard/src/i18n/locales/zh-CN/features/sandbox.json create mode 100644 dashboard/src/views/SandboxManagementPage.vue create mode 100644 tests/test_sandbox_frontend_contract.py diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py index 2cef1b1fb4..fad3f99d1d 100644 --- a/astrbot/dashboard/routes/config.py +++ b/astrbot/dashboard/routes/config.py @@ -9,6 +9,7 @@ from quart import request from astrbot.core import astrbot_config, file_token_service, logger +from astrbot.core.computer import computer_client from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.config.default import ( CONFIG_METADATA_2, @@ -536,7 +537,9 @@ async def delete_ucr(self): async def get_default_config(self): """获取默认配置文件""" - metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) + metadata = ConfigMetadataI18n.convert_to_i18n_keys( + self._inject_sandbox_provider_options(copy.deepcopy(CONFIG_METADATA_3)) + ) return Response().ok({"config": DEFAULT_CONFIG, "metadata": metadata}).__dict__ async def get_abconf_list(self): @@ -570,13 +573,17 @@ async def get_abconf(self): if system_config: abconf = self.acm.confs["default"] metadata = ConfigMetadataI18n.convert_to_i18n_keys( - CONFIG_METADATA_3_SYSTEM + self._inject_sandbox_provider_options( + copy.deepcopy(CONFIG_METADATA_3_SYSTEM) + ) ) return Response().ok({"config": abconf, "metadata": metadata}).__dict__ if abconf_id is None: raise ValueError("abconf_id cannot be None") abconf = self.acm.confs[abconf_id] - metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3) + metadata = ConfigMetadataI18n.convert_to_i18n_keys( + self._inject_sandbox_provider_options(copy.deepcopy(CONFIG_METADATA_3)) + ) return Response().ok({"config": abconf, "metadata": metadata}).__dict__ except ValueError as e: return Response().error(str(e)).__dict__ @@ -1427,12 +1434,29 @@ async def _get_astrbot_config(self): if provider.default_config_tmpl: provider_default_tmpl[provider.type] = provider.default_config_tmpl + self._inject_sandbox_provider_options(metadata) + return { "metadata": metadata, "config": config, "platform_i18n_translations": platform_i18n_translations, } + def _inject_sandbox_provider_options(self, metadata: dict) -> dict: + try: + items = metadata["ai_group"]["metadata"]["agent_computer_use"]["items"] + booter = items.get("provider_settings.sandbox.booter") + except KeyError: + return metadata + if not isinstance(booter, dict): + return metadata + + providers = computer_client.list_sandbox_providers() + options = [provider["provider_id"] for provider in providers] + booter["options"] = options + booter["labels"] = options.copy() + return metadata + async def _get_plugin_config(self, plugin_name: str): ret: dict = {"metadata": None, "config": None, "i18n": {}} diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 187348bcaf..21bd053f3d 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 260 icons */ +/* Auto-generated MDI subset – 261 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -304,6 +304,10 @@ content: "\F1C2B"; } +.mdi-cube-outline::before { + content: "\F01A7"; +} + .mdi-cursor-default-click::before { content: "\F0CFD"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index e8c5b375dd4cc3ed4bcf7a1e227531f3baaf88e2..ee0b3cbc8d309d17b6535bb9c76e8cef82cd6f7a 100644 GIT binary patch delta 18385 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%0AOQ*z})~yN@g3m*yCa(p3csfLn(n5E=$Esz8DE- zrASB$#-)5Po65{bLaG!Ch|#bX#b2C^2SfS%d)^%l34AD)Oy@N<8V>t^15!+ir{b}^ z6%9*%krxsnmFX@14Ep`Uz+X)640kuRQ!y0_Rk+{*YFUu8;VX!2%t5d*$Hi2SO|N@D z#G!NQEoPx0AI}*^?&Fg1XgT|6wp`A>EoYn-gTbg2{P(xM5(@@Np`5)pTNYwvEWe1G zV}B>5L`euqrjfgtGeqxyQqF)&e+#`Mq(a`Dy$Wkk1$l3cY2n}%#K~(=H6h`tOo=5j zD*J(%!1AEH07Ss0?ZuKFkz6UFmln4*HzIYVNE-yH@?3YWJikWzi`z?;Q|UD+5|P%@ zrz-G+QBP6el)M9OD>S2xMzmRg!|{9o+{mS%O^dKTsbbz+ne?cCiiQuzP$&+i8ZfHQ zc$1m$cIVgT=GNxB-MsnWwQED%PfCY*bBW5JOQv9Tm&}%T%CO0QBi|$+V!Aa4K>?uO zma_)@PWK6M(lQ+!oj4KFXhzftl*LHR2TM)RL>yq`1!i^q2SOwzD6yDwr{Xw^@p!sG zq!7q{@}2TM!s}gsk%i9)si>HCob-isJih2SN+F*O$skBxNHx!iuXjZjQr-v-$N;EW ziL+5F;msq5%`OD)Y}z#$S)C^)V+DZQJT=9yAf;enO`(0#%U9frryChk%0V|a74zrq zUL|ka`3g6Y6Y=R0pn0p*+RErD0xa?mZ^N@FHNH_hKoBH<+#9ro(WS2f22haq$)28| zfeLPuY7$VfJf^_kshS_^D_UiTK)yc4|RfL6CX>7Ai#?;j<;lP`dBfDj(9 z>2dowIv|Mkd%gW$Z!9yl3Dq8-B<60f*TM1wn?RI*T2`qr=y;+aUlfx?6g86B4ys^5 z%6=_Jx>VeDI|yBp+0kB(Xfk5t|+FSm_w@M*;Kea?01yAnALM&P07!w&1M>u!(wx9R%tS@1~X#v zlH&yNarr9G@3Kwok8pi-`2lv+oQdHe`2E}t__73`lA7{CNh41teN@&v|ESX zsRCX;0wZ4|Kp#|QQ*Z_1F^v8 z1%AQr4=Ul$<4HLDt@n+7=(@z(35Qc3hx5QQ^&SCPbeX ze)Xg0zI4Yg-~LP6Cjxazp{Lta};GB9#=A3rN@8k1->Goe{GRiJ_5ojekSRk;W^6$a) zL0Hb@Y1;j4`fL$$seZ0rSYC*vq6OH&NvddP3e5s(|F~SQmq|!16x8n{bN$x0)P)80 z`&c^qn~Ysd69TC5@s@<`&Dn-kS*af$L;0cG`?b*m(80k3bR?ybaP{grzp`-4p_H(7@h6BqQ z04Q}QcKfU3P1qN5(ivPQ_rau}wQg2C-vFevr0?P{-2raKlEojxnxb9|KzbeaZHV+M z8uSa;MU2Pi}J zAQ80(vbn{W0kS;ul|nYzU07b|CbNZ1v6z9=drL=3_rfVmq1`Uqt5e0^UHBt>_X$rD zfv#4#C2j>UW0N}usOc2y4Vk_}9P28ukH)%7s{Ft~KphW{cf#e0w->#CecQHSIP4B^ z8)QJhc)F;h#&akb4RG7R&8~H=1H>3bO;jx7*M%>?R5~?adEF?dhIbqO;*ESxBFUL+Vfz%1I zXRekybCn#^^7L`HAFUjJDQ%qHD5arvN`uS8%0;;!dOy%~C?NyVCLd-F?ko0{8{Fr3 ziLdhf=e|FyTyMR~^A&&4|G8P+t@jpRgH;fM)zJjH2raIf)!lyo-v4Pa)i^=8PQU*^ z^XMZRfMBGF#WfCVVbw!1C%N0WbBG&eaiR7T&SkoI*_ZeH*YMkaAzfVaqieW*o#S^X z^sj?+#Ug{K)WJ&~DLSBX3f&B70lh>xd)L~vNrz6wYc|y^!keIO$((+N3RTT31`~^r zB6xzz3?j4%fB48aIKj0F7z;~Lo{inIJ+(V{!yiw-cD$s^O8z)Aul^ zU&R}aAK8A(6v;o%E2A&s`5)Pz#a+b?^UHJO1w7t%Uy~xM-&T}Fg1wVBFqZAFN~0rr zg`TlDTINH`c_vi4Fi_bV4pIEK$1mt%#&5$K4{{f#eF0%Pt`+Jn`8i=x?S{LBZek3&B*X z=?@ayNBEUnU6zyM%U7_96U(hgDq`@W|2)t8l$dzb9|-u5iZR8<^XL5{Z`|r~Fgm{M zahLA_{Y_DS?Hd|3pzj@=P!#AliYV(Cr1v)X+t^&O3WedNGsBJ1@DiMyA?_vkYj3VB zxx@AlcAk`hT1Cmj$z0K-#&{Un01$7}`x3X)Zg=22>h8jF?{xOy1Ec2B&s04E(1sWy z_X=9~lMMn3LulGH)uyCJHibYDjabb<*nD z_6=_0ql&a0=6FRjomCiTO(fj=jdy%vbMq7LFfLxa`grssABjGGm7CZxt^xw2cwm8` z7a0ACqrpIz-+WH_1@^o5zu$Q0zv>20Jo07sYp7@H_Y%e2qGO&UhwHegoO^S5wxV~f zJ-QWt>~@1&k4{xK@!@JSY!70Mi}FVMz-wbjEiQCSDyZ{snne%0D2*)w(hK5&p)$Ir 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 AIRF3v diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index 2ebc5cc5115e36a31318b490a0d2824c8a1af746..058cf92fb07769c0d1c38949a926c9f5722ff472 100644 GIT binary patch literal 14992 zcmV;BI&Z~yPew8T0RR9106LHW3jhEB0EDOj06ISa0RR9100000000000000000000 z0000SC0LI)rl6m13U*duNSA|xq3_sBr7 zaRda!=8=J5;{YHz)3X1M32KZXymkO$Z4L`uR^!f`luV6CcLk+zpCLXEt?4e^v&KNDV-Orwqe2?pn&;@K-DxeQNSb_L=ZR4uvCB=oy6@!SX%`qChN4V6?k|(x9NzX74to6vJQ; zBKU|uJn7~?B#f{mmjNV1Lc+>>VJD203khKj?~WlBv4AE)r3xZy&Ebq=L)=9-T-rLj zgl~e?4xvtn9mb9+I69{NHS^`vex`eyX>S5U%d#z51x(VeVG-hHMw3P#$2YcuO&%Z9h#@N6W_pTBray179W%ebVtBtX-e$YZ=zP8U|$FfI;wY-2I z?g9X1UWzhJl?b>}wMxPDx_iU@_nEhq|8J0fklG+@&I|?2fB;F*2mmD=2|19Iv>>S8 z11Wh*Yp6X(owthK8i?YmDC!KUb>cf~^wvaI7kv%i~cdVB6xZFn%TXz@vDfn63^JO@~@Lj zi)@SmfAr2E=KpfuOW}7jC>qEbBZ9}$wJh>f-NZSI649QSrN}WZP zw7tldb{09(-Xd2zQ{+jPihOCLD3Goch0?V{kxza8Dt-r_Pf){80v^5;VT6>zg-a79 zO1c;^GQ^3KDPFuRN=n&EmC8|Lwp^52YCU>r?A0gVNhj&taYups?$dkVfkMwcSLFZx zdlmnCdUGhn##V-puUxoUDo99FOOvifrc8u9c}N->lw!qd5eRkWn5$lsCXHIPn#;i8 zrM2?|Q1*1f(?#YPH*P+s`(7T{H~e6=?$2+#U=9=wp7_)P1|M$^eF6ot5)#@cT8#Y^6b?$1=#cs5 zJFHEcBRX|@O`kpkh739CoO6!3?Y86lh5|SNk@pV;a1tUP_TiLt=}v3a3e%y(8A~lS z=pFAE+IxHhU?B1hc|w6riWG0`6%KDY;DEEvIOCjQ!_Lo10bGEH9dNiPS+Yx(Tkb9W z`n~OtL*6lH(7XG90(cK1_MHN_3=#Xw0vLvfO$$FB#y#=W#9mNL>C&e#t+KqBQQ20^ zs$5yjsY*Z0%aUVZZ4}F@dWscQtBO@sJBu|{6UDl!uZj)T^NUT@3yUq)M~iLM$BP}+ zZx_3&KPvWAe_iaW{;xPtv-)u8V;?(m%Pq&8oK7}PajF&`&L}9HJLQxMZ#wJJC6`?9 z1;vdzNpY)gPI0HMv$$9HQ}Lkg-{Mg{Iy^~}=2?ptFCO~R>wckltKoj}PGfEHUeoNu zhrLVjQM3Qy6Aq5g+e`69^IyeREg{7>E$-sGR>9#1C8eLzr1=Gh`>j-|KUP}luQLYy z+XV`{Hc1KSF8w9~dR+K>5eYzVbTAUhTulJ?hV&;>0~|Au^C-Dqmc8$}Dmh*YPK3-V zXe94q#K?q#I&TA1*H&y|MEvv`Vc@7SJIW&xv%o}kToZIbB767&p}MNS-YcwFrWV@A zEZ5~{Y_fK(Zm$6B*QV@(nrC&rV^eaDb6o(OP6z=D8vQwrr=Wo=A}6Cc7W>R?tT zY&B>OoyOcz0|G@@_%{qxKaPq<13J4y*t&_*xn8LRKAK?#s!6~}%T||I71GR2Qbth8 z^LCR1FaiZA$udH}aL`=q3a~D`Uzwv=HVv*T%Gbu~(*iZsA`Vrc)R0q^x%t+JHm9s$ zJM;SR{X17!^ebS<29Ax%YIU&ZSG{sk8_;ey?g}J-W=$R+(-%5q?m3)iqjI>-x?o%> zytT#ZsQ22XGhMf=(4IG2z9H6FcS3ymQ|-`Go-WP5gD3=i08~VUU4@x~AdHk2@t|k= z%2AP&CS1I-uPI1y689K#ZcQzr)-81Gg94WEDoYNiz$5W#p=LhsH#+)B)F)4$Zp{vc zfShdZ3waYpy%NvEp8OoacC660O%cbcJISZ1=X_|sA^%+Yo1cg1s-U`tR`xVMFZk&{ zjfzl}A|*uenxD{@Fe~hikv{jYk$Un-ZhQW{RXr=}uFEwp47hOt1JF6#A z1M=-!XSA8rrdp%~E~vie2#7kteTDPZTPGN*s!MN?1&Y942|ehWG7z| zSmL;jNFcrH7xiNngMpKuV|}@id%_unRMu;yu@TWGv-Jpnf>DF zeHa1`#wAJYC^Etj^(W+a{xwO=I40)U8X6-zXeeZKA*BtTDP$aPXH?(7(wgywe6?8e zEUqG#yD0=Y1u*YN=FR)>><{WC#b+TwBPs&!RPw{OsL#s}PGEuJ%99UiAg8V&I6jQY zk&j?va6bf19${YtI4EPpqzp;q&BPL(Ga}V(!|k(AZpfX7469ume^aLC^d~ksr(L?!bDH`rKix?Up zg4_Zt42>w#ZFCyp#}M^#3{90U2?b%b2D|ZDgJEosqX3ltlV;G;F_*YJSXlYMhT}4jv&tip(foWqE?T(47@n81;IxWaI9XtH2v@s z<5+^`K`J8iZJxRyYOr~rxI~JM$7qP)Po{aduIV|Rs<;sJRdK4I|j8elyI(5KE4z6Oki?b{V^liIns zeofW?(-Y8onpEw>k=SWJS{~F(MIwZMigA?%_Qa4&+VXe5vvD1UWwh!sN!`qBuaQ%j zIJ`>u*dfEnp{D3z^iVk)2~bZ(Wjk}VCW>F8fvm5ix`9B(m`Hk}!t_kThcGeWfz6~o zXr%2jsRMCf7QH@VNZaeUfbK*D38ui7C}$f>$0oI<(PC|QaxvnxV+j>cgdERwe7cL< z;)q^aMi9hOewU+QJaaGKa76xtObR)LR-XI@>IRP>?U3mQ0`S0k`;bBgLm(v7dN`2B za*qnq|11wtM+!Bq6NkPc{18F%+8KA+PQjC)0F(=LeMqm0o&du>PcTiq%H_E&cs}Qf z9r12)42$Z)3t3!wH#e|zLBVfqB4uzRkmcb~7a+UhoBL2vu1kwCobJC0%dI!KzEXl2 zHO9t!FPDa-K6E$-Fcnqx4a#J|<%|6ZWU_|MUgs!dP6nb11`QXKF{-tR5b}*92KqWz zpz?7}zx9~-#JtXyTX(b{kKtl=Hb(v1hsGnlC+$FbPo1O}68+p;WV8d{u7sL7CPxyy zStoe#z|$iV#qTtxt6#ray=vTqG+1k9yb*6gK{g+q+m6DbU(ne4#@T7!#WjG$$c`m= z0&P9ObsZML%L|@o`nd9{*GK)(;N2YW@~+{aKGU!#0C@v(cClb|ru`IBnPI6jNZLDw zYx}Txnj73Bh>6ZvX1@Vo$G|8Z(kcQ_DH&v^YIu+yXo-Pj8H!^Qy7lrp$d7VB=y5-~SV&5kc#QlUM3@oaM8yexMzVqi`!nHZ$P z>8OOa_VoF*!$obTxuG$!Rc4H$XR=a8r7g0>hm2_tMTMqE9U$hyw+&7k@wQUv5cLDr z?RSqrF7Y@FHMnvE_3s~>Pd=2bkH#nai}|@8FW}Qjt-`(g{qHf#d-gGuc|Vf`9f=k? zZ50CyiHWw12t~w%Dolb-cQ|ITKjbz!3FzFx zQw&1azW%2KV78WMowXMlkHdoYjkZ z5Q4t|w5PG;5Te@X7voLwxuWN;NBh{Q6;i5F3{p7a8#Rg;IVVwo2ni?xRTRsAK`bXS zG!?pIaw#-ewwTHX((-V(F2Dv25IG|f)!!JuMh>2U)xaKfDHL4Dsy$E$ro_9kQcbY3 zvsF&7d{+iFP>sHgPsaK;bE`u^?)kbDpU$K9Eg^pLB7`)YWTZ#HszYUxF%Kp?$oK$o zo9j@!sT_S3J{6m-9hv$6lZ)>|SY~KE>IoI~zmXC{6q#v7xO~|$_>$xB)OE$*d%0bPi-jn&ZBY2tm4=KbG^o0U+Ogeb zW-3+V#mUjXD#%AGk>h=Hl48t~3%SxjP71gM6INsiW(l6=XLQ9PL5)yJ=rI(C z8-b?Ak2_^{13aj!Z9#5k2&eUwn*`#S`BM5=qu3$9@;L&?5TnEEC|BZJ#PcDf=G8p7 zdu)EGt%tJHguJuEBkvB z4Z+T~Li&|zk=Ye?`@<9S@q2pSZA{gQUMwjZg6K>x6HFW!lH%3=#tU>YK8lo4G*4Yy zT=F~m2A8LY@f6ROaAWn8tG={QVmsQOeKX#Vo|iG=k7}&)BmN#o3lv1(u{+A>d6Q}| ze~(=;snpo*K7J{vL!KgLt_62l~^&VU~fBAmCzm_SuJ-#=)G|l%p zNkjekTySTUayUBB+Lx}qdG;XPI-Sb<9ijdD<@Izc#}nRdcKhv)==01g#$!qpg-(EJ zwz(4^xv$!N_sg%}ANl<^RSm3&6y_tX5*mcsz?=gP^e+xe?jPk(;p< zHtKm2$ft+5{cfEC(e5K)v(?ly)6#`JeA^j?(Tj| z9a-!Rh#;&9V>7_D$NK}!e;3(V;2KW3)EE}g>z-|=az*uRUU3b=rUbPY)h4k#xV4}E z#qk8q^Tlc=Tk$-R`9A;aps?p(4K>0q>Xu&aci-iy>u{xXs_eQPaU9{duC&u|(BkIX z@^7i~>20O-@1tY8@V0yEui47E?bXaL8IZuXhMtpas&5Jv?+*eo_u0l1C>U8HWr?Zz+>m`L9q>Aeww@1v z0DmXR?u&7(g?}jP^W-7HR}WA!lPQ~D|5m-aoyzYD;F|{1E3TlO^KgANxdC2BUKiz2 z^qg$~M@@YVle1&y=V#LS%TgMdgdAkF@A}0hl<)e-tzFHuvzzOvo%LLhC_b(a(!EF9 z&d9Fj%9_dNWIkzRGw{Niq;!SX$M9(+cysN%Cpld}+2!p-KuN)oU|VbZqTeE)DRz(+sC*rq_%N(=ZI*0-j|iPC-UY zvZ+-_*vd-O65MRAI%e9|v$u(n+~%WS!|ZpW{Yf(Of?mkON=Y*DuU1!Jh6iwX1@h*Qd=pfP9@3Vn(0>Advfik zCEeDY!o7lad#-;%XHS0`2(Z%d+5EkEujaZA*A3^M$9Vg5;4@E$xn~7;_SF}@mM5gH zl+;=%Sd!C=CVSaIZ&kltrSQOxp-w_gLDO8nS-6+AokG-G0O|+W!sXac1+oPn8p1=)@`zRA4IceP0ONaFDwJHT`675&1)iZ$5RZ=* z%Zci0tW0XMq2$}Mtc~^%p0eVXN+jg(u{>fsb`ZN^{K}Uo6Ua$7F-}VrQ^I7XJ5yVo@gK3O}S|mpYzu2Tc`_fsc=4_JP^hH(_d1@^Z?gM+IYB8nob|XGAr2sF^3Z6GQt$^G-HY}t-;Z;Cf8660D49}VX_-> z8W5N?ZFEB8Ze>Z46?Ij!>Ea~F+FP(L$VjYbcn`?t!7S3rd+-NqfuR<%kS67-QYy=p zQn9+i*DI-hcf~b-{x??azh2J{J54vf%6TImZwVx*6j_~;1;#xl%-=yStR4d%94QA9fKz? zSlJkzRFT2kk)0I4SzL3lOk*qD2;y!M%Y_W;^ZmRB>0gnHY8Uk}`#(jVYj9nuySm5} z2Zh0@s#mxuMAMDW-7j@hQGwk#{Po9>-v4N3pF?;%IwsDN;|G7etLO=yzQ1S(;t#kF z7O1MVuB!C_g7Vv=kEkZd8BC=P?G!L?Frf8G!u}7ex1iIH&5iEbA&-vQ%{}Q z5UseQv%7iHN~3TNR>b-li|g_Uh_zaMNoR768F`$w`BUQyBTt=Bs$a;gC@BsE15g|V z!UMd%?xW`8_spH59AIOxlmhha8$8j})p^Gctg%M=!oGFHV(R2m7*+3-q}s+4o*;fg zew)2_IZwB^#fWXwH8nUgGGryVlwp?RQGP|kF#a4T>VI#U$x!_Qmzn;LC;E@T1V2NG zCiE*1Z9`Cv(fT7-sL05SPt4A$O(`i&O)f5~BUtT=khIBl8)ckAj$T})lL-|9X9%KD zi1YO!1kx*38&%2e$o=cXgb)A9?ci2wqhb<7I#oehfl7B4RzwU(R1gsr!@op=Zw_W< zG&g4e#n1#D5J9(fPW9QxJ4i$45t7J|H7*Ps(z zs|sA$J}5U2paL-XC6x+UETpVWO|4bpgt--$$7;2)H31e@GtQGIu5_hqe1(Msl=Zb^ zmZ-rvn>gn-S-QBn`el~sLsyn85_5#IguPGKNi(>(L3Q;r#DYH4}ky+|`^3AMo zp17puM>#M)oXtlF@!6tyloN1Ve>@-u#fzMOzTxqJ6+z%K;W3%y%F~0%2^I126$!YB zYd{hN0T|&FRQDrhw;N*?48BZ0;!T-M7;yU6H$Wit}0N7XhZ(O;T{LGnlmz* zvmm{W($LYA@qCGv20X~M+|7O`fN)6GDdNFSplvjixiYTz{E97^cyxxjtlW~iqGAC6{QfjStbX<4b@&myxLKnM&fg{XDvp*MXaWDwODqQsZynQ!^f74u*im2xl%(+GNrjMcC5x4_7a5F3p56Hn04gk!=ng>Kg zy0pi5AtMfOWH|z|2y3`srMXU|2LSms-U9xl&G#J55xo=)Qd_KPpJtGmexgVmYS{Mk ze$$QwRVtIu7wVawp7)5AS>94t=ql#ptJvX+1@#vmxK5mGj&b*HK6%3RNs|;)k*Y{b zef@Qh7X6VUE}$f@tJANqD|mngh%Q|IGxbH-%w~WCej8%Uh(@Ft+91Io3WaFx4uKrV zZ4ih7jTKp+JE0T#igIF3Fc%w?Pz7G3Gj@WCW_yIIAlu-2eNZ4)m`pQJYhPRnObHMm z37r;@5@=8dN&{82YEDuTzQp-z|5o^x2tLj?ss(%jxP4F+vTrCcKXS+tI>#CFel~Tn zR(p9eZ-Iosz*HlF1Kue~nT(PQ$OmRASSm*es!=ek$?&l2@(tJA{Mc~aXM=v@7|C>p zz3Q>@u%7d=5Ayk#qkj*9U}+@5>Y3aw8$gXyB!wFc{5~7w=j+y(AzF)IgkxuF7$S7= zQ=lOYoTerzlwd?5JNzo4(Tok(SUp;B9QFs`2!GlE8w3#z8Eah%!i1PeDwGD8fl_v9 zcZTEU&hX zKdKh%;j*=b=F_@dn=QF5R`UHzlJF2NHzZv0()W_sw&V&_8-UTbWaA}^1khh*IXKo* zf7>u9M@1mkhXz>v2~&Tu-@V1d}{qe>l)VrOM!ank;p)O7RxeRCk~87lG%5sWZf)?L~kWbvK39W?No)k8v`(iMOr54(ytw;^AC&IamIG21 zsgf4Mk*W$kCC`I8L`q^n%sLX=ZEp|>{1SX5UKm`&$Y>PlM2JT7%&2edMQSH_Anq9M zS1J1U%9vDdl4CHqgUKQ1rN(?2rIo)o2nqTv0LS&>08YR)@&BC3*%iIL6>^JQ1E;dJ za-Ff{765CU>?KI_SdN8*E-OTaYL-LT$Iyj9YZ}ow4F%Q|;KYat5R9NXrn+^SAizxk z@;V30a}Ee8w=HG&ff$^&Asaz}7{JXUoro3|q!kG1^uT=99zAnVrGXmN;LK>q_jWDe zi~Sv9zz#wPtaC!AoLONkgvxH;b_GWn>W2h`{maZi$W{Y*zZ2L06+QoS<2L3k&W`>* zEvd8AxUYGY%TL{~g1v4!L+;6wm7Fyj%H9g|-Vd2wtBdx%PCjy=pSl?g>@8(Ld8 zq!pPzKw!L00G&`^W8e~6N-u%H&4G{l06Ut7&q9zB5!6ajV4l<<0s%)ag6yWkonofJ z07rwQR`V%|;F;o?z zjtTX`V>01Aj!tK_0y0OLE;md&b6U!l5;9-enKNI)&X)>8_PJB2t$sz>&1rzS1;99p zJ;ElZDc`D(_%e?(UuXe)O6*7l%iMg#pX5V=l@X?$$pt(i%ZD%bn8~!xY<{0v z6Jbwo!+ABqFD_@9mE4Hoyhn>>l|SZxRqD-!#WR*B>%@X?7Qv$AXRBKv)+H~U0gJib z(yPG_jSUzEmX$?4awmooG`vxlkmc5!75GB{f#cHc#b@wbCy6PHn?Ew$Jdi-#mGm=M;veCl2l$2UkWaE`( zkcLD*T7=fVXsiH%NMdtaf#*Bu4oC*<5C>Zs$$9;V5#SKF8q^Kww0em z%%Mr6kpc%OUR#gh%a_Ym>a`wAmTd2r-^eJsAwT!%GfzWDV1D7*%a%pq`Z3kjqD7xN z%lK!Vs$0Ifv+8xa`iQsgP|;d#G%3D~5h1gm&JH0lu#DrP;)1eLkm$N-QMb}KvB5lx)PvGWY+Z|ds-uMMId)oK z7wOvmGcbwz{)(IWaEND}AYEj%S0l7yRi?r9V)gWm>u`v>?ahO?I6C@WaOHSyQ1hHQ z?zwYff+)AEGPt5rqm4s>yv6_;^h0SOFieK!4Ia`*zEgP%$Vw|$BP)c^ik>YQy_JSK zSG5~mMuSFU*@v3k$lPj1`wWqd(njEot&V;_G|~x4{m|~;g@{7J-**qyU;pH{T6gMb z!c~KDbGRwoXoRVbiLy_FCLZN)NqWG3Ntj%u3M1)Ew;?0YBi?a}HZPX3g zZH_BRyO_4W<}+5zz?lv4$6sp?nmZK5N*P>_mf=S3s{^RZC zx@2r9Ry=A*Gcsd~G;v{;_JRJ^ycHqPKlnrd(&F5=5AgqVEfI+Q&xuV2BQ7LW1;PVw z=gyJ^7jlSlpP8W4%l>UXCbcS786}cK(f)aXSm|eNAa!A#nQye0wj8`rqg@e<2j|=5 zuc7meu2n;i!4j}^)QtsHJ#a6)@qeHdZCh~K9b5od;rt8oBu;FajAz;t-^y(UVj(3G zfX3LW5VzreX7>|sFc>T%%ff|ofYY2#qhR*}1d(&n2_d9N!rkRygpj*p*grxEYpt!Q zX!Z4H53_L!E4@ed;_!3`%a!qw-r+un(QCxmaDd9PO=vE4zDxN9a881M~3eIVd#vFwEnxu23 z8s089{>pjlGK?D}1VC?xuQ7gP5< zT=lO#ufDNA;tfIUFgGA$L2Z#gkBRgCU&MaE#78K6;xHoP6yzh)o?+|*-DREBFNKk@ z@AT00d-Pfb5T!?CId-m?<8~t}vse!c-2CCnGIuXH68*2^`t{N0sw1`ClJ43gs^^Xm zt~>sXKC+-2%pE9rh#o5BJ7InF-&*DS%Ib>2Ny;Z(fncVOyZkThHto*JG)gj8afgqytLr%X>)j2A2O z?-j{h2_{vu{nU}%l`r^r2Fojk<(cu*Yv*z**}zDCEbGU%^;hz|mHP`0Xalrhz@Ed0 zRVpfh@1Ew`<`HUSGeuVpY4UHZ)z`C1_hgn%0mUA0vM5bDyS%5S#)|sq>oi<){}*UQyQAB6r1CxPn7%hc3hB>7lH z7aKRL^+4M!dEBGxEK2wAK!G?`S(ln(A;9t-47^4(0;gjKPD2_T9WuOWlcD6-4-?GC%ehHjL?0yx2E@B#rVsK-dK`hH;b3fI2i1M77ArOkOS9 zCwIqAG)Y1yoFtGH0b=sq8F|0SG!4)p1`Qo@MyvEcyb{{`{1f3}t-bHumwJkTw_rU` zznySH$wJ6L$b@u&j_VQ*lVie2B9h>lNYjKmEo*=jqBweW=ONs6sEDfSzOJrL5cY>> zUeqA^r}qx!BymE$XHV^#?7bO#vo%kdinll$6ikW zp7Q-&`_o=1c{j6lkYqHVM@P|Ju zmtH-aClZFFDGREbo?0p&w_aX&?VsaG>1QuwRK3(3alNZ~`Zj0$(y8~}=CEcYcgR** zL0Xf1P(527*_^rVg-2B3h2^c@cckfkZ>>Mp?Asa6y6EF^^ySpRhx+!eOZm%^qf%?y zv?{*L{o}RHGkNW_hew^qlf0*V4TdM}Mrnubp&d;#=Lcx2kNsM`=lHK5$w8z27ykqS z5wamC@Q6s%IVaKg43&M3CP|<)Fi=Ux$h8b6;>c2}&>v?w1e}C-P9T)k9G_7>H^8!q ztk}%zL&aUcd7N`U*%Ng7ab)6}g>H_WwA=RR{)9joI?Uw?qX|gkI4@y`UBHIZZ2YS< zq?tHI0P*Q38HQ|F-v!6On(dYaAb1&4c1vmuYW{iLBFo2E^seyVR0LDSXU zDlA~bD@ool6Hpt~W$KF7b#1-}%mgHG6K#S7nCj!YDt6TeTwJrW`R2;t@;+G0en~@Xk82Mu=^`w7}(h$zuB;ALSO@wM2w-1EjC{Z2k zj{p%chIA!;!cE(n&4?tY5P*st{N)46ZVnF%1K8?4%*NZk;khFi`OWG4F=S?#BCX#s zHLNtETTqry^AXv{a>-$TCZv;qkcPm0=cbkViFf6}@TYZsEa(yof&vpcJ<}|bbxowj z!)V3XAO({LEYhFmYUFx<20-ZR?N*Cnlw1=pc9JAR2(JRPZjDTHVv~bIeA*C73G3Gg zjuwFY8^!5r*_^={`PvYlu6KNL?z?aO98u34{cgxk#lLZxdCz}Uek{~o)}^ph7@42+ zW*1GrXYP%!W4DXjhnYfsVIi|rvD_MFY@A7R3Th%~rM^>T2$1l$)qcEBN%`qS6!1_J zdnB%HaI?LO#K`~tzsH}S(or|NHI-6r@R9NT<^TVW#7>0N(Y9^uTsFe`m6RhF<3e(h zo8XmVw9^cL_1M_eAu6QpqE38FO1Xm`9OvZxIKX*jCfO9z!YwM|w!}1%nM%*K!jUYF zBaj6`(ajK6vqI^@h`)bEknXhl}Q0V3EBGq%hO?h*JR z%<`b|@hwcu?+sCqLOTn3P}$MJ#}PA{V!sjSSpkxIZ+X_&FJ4UObgrkwlf;#8*n+Or zrhM{*d?p|{RB$-u%AhWzEy0wI3vUV)a7gjOi(6dMG_Hqc!)qlJUC9RtnfjnjHUv|$ z9)9w`_29un*C!9hm#a1H&q5+6<%&dl49d9kG;$WHANJGF-EuQN*%;*R5nhrgr8&x| zs#-c-bn@}D)Ks1uC3__!>{?`(g84H{u2KJ?*2Km{lntb_mU{>UC+;7uG0xjm+`ebI91@fQRkKUT{zzOh&s_>-UY@3OzGet0te*1t|gkZ#2@#Zy6G zLr>JE*F|3Czn8^Afh7KAklO8)laWJA|(#5{QBJ)NnY5SYVhNXl_L2fRb#qFWvODBtR#Rj8Dq=&`E>QZkYDE@Cc3Y+(dArdf>e@#F_ zxbMMNv^o!#;F*9GY^FIoz7E(PM!U}MRI9{B<^R^+&K!#tlr~J~?-etN3jp4hwLEvV z>f)IWafBDD<&Sbk^T7k{UXRTFMk4P!rDdE=XIokTDJR%6S9)@Q?3oK`6#N)Mw6Rb0 zkE*_*=4c$lZL|o=cMdv8fyr#&|7I!ev;v{Nxqg4@*mP7(G^q>EvGYOz%D^JT8^Q zb;rFmId%aR33=tHoirHJp2`Z|vWrGV#dqJ`$Lx@0G=(k9!2?Uq5Ch&l87h6d={f*< zz-}J?cUEO%nqE1rX-QS4treRgrkss|2V-u?!roE(RDX@wSi*EIn{vKQa*ks454J+W z62F(+W_Xh|M`LqwDwO66EZ(%Dtg$C{pq)WZ+o*SS4|*lKrpdw9k;qmu^x%s_>o}0J zZJLp_YxMvkHCqK3I(Khx6&q`+#IfX_fvSt>aA{- zr{?qymV&=2RgTaj?rr&;MtLlDDTKE4XM4!^tYmuDg%0Rlvb9h%XCrv7r(txyQzKt^ ziZLNzofVkQv194MQ}hOBUCjruSLnrci~uoOO)u|!U_l3?g7|OFY}2&&>r#N0xkR|F zH_>99)#SeF(ZM$Hj;sWWU{CtmUD0y)7suL}m3d2<7;*EgOZz&rkbmw$JW335oIPbP zbs+w$`vCXul3L*(OVVZvXY%bY`ek*KLfrZ1vxEDb9&gh38EySX&y&G8l?_{MzBt;N z(P*9~!ah7}261K<4@P?9(VO06@9Tx0u~;rhwl8pOD>m@`(&=X&E*)%>I7((5zu{r8 z9L%Ehh9)Y~!1F)o*@tcUYLg{!{r-_g-`79%=G_b5p6J1t)@inVEq~pLthMy*7y;Y9 zjE)YXPx1A>Vg1%`ap26Sho4U$Y-?6i(;@QJHfd~tY^7W6Voa7CrL2q?n85p=HXVQ6 zT+SayW`G-IznK)v<2vxNPDxq*cFJ6+zpnrxOq-AYVj`LLQ;!~&XQR)Lzs3IMrpkbr z4TK^`eYvLG-W|H}u?8t!X02w!<iEX<#2(#LJNcp00A}vBm;yF1Rw>0LI)rlsca?em{GvS0b}Dw9cKoF zjRW$`#@dmA+C@2=+5dk}ZVVAVyRJ@~=>8KD7o(EsCcFMH0 zr7dlFn~o{jJbS5QS#uyht*_`$$?;bkHs+Xbhp)=vUGTvViBO#~xwt5YUewn(FfCIe z5#sGAH$?^oL`<h z8ipbF^So~ULBa@2@<_r6NJv<@7k0u}c_AT8S-$tcRg9lu5*$?!QQJIs9Gl>%mb&4^ z*4ZWe60CLzbrU;`9aC_0O+yfzdY`>J3}YCBB6hwuduOgQ?QIzBe~0K7&2B{;`Q6m> z`f|BK>u`l+RU)erSr-`KU%K7j-u~_?w7^qO;xyI#UYmV?tDRLz*)kXw@S|Zl;&~;N z-*nXaPC=2)4Y18o#Aa{X#M6lNn}|Z~C7VVK)GocC+%omd@U?w7!$^3^GPV=m>+6H@go$$LHlMN)?Jo z{1tOYX)47?ghcZHtMjWBKSg^scR8MZ_ZRIH?KZ{by=O~$4+#)pOTzYmoCG)s;q?d{ z7K6P9nNs zG`!SB!$;jV{M7SvD2=3#E0FrA#g*l9j=4#jO zIUPDR88gPf>7M!f3WeruHk^@quI%vN6 zUec=7Assp#)}zPE1`OzP)>*H(>82z53WcNl4he7!lI9nV%a-kg7A*)%EOF9OOP%tj zH}&srJOKtEnW1kOa57{#z2{MQ)d2^*=9E*;7&PeYoJfFkkgR~hdFj$!u-tMN+1R}9 zB`G9 zx-T2cy8jw0`qc-kKJ=kAH{7s3Ck{3?iN>Y@9c)RGWZMZR?0C%?yDqwDf6vf3FiIPT z#@Pjqj2#7zjXyL_jQ=!FP59tUmMrI*HM{V@=PvgX8rSA~jT=itfm`d0gS)+j#=Xt| z;DLa^Y!7X`*xij+hxp))lG3{@Sw4_RpDI=Q+DaUJJEh;x&ZF_?ls5jk zWX}oob^i525}-AFX9uYuY`n?F*Fyf^Lo>PwPmOOIUas7IeyaJ}gESUVQ@vZT*KtMa zXOPJ({^DBs*)606x>{0GKUP3Aa9vV07*eKqmt5%D??g83}!qCvJPhtj&8bYxMvgl*0keT}QREvNk2;E_{6i(J) zh(zcf`eet!k@S@n=t8h|Mx=ASs02RNiUp(wfsEByONta>Gd2m4K*f&RO%A{cWDx00 zPGc!=Fur8L+J(`}o2EL`B`<*R^UDJzlq3#SAQcx&m8sd*h&BaU*gDr^{dX*=-u{9L zAleo#Iq z>yZU7uODl?!53e4gS{+j-Sv{EOYmN;kpK#h16B?d&u?2vz|)QrddMkjt^^~uwxwb`M<5hq*wLf(X4 zFXFk|f}K3rWChmMgbZDW`SzcN%xPCc{<-*@pL=hLpqQ4g^r<^<{OLc93RjdQIV9qb zPP8_;Ek#he5E@H zkF8ZVys}Fw)6?{BoL`KG=}w=;JbvrV&%@J&H}&o$I2pLE&%Tn^sJr}7KzI9gVVygd z_Lc62bpS`D8JP}CRnkSgaMeuhf_H;9w{F#(hTVKx4bK0Y!;Am5=xR%av4lv#ed>4p zMZ0K_vCIGWFDB_1ok2)>tzJ5tP(EV^aAC}!4gs$d!N*;KM>-W--J}KqI2e~GYGIfg zE7w=eU;UfucgRy}CD}D>4r8;znOR|Y zNqO?#lpC4AN)rUk%ugwsasGVOWX9-J2qg*mucc_zZWggIAV}_J)gZ`W*t_J+!TEo= zGDfz_)i!`|6-UEVYp_&sB>E9uO9H5jFrIKDoZa>7opBUS;%HWiO{2x9+44q&TL`|o z0Qnp-EgViKK~gON_;x+axyBYRf~*I=>lVAlTfMRT7i-!hgfv(j?&0=Lz#Fk z-c5`W4lF{k=aEp6?h$5Wqb?&~o#?vaqXw`zo4KRGIoVDkU+=CfwdcWCenojXVP%vy%Z@c zEXJdk|B^O`F7X<3j@bp6y_^vJ{@{O3`!lb|&23uA{ut9h^xe9ZaQTz>JtC$DDD35% z9+TvKetC?3FT~+q1Y#$KkwZy{&yk19Sxtv=JSy9jD>TvRh4LJI57mtX(uSB;(-fw) z7@lMm5pK9t%0rA|r%c)yU)U)aMGWa{6&KKqAw%#2wjz4AaA9W_#gi}^m%@kx&!Il> zh~r^bQQXe&AT-gIQWry$yLUvvc$r>4p9uZJA%$FzRi0pjxy)fh56a?m0eIgzUJsDL z5C{qN9uDNOSEKy;XAXd6b44`2);e)$E5Z*Uq8-{Qxq9xQ2qr!r}rQVUn@$Ms|jLYZ_yPIR6rfb8=>-y=h~W=Q>qK6~&M zEOSS=9VGh^RmR5m2CsLtG?qjHrjmBF!^j0(uG|ZdH5XOTjfo~X8H_5>8ZIamRBF>9 z6rMU*{18-9j=u+oNKg!yz(Jov`&hg8ub67|=eW~*wv5o@R|X2v%V`{NnprYqN0aoF{1jlJ)j9dHV_ zDkMhs914^*R&ZN`g+go=3U(1U&Vf->OBScp8O15fLsdGd)d6x1arUvi)T<3hrHQ3A zA!+qSliVUK1>*r8A;kBcYninMV8xcdWXb7S>3CZRRq{ZDv{*Ql~u>7J^3|AYO#G_BRUg z=IZ2-^>fa)lMUq8XGj=xbj2sAo^F<{vqkmXx^S=T%iMZCzn%DR^yuT?WBkZt3}ZgP zB*6xviB4a{07Ft@4MRc+aghp>qKm5uuFoB*YQd~8f}dnhcksfEPx(plmo*lQY!CUq z3`x;8*7N%DLk82=r<){sqP&pr6dIbP5-8WoLv$hxp|!KJ{Ch639d`1UHD4m93sc-h z)lRmJOsI4y2T1!S!8U>7CDMIrm|a5;mPlnC3y8mAw{Zw_nn}k1j~0O&&PD1e7t&k| zeN!);bI|39`pW5oipz-C+}dzt(-JtuWzRz_CMmOr2(7M5VeH;841<#a;(o*)1e(4K zt9G(_AcsycScEOjUF#tT<^bB;SweiMGzP@@#`Li0;p^}LHqkmM6)6TOobZ(zC6v%d z7$89kia-^m@*HA0kzuLO9hFlN!$x;Ac_Hm}_v!+COrPo*(WrrZ$xn zb!jU}N+|J)D3t{D&{Vmk@Lf&PKrMWS?Xdop`o*Cj_k49qQ6ES3TSEGhT?lC~$(ddO zn+}yp&UrA|K*l?O&qBM}tIIJI;WN?Y+|i}}eQduEV_D+<7*OANAyHyTLQB4zMOVDa zh7#}6JhEw^iDODZ@k5K7zp0qeudK^7cpc} zJqJcYXHXz+6q*`8?v}X~aJ)}9saees4eKql8pKlzmGmiR)W9b$eMHHlx6T~~PlQKx zh;ByEJJuF{7RGO#L_)mO%bSs?fU}Vzw8vQ_CY+w%bD!MXZpDF++JhI_jg?zB)^1&2TcLWSgGlm8Ak6=7SDLS5K52)p z?fNlm;f6sF{_B6+)MZQL#1iP+H_36$;8RG?y@&HKRt|xc)SRX)pxG^yM_|q(f;Y%??1qwy+HvVtg*_2_)y?R*xkRb-=s2HUI3Ffo;Vd}XnRn>td9 zDEVH|pr^4Wlz>JvMcpixuY~ZO7XGz*2+Zx_;M^?mG;yKmoI5wRS@P!S(-d5mPBeHepRLzp4Hp7}8B=LN|K) z9&>{HI72aVcWFNC)+sPuPeOXT0cnh^Aytg(7w*MNJiPm|_3a?}X&C&b^K6(|%Z}3h zh)sK|2+`I*v!~V|Oe%Fb&oRheIJr&=<^i1s3YlgM6gdni`&?}$5HTthG}k(dyl)axk@I`` zu#gf-CeM}M?$KP4q-=F?8UET-*VqWxD67Sq+~T!<&9ucUg=wfG!Z5rFILf9v`Aue$ zF1$h_R#u{(!KNG2k?&j2UB)7N%_mR6?6;Kvo^cCNg9)nyC3tnG4AwzU#2_Ud782nY zz{Q9Z63C&=bt(3lkpx5 z5porweRExhEp{9BNvd>&dQy=>r>}MuI@_ecx6lXjw*W?M2y!ecoJQ(m;|=)XnlG=t z*|89x#I<22zo3h6p6Uo_&~T?^(gNQc_#R1v%y;pyJqJGAE~(#^EbOa$MWc;wr^>3< z_+C|!BcZXsA<;K!-C3mYjgg_Q{WltDQC&CV6S?MfEL#Im+s7U*#~oZCO-aWvAB8|y z7Gu{cmi`*5aG4ItiK5oNWj2i@DM&gfl6hg-=C}F0c79h1Ez4KkI+QnftygED(FxrLA)>t^GVb% zV}BNzj*_p>veDb8@thSuRb)l}9_u6aV~4Op@helJ%n&ErM2VHk)s@Li$*b2KXP)E* zbtdxk@rQv2E8Zuj#=7r{=BYq8sh!DlMCq_>_=$yhmXg>ob_Fzq1{&{Rq%$x|@G9WO zEoIND$CA$p{7j9vqDk_74+LiN6gk@=$ZKzyDvY9kgWf+ywG~5-1%ViwFeBTn(-d-@ zoFowoR?*0tiKfQuB&Q=8B5HOK-B>(H^MBJpK#C+MXh%eik=FBI8X@5p393mmJpAwU zL(#nyqDuKF9TN3|ns8G#v>|!)@m-}Jc?=R^@a}rNy2YZpAM^&y>b-qytA+}0Wx9A`fcChEamc|loWF>AQt{Hq6P#e zxUL#pmxWvXGb|6BwAx?1^^oH3c;WQvHZCdZ5 z#+4OYrscfIZ4nOqqQ3bL6d9>?Uhsf?9xNhl--Ew*S_SSQ>ooHM%k@m(wS#@RUJ>%< ziYvp_+YZ8)q9>9u)ml@H#kXrkKfL)d9*SZkPp_xb*QkDszZh-pl_|bn{HR5#<+5r4 zj(*rleVpw)wEP@hiUrO$K3ZO%I&s#XKd?y1FL0KQ*b3=1^kW9CON(PKsOOBfWefCH z=tgnl2sag#26KX16nBeQP9y6SS`hp2^3X56d z)%^6Gd=lZNH;bH+=#K4q^1v-B+g0!1bkG!_deU~3(;}C6_Y%VEDZ%l@&LR&A$x>Z07d1!mr79oY4QnSN zj^|)QJnhgS$ic*0t$sYnOsWts5*Kux|)iv9=tQEj_t-!UXH<#1B#IuIS@u} z>zwG>Uf>XmoXZG>_++Mn@DgmBRlg!E-a5=6Mq$ASL$nA=OI-a)RnGm4OCblK%{(!32W^2rwxv5c6E3A zs%~%Gm^_0$cG(?YKBFz^<(HG%l;sJ4p1};uAjV=TcxaKi9)C6MtH&;x*3Q!K5mu3M`R)x30_HVvhdR!2=irkZBd zTJN~TuEgaBf?br(bp?YX!b;GE9YG>5jxaJQFK&hjVHP$41}>X#n4d%4TchSv!6D3t zc}7qu5drhw{4m01e1WE@0(}W1U`k2~VQ<6}Xksj2K1fUm_?%Wp!!l~Mvp_BN^tf8l zPh7T!g7VG<=&==)iK^%hye;FBI=HFl38XrIr8)1xk1b?q*dOXGMJ3ZgX|q{B%bgSb zI|g1@9HdY;n5MprMd0;;*yS<;%hUy|LKrEAY1Wx^PY*=oP^1GnNDFe$18F2C0U(lS z28c*>S=Xr%TvUJ~&k>L}vqt*WSQ;gI0OXgq7Wk94*z;_fK#9J_nX$!I=io*=X*xW4& zJFweeNC8bR?){q~C-xN;R3kSR8Jtp!HmoysdKE3h1eZg$oAutHK-_CKcPX3u>|$U> zfEY{JtbmL_qdHI)sA5$y#z;j{=NFL5YDMi2(BSn4QDa{yx6*oRQ*!W4m-xxu2 zUEwcytURO_eCUIHJ{0I5!w@W+OOQR|J7ovdBttW#(J1P%b1?6+#tpEVL6gPF_#ndg z;1OUE3r;iR3}zTr$d9}{(^$?3_nuy{(< z#ngRD1FSI(9s01*^gcsvL#%goc|Zp5ez<#rA4PuJkiK>iDVo>n6lTL)`O!h z^*4<`a)DkTk5s>f5E81tS6qT(8P;Sc?+$x|v;#Kiw~a#_IsRxaczu*NxS!BU`Jb~q+bA2YSCBd$4aerYgu zQteQy)gY6YQ2(_rDFG_fq(T(s!Thr;k{lGU-R z0uK4B-Eri_?L^o$G<2|WowK+l_(T8DtFXUwggm@1 zTpqFCd%O3X*znBoImS~fF4^5cuI3+~6~yvDtGC~Z&*CFw5w(C*Z{>3|BBT1>KHWrT zP$$t1!it}DD=6Y($UEu$K|V`?^g9)c`^lFO_lQ_gCon=$FY7E@k}8KXnam-jG|CYG z^4h6qOMbZTMBRPCZgUEh7Dbhok| zkf~^utQ(0@RqL7KJQkB=Gy!79k;v}h0+HYy72&9X!6gctOcI?0vpD9HzP}f$ozNg| z3irDd_iIgjrZ+7x8r{JZ(DO3mUyRC{UuRVkbj^bEdQpHN;EL&ZJJa*4*Q}{lSQQ#% zJYTEOSxaFFSmTs!6pf!JunO?SML19`3WNh3n+;mCm?c@nkmJD7L{SK4r#WVpb(W$! zHv#xP4ptN#h+xv1%I|>`oVH^-ML`PSW{FP1MwDcgM6l)Q`OI+K)P9u)X;l4FY&9|=tpfb|`u{*So%C+BE`wEc5u z#C@BU)=_B+JfZQq$rFLgPh9JTJ$3q!^Omw*mQuwIow4xv@fyL48>(Ipmj%Cqtkf~L z4(mHtm+gD@)@bhwW#s5q*!kPsE?A*|ixrd#V*SMK%v13f{6S)rKPnv+KTCSj5a1@l zBH(0!wB|SIZ-7W6np02!E-8(m1?+6Csi{9-TRS|z9NHhS0t z>{u4vvY|Mkn3c5HV$>iCgJYM#aWl!CYhhu)*&r>|Ohzhxn)Fm0ym_58%p=VsOj`Gh z;Iv?buKz6Q8R&o!<^~XrIGQeRfGrA)5W*U!v5hkr(FTl0S?C3WVp+B*z07<2?!I&X z##~5Y{HgXXV8!6&pURn^W>fNKm42UeSCgucABoUM9Fa%d5$JTiy+Gy_uG5WKr%uZF zGD_|%KXvMJRQcjT=pJ`38tR@`b_r&o!V)l!;ScfYZpxeWAzyBpe@8J4l=wth%iOtG zG%i8{GNs0Ls;KZm#XyOJLh%XRhHRw#>jl&-sDcu&WN)gRSaJ0w#*Yci{)J|N5dcAH1i6ApIs7}l9K4bqU$CtDu2TN zvdlXKl}}lku8R_P@hBc+tX<+LUHZ}~dp9Y>TXq?JJRx8NEG=%DyOa5YWBaA7ba;3k zS+Z&QOw5wHPj7#XEDPAPVpW^Kp@+5tjg%3_Oq z9cDphuB(yRmNoAW$lvB6LV@NffRw#2r|0_U3x%`RUjVXogiet1nl#~TeZd@7@cPUP zgtP0C1^{qtmS!DTXktPfi&$qP;HWW}8J3BifGCAFF+p3Gpkq!B z>gsAFwg)`;;?aY3&X`vODeEn0%*bd^#msqU8DbG-%NJqT8gr5GF73Phe(;v9?gq`? zrHfr1<3hYI(Qa@g>RJ;+4u%jGvZky79@!gmAY|_{pqO9+nVrbZg@HFXG|eO=5vexe za8tpP88|_cco-oPK`<2UYTdTgW#NWtHZR@WP&s#QLuKAf9t(;T4i7PQS@wr%!?3&{2!%sYoO9G2a&s?%DjMUGqtCKAJ##zTc=}_J9Eu1z}r<)o5`Yk3-tBs?h zZW2`Jj3+ZfX#$pVVr*g=lgKRWl=Z9s{!V6Yj!%ql-pNw#duz0Qbji3}>674!yXs59 zpL{3PEs15z%AC6k%rjWsFB?SGHA@#oe9wtf`?zS=_8)D$&#JtQLYj_ql5oYy3< zVpXG|^-<~-=d2?U>Gm6kw>UELO>oUvLy%$iZ1=L+@j;CHc};Njphl~Jfzn2R4E_;W zCNNC8<&6Q-m%d|NGi39pt8p(vs24w7JaQuocdTkRxlBfl#<~wTxl>Dv1@AM)%#qCj zZ+vz1`-PF+mChX4{i}o-9P#V!ftgo7`lZ3$ZzSci(X=_z9BHz~+zP4lL!Uz=41SIt zUWH{97K3jAW@FPtXoWu@d(Wpfhu6v@XRQvCz3J0br6VNyC!w+W-;nHp{nBuSL?@Uo zt@U|>{po5}%UbhUxODEpA5^CAwymu$ism@j#$LU{gd0N&WnCwz<3Z%R!&O!F6w-z( z+QkKAA#W5SvFF?6#Zz(9T~b6$e5XUyfrww><`$kqwqzt1$Wa*bM-~KjmwXasIvat4 z4>kPLcsN8Z084_>?NRK&HG$xCE($oPG?TmKATi`agH=7t7%_T*KdRBx_XZTF4(HGJ zsR@zjRFp)xB>sH-5}|}r=_JB=@z4LEim&~r-n&T{QKF(F#A}pwac3`3JJ5U)-$I6G z_ZZsRe`8?t+}>&*`0ke-0NmsJ5>^+E+B= z&tLZ}Z9IQwb>Elg&o-m;9^Zy#wRi~@z?PgYXnQACWL?PGU;ha&zVFnA$7k^jrw|PMiKv{50u_?|RDh+`)g`Z6B)=RucO|Dk1DQ z?o}b9_ChSX{oR0Wei)o(W#NV}LwTtoJ*05$OBYSE%99?>$}$OKOEgJgmi9hdOVNtp zv)z9!fOJ&h8wW(cyB3S1{Le<2jV3aJR+U5?c%yKdJh)UqO%Is~Nxk@A7gJJe(khcw z`cj;KeGn`Aq!scO)S3Efdu8*%^Yz*l!DMjp7W+ADzR7LY*ln~1EFEzZ0k!wt3$FbO zTCw)FlWx2XSdsDz3M5W#9#7`lQ(rGMfLO$d_>eQUDkSZspT#}wvy4Wo#JXU?Y;aoA zX%y_9j}dl`I}t(*P2pV*g(w9U!{HHHvDVt^>K0#r{ve-ZsM4o2TppP%M*O#Y{W;=r z$DcQX5r0<4ckw}Dl(l8O3tQr1XQ3c^GT75t2F`;P2kn4(-0s>eh+b9Q(J?&Hu`sc? zzLE+Yzi=zXPm-P{xiin8#U(iDF{c2NzGm56nMSzFO+NKH&kOKvBe9N0_H7yIv2OY5 zch0&72Op1(K8~>656~=aCFj9qp7g9n0A@gCkw;$MaJ#3=sMD&JMsRrilG zo|&En-rmgBx2mNYk2|s2AA$mZ(8k@KqLJ2oc(K9DyTCJZO2d`6YwA>&RoXuTGy8*N zk@CpQ$P6m9h9ION=_`K7_2k8S@pAuMkscx?WNN;jIwoZ18~>TZ^NQzr=KS#PLj*N^ zFi{vwZPd2@N};!Mf5`!DfEN1iK6FT>5=H3U%U#(##0+f~(UrrN{AX+R)x63*xs?;3 z*aMB1Wyxkt@2;=!#kSeSDXop^${911vpq>K-v?FS?u=o*NVMqUt*ExP`;1om!i&nX zrZujs)74Awhy9MmLB-~h_4NczKUUEtBu;BN&^k?#_~<%~(LFd&5|yBA%*@yzK>8I1 zuQ7|^a_S&i#KPIZgPS%PE1o_rH^R-+zEwrfEZ2YIX?z5)Ub$lUB{<5$3ueE0%Qv)V z|B82RZh1PguY`B;PLSAe^Uq@*!a|9O*fm59@`Da9{k0foo*+EDqUZUJ5~IHCLtj6y zp(mc}eeX^As>v5TPjDS|_yE^F$3V*;&aA7|Onz2C{P>SMci-9J!)!{4_g`wM{e5k> zH8a#peB9<6l5JSx<&#)VE3@`pQkXR|t z0>UPc37i|}`qZ%zKQ2W+rHW$NIfXlQ;&B=|(Kv;BF_4h&&M9nW^CY0d93D92OjhZ4 zXeAo%@h?J$w8K4zpX<&6Z($u!-;HoZNg>1`B!nD<4r@{lv*WIb#xx}~)8@PCa)(C5 zU`C=>cbp>KK^0Y1-P76G0pWOL?gb5@e?{*wL7E`Ud+yM#$={o^H(&FVypU_mmok?_ zfl{;i?S7_Qiy>pKrKFrDNaAz~coLbhSj2d(CB|kEr*?{*=%E8U5dZ&&6(f!wv-&To zZ(Oz6-=~3j>ywJbFUv>5A|L#yTzdITkt8BCOIcFa^u${8sO8dvD}NnL%RX~Hr}jBR z^wmzod6(Um{=C3;B(U;lM0Kj3Ia3GZ+6pgd^;j} z7koTk`Cn$>1ATkv#o}e@v6=O)T9rud{&7v`nZ9@<&M@-{*o5bEzocC#(q=B-)AW9ob&!uQ3jxKBp z`7|k@&^Wqtf>53zIcNIZ0BH+(3AuGIm3RK)an}8KPteInF{yJFx;S!t*tbXbrvzo# zaF;8br4UQw+NfdF4K|);6J2Hz%cT(t)MtR^IJ#n0KLV?_S=%6ZJGLW=Xnl>2k#DEg z>K~gHpY3&ZuDaTfy<4;&)l8i`GQtoHtn8qV_~65$t3yx6JvEw{vox9`kKumJoew?$ zZD~x8+mH>jn5AetYadXIj}yf8(M5=aBu$r)2*Aw&fm3P;jFHGBgyd{6NRZ$$JEq~o zxPHf{j{YLKNXY1a&8M)=p>X9Ysq`ChgoFfY`0^ZUG z^#yUI1x4+ODD9;~FxE(r8LeA1&WG0*WX*`zWJcl`jg>K9nE9R0c7WmjWt)LoYVJ1&CPkBl1Co37# z={#qwS(BX1c32;ixN);t;<3>KB&QmZ(_Wv+iDE1w@R}uKMdc~fzdHn~vWdQ7NlMuG zF?z3PxK+mO+hwi8MGMCf%2_E}nWdCa{7b@1Asm29paNxk3Uq62Ep@i8ZsJjO?LH=k za$)3{XKlhwlV-Dx=^4k!e0Dd92-xuaDj%D>NE_Q}?u^qlZGH;eT||*)){H2a;uE_P zBfryVT*aD2l0>A1cu<6~-}R^v<23o|+Ws4DPrezHkz!s)PM85P>l?mh>_=X){Te4DTf zw{Q4+JsA7V?)W}*YIstX4VfCfEM{v^O2mpV-Nt&!5q~1IlR}6^;J$}sRr*EkDngM@ z8hb?0W!i!w6KUO(JeqffrA6Iv^_eIIRYWWj9~WvAdVfDa=*#WBR^tf$Vsey|ra4M^ z70|XdG$}|;0g1>-OE@KLUqd9G0MW6KO%}`c49O|hhWd2A=~HmeedEXIncOSi4EU+& zw@Yr>@?VtiOLdoY8T<@R?kBt6$+B--)+Cp)+lB3eT&cdalv|pVZVER##$~w|YAR@@ zzC&dUkP5doe7H}^_}N4h@laE{rLJXo*v>^0^nd^E@fT$*X`IoL$*4B?$VL8&fB&P2 zW1(ZTYzI4*4e@@a71+hOu!5E*c%vBZumE5^K5=<~32nWgi#j4>-0eCTY%|7B0M09O z>8AMRkg~Fn=J+N$R~figIGiVO`0_v~ycxl2Dnhm(I`9FCVq3G#wvx!B|Bm1cVHI#{ zo5Fz(=6DPIp7<9`|KG4W&M1%S_7@-8|K^+f4~e7pIb?~$%mf3P#+BV|9Nk68VKOOb zr~Z`Gre53&91un}l(S`OaJSeO6W0549^D|+{O&M=7`(Hj8&|#3|0sG&RU9yZo*5yj z_qylInTr-tI-TnY^*D9q18UHJpelVq{a8SO$`wdA0?U zCU70H81F0LxEc{eGWQ@muM3m24?eo@x_|$H>!Sx_%hi_l(U2<0ctx-9Dn>aHBB#!l|P>oekGST7{4}QIW ze*R~#pV*Fn?;j)4v^(=O>4`XSR(I^C(~_X_U(0G`Af`X{GrPUg(!CDyCQ zc~fs% zZSkw*dE<#Q-|)c}pD9{_(ysa_KpvhMo)IpCk`g%U8V>7UGZVxOsmR!eT-4F9}2tnOZi6}dnkX){4e++CI}TZ`x^SvN9MOep#Up}|VrbCrb=d8%C-J^jStZzXrnwnN zIblhm>|_V2Q|Gf7^f8QCQ;+JG)ZI{1N>aG>79zP~kA9CruNcwdR`LH%riHaCv^TKj z56FF2xrV=Iw5CiK{3gy#u3A3h?_B6y8TQVrsE~!t37cD@o0Gl)GK=L|@-umHmpkL` z*Zf@5;Mo8Ej?H|Q^sGdl`84^lj8F4rTCGeK<7&A+v~h!+&$O5^#&bbLUB(5|FImy` zpt3TJ(-WnHj_*d#t=T6-M`hB)uEcM6J?y)HErv#aQ#abOZ(jv(#YLmK`rSA8ktT|a zrqs-JcBHg&1$^^qx%Uo>s{t(C)E7Sblbtzu z^Qd#tePJKazCF@7=l7DjOi3o^e`e#GO-kwM`Q+d}XJ@j0pRx8Ykypt(Gfll7s#kgw zdwW4sVqIyy_#g_se=jlo zib^fDQY&6!6LX4oy>gT>=+I&*W6|2_$g?cF%_8OgXK{v>F--(Qvuvr`a`^`7Z926x S928VpEWIw3UWYuroH;fcy(i%S diff --git a/dashboard/src/i18n/locales/en-US/core/navigation.json b/dashboard/src/i18n/locales/en-US/core/navigation.json index 5cd5af13a3..35f05f36d3 100644 --- a/dashboard/src/i18n/locales/en-US/core/navigation.json +++ b/dashboard/src/i18n/locales/en-US/core/navigation.json @@ -20,6 +20,7 @@ }, "conversation": "Conversations", "sessionManagement": "Custom Rules", + "sandboxes": "Sandboxes", "console": "Console", "trace": "Trace", "alkaid": "Alkaid Lab", diff --git a/dashboard/src/i18n/locales/en-US/features/sandbox.json b/dashboard/src/i18n/locales/en-US/features/sandbox.json new file mode 100644 index 0000000000..7fe8760afe --- /dev/null +++ b/dashboard/src/i18n/locales/en-US/features/sandbox.json @@ -0,0 +1,105 @@ +{ + "title": "Sandbox Management", + "subtitle": "Inspect and operate managed sandboxes across providers.", + "actions": { + "create": "Create", + "refresh": "Refresh", + "inspect": "Inspect", + "setDefault": "Set default", + "configure": "Configure", + "switch": "Switch", + "takeover": "Take over", + "console": "Console", + "release": "Release", + "screenshot": "Screenshot", + "destroy": "Destroy", + "cancel": "Cancel", + "close": "Close", + "save": "Save" + }, + "metrics": { + "total": "Managed sandboxes", + "providers": "Providers", + "busy": "Busy leases", + "default": "Provider defaults" + }, + "labels": { + "default": "Default", + "busy": "Busy", + "available": "Available", + "noController": "No controller", + "unknown": "Unknown", + "none": "None", + "temporary": "Temporary", + "persistent": "Persistent" + }, + "headers": { + "sandbox": "Sandbox", + "provider": "Provider", + "lease": "Lease", + "lastUsed": "Last used", + "actions": "Actions" + }, + "fields": { + "provider": "Provider", + "booterType": "Booter type", + "status": "Status", + "owner": "Owner session", + "controller": "Controller session", + "retentionPolicy": "Retention policy", + "leaseExpires": "Lease expires", + "idleTimeout": "Idle timeout", + "expiresAt": "Expires at", + "connectInfo": "Connection info" + }, + "empty": { + "title": "No managed sandboxes", + "subtitle": "Create a managed sandbox or wait for a provider to register one." + }, + "create": { + "title": "Create sandbox", + "name": "Sandbox name", + "providerHint": "This provider is visible in the dashboard model but creation is not wired in this phase." + }, + "screenshot": { + "title": "Sandbox screenshot", + "noPreview": "Screenshot captured. Preview rendering will be added later." + }, + "console": { + "title": "Sandbox console", + "notice": "The console does not take over or release the lease, but commands directly affect this sandbox environment.", + "command": "Shell command", + "run": "Run command", + "output": "Command output", + "empty": "No commands executed yet.", + "running": "Running..." + }, + "config": { + "title": "Sandbox configuration", + "name": "Sandbox name", + "nameRequired": "Sandbox name is required", + "idleTimeout": "Idle timeout (seconds)", + "idleTimeoutHint": "With the temporary policy, sandboxes can be cleaned after this idle duration. Empty or 0 disables idle cleanup.", + "expiresAt": "Fixed expiration time", + "expiresAtHint": "Optional. The sandbox can be cleaned after this time." + }, + "tooltips": { + "takeover": "Bind the current dashboard session to this sandbox and obtain control. If another controller exists, control is transferred.", + "console": "Open a shell console without changing the sandbox lease." + }, + "destroyConfirm": { + "title": "Destroy sandbox?", + "message": "Destroy {name}? This shuts down the sandbox and removes its managed record." + }, + "messages": { + "loadFailed": "Failed to load sandboxes.", + "operationFailed": "Sandbox operation failed.", + "created": "Sandbox created.", + "defaultSet": "Default sandbox updated.", + "configSaved": "Sandbox configuration saved.", + "released": "Lease released.", + "takeover": "Current session now controls this sandbox.", + "destroyed": "Sandbox destroyed.", + "screenshot": "Screenshot captured." + } +} diff --git a/dashboard/src/i18n/locales/ru-RU/core/navigation.json b/dashboard/src/i18n/locales/ru-RU/core/navigation.json index 2abb189f48..94a86d91af 100644 --- a/dashboard/src/i18n/locales/ru-RU/core/navigation.json +++ b/dashboard/src/i18n/locales/ru-RU/core/navigation.json @@ -20,6 +20,7 @@ "cron": "Запланированные задачи", "conversation": "Данные диалогов", "sessionManagement": "Пользовательские правила", + "sandboxes": "Песочницы", "console": "Логи платформы", "trace": "Трассировка", "alkaid": "Alkaid Lab", @@ -46,4 +47,4 @@ "normal": "Обычная конфигурация", "system": "Системная конфигурация" } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/ru-RU/features/sandbox.json b/dashboard/src/i18n/locales/ru-RU/features/sandbox.json new file mode 100644 index 0000000000..7fe8760afe --- /dev/null +++ b/dashboard/src/i18n/locales/ru-RU/features/sandbox.json @@ -0,0 +1,105 @@ +{ + "title": "Sandbox Management", + "subtitle": "Inspect and operate managed sandboxes across providers.", + "actions": { + "create": "Create", + "refresh": "Refresh", + "inspect": "Inspect", + "setDefault": "Set default", + "configure": "Configure", + "switch": "Switch", + "takeover": "Take over", + "console": "Console", + "release": "Release", + "screenshot": "Screenshot", + "destroy": "Destroy", + "cancel": "Cancel", + "close": "Close", + "save": "Save" + }, + "metrics": { + "total": "Managed sandboxes", + "providers": "Providers", + "busy": "Busy leases", + "default": "Provider defaults" + }, + "labels": { + "default": "Default", + "busy": "Busy", + "available": "Available", + "noController": "No controller", + "unknown": "Unknown", + "none": "None", + "temporary": "Temporary", + "persistent": "Persistent" + }, + "headers": { + "sandbox": "Sandbox", + "provider": "Provider", + "lease": "Lease", + "lastUsed": "Last used", + "actions": "Actions" + }, + "fields": { + "provider": "Provider", + "booterType": "Booter type", + "status": "Status", + "owner": "Owner session", + "controller": "Controller session", + "retentionPolicy": "Retention policy", + "leaseExpires": "Lease expires", + "idleTimeout": "Idle timeout", + "expiresAt": "Expires at", + "connectInfo": "Connection info" + }, + "empty": { + "title": "No managed sandboxes", + "subtitle": "Create a managed sandbox or wait for a provider to register one." + }, + "create": { + "title": "Create sandbox", + "name": "Sandbox name", + "providerHint": "This provider is visible in the dashboard model but creation is not wired in this phase." + }, + "screenshot": { + "title": "Sandbox screenshot", + "noPreview": "Screenshot captured. Preview rendering will be added later." + }, + "console": { + "title": "Sandbox console", + "notice": "The console does not take over or release the lease, but commands directly affect this sandbox environment.", + "command": "Shell command", + "run": "Run command", + "output": "Command output", + "empty": "No commands executed yet.", + "running": "Running..." + }, + "config": { + "title": "Sandbox configuration", + "name": "Sandbox name", + "nameRequired": "Sandbox name is required", + "idleTimeout": "Idle timeout (seconds)", + "idleTimeoutHint": "With the temporary policy, sandboxes can be cleaned after this idle duration. Empty or 0 disables idle cleanup.", + "expiresAt": "Fixed expiration time", + "expiresAtHint": "Optional. The sandbox can be cleaned after this time." + }, + "tooltips": { + "takeover": "Bind the current dashboard session to this sandbox and obtain control. If another controller exists, control is transferred.", + "console": "Open a shell console without changing the sandbox lease." + }, + "destroyConfirm": { + "title": "Destroy sandbox?", + "message": "Destroy {name}? This shuts down the sandbox and removes its managed record." + }, + "messages": { + "loadFailed": "Failed to load sandboxes.", + "operationFailed": "Sandbox operation failed.", + "created": "Sandbox created.", + "defaultSet": "Default sandbox updated.", + "configSaved": "Sandbox configuration saved.", + "released": "Lease released.", + "takeover": "Current session now controls this sandbox.", + "destroyed": "Sandbox destroyed.", + "screenshot": "Screenshot captured." + } +} diff --git a/dashboard/src/i18n/locales/zh-CN/core/navigation.json b/dashboard/src/i18n/locales/zh-CN/core/navigation.json index 82fd487040..e63d78a78f 100644 --- a/dashboard/src/i18n/locales/zh-CN/core/navigation.json +++ b/dashboard/src/i18n/locales/zh-CN/core/navigation.json @@ -20,6 +20,7 @@ "cron": "未来任务", "conversation": "对话数据", "sessionManagement": "自定义规则", + "sandboxes": "沙盒管理", "console": "平台日志", "trace": "追踪", "alkaid": "Alkaid", diff --git a/dashboard/src/i18n/locales/zh-CN/features/sandbox.json b/dashboard/src/i18n/locales/zh-CN/features/sandbox.json new file mode 100644 index 0000000000..3ee0ba8fab --- /dev/null +++ b/dashboard/src/i18n/locales/zh-CN/features/sandbox.json @@ -0,0 +1,105 @@ +{ + "title": "沙盒管理", + "subtitle": "统一查看和操作不同 provider 的托管沙盒。", + "actions": { + "create": "创建", + "refresh": "刷新", + "inspect": "查看", + "setDefault": "设为默认", + "configure": "配置", + "switch": "切换", + "takeover": "接管", + "console": "控制台", + "release": "释放", + "screenshot": "截图", + "destroy": "销毁", + "cancel": "取消", + "close": "关闭", + "save": "保存" + }, + "metrics": { + "total": "托管沙盒", + "providers": "Provider 数量", + "busy": "占用租约", + "default": "Provider 默认" + }, + "labels": { + "default": "默认", + "busy": "占用中", + "available": "可用", + "noController": "无控制者", + "unknown": "未知", + "none": "无", + "temporary": "临时", + "persistent": "持久" + }, + "headers": { + "sandbox": "沙盒", + "provider": "Provider", + "lease": "租约", + "lastUsed": "最后使用", + "actions": "操作" + }, + "fields": { + "provider": "Provider", + "booterType": "Booter 类型", + "status": "状态", + "owner": "所属会话", + "controller": "控制会话", + "retentionPolicy": "保留策略", + "leaseExpires": "租约过期", + "idleTimeout": "空闲超时", + "expiresAt": "过期时间", + "connectInfo": "连接信息" + }, + "empty": { + "title": "暂无托管沙盒", + "subtitle": "可以创建新的托管沙盒,或等待 provider 注册可管理的沙盒。" + }, + "create": { + "title": "创建沙盒", + "name": "沙盒名称", + "providerHint": "该 provider 已纳入通用页面模型,但本阶段尚未接入创建能力。" + }, + "screenshot": { + "title": "沙盒截图", + "noPreview": "截图已完成。预览渲染将在后续补齐。" + }, + "console": { + "title": "沙盒控制台", + "notice": "控制台不会接管或释放租约,但命令会直接影响该沙盒环境。", + "command": "Shell 命令", + "run": "执行命令", + "output": "执行结果", + "empty": "尚未执行命令。", + "running": "执行中..." + }, + "config": { + "title": "沙盒配置", + "name": "沙盒名称", + "nameRequired": "沙盒名称不能为空", + "idleTimeout": "空闲超时(秒)", + "idleTimeoutHint": "临时策略下,沙盒在空闲达到该时间后可被清理,0 或空表示不启用空闲清理。", + "expiresAt": "固定过期时间", + "expiresAtHint": "可选。到达该时间后沙盒可被清理。" + }, + "tooltips": { + "takeover": "将当前 dashboard 会话绑定到这个沙盒并获得控制权;如果已有控制者,会转移控制权。", + "console": "打开命令行控制台,不改变沙盒占用状态。" + }, + "destroyConfirm": { + "title": "确认销毁沙盒", + "message": "确定要销毁 {name} 吗?该操作会关闭沙盒并移除托管记录。" + }, + "messages": { + "loadFailed": "加载沙盒失败。", + "operationFailed": "沙盒操作失败。", + "created": "沙盒已创建。", + "defaultSet": "默认沙盒已更新。", + "configSaved": "沙盒配置已保存。", + "released": "租约已释放。", + "takeover": "当前会话已接管此沙盒。", + "destroyed": "沙盒已销毁。", + "screenshot": "截图已完成。" + } +} diff --git a/dashboard/src/i18n/translations.ts b/dashboard/src/i18n/translations.ts index 9d27b41d7f..63fe69da87 100644 --- a/dashboard/src/i18n/translations.ts +++ b/dashboard/src/i18n/translations.ts @@ -37,6 +37,7 @@ import zhCNPersona from './locales/zh-CN/features/persona.json'; import zhCNMigration from './locales/zh-CN/features/migration.json'; import zhCNCommand from './locales/zh-CN/features/command.json'; import zhCNSubagent from './locales/zh-CN/features/subagent.json'; +import zhCNSandbox from './locales/zh-CN/features/sandbox.json'; import zhCNWelcome from './locales/zh-CN/features/welcome.json'; import zhCNErrors from './locales/zh-CN/messages/errors.json'; @@ -79,6 +80,7 @@ import enUSPersona from './locales/en-US/features/persona.json'; import enUSMigration from './locales/en-US/features/migration.json'; import enUSCommand from './locales/en-US/features/command.json'; import enUSSubagent from './locales/en-US/features/subagent.json'; +import enUSSandbox from './locales/en-US/features/sandbox.json'; import enUSWelcome from './locales/en-US/features/welcome.json'; import enUSErrors from './locales/en-US/messages/errors.json'; @@ -121,6 +123,7 @@ import ruRUPersona from './locales/ru-RU/features/persona.json'; import ruRUMigration from './locales/ru-RU/features/migration.json'; import ruRUCommand from './locales/ru-RU/features/command.json'; import ruRUSubagent from './locales/ru-RU/features/subagent.json'; +import ruRUSandbox from './locales/ru-RU/features/sandbox.json'; import ruRUWelcome from './locales/ru-RU/features/welcome.json'; import ruRUErrors from './locales/ru-RU/messages/errors.json'; @@ -171,6 +174,7 @@ export const translations = { migration: zhCNMigration, command: zhCNCommand, subagent: zhCNSubagent, + sandbox: zhCNSandbox, welcome: zhCNWelcome }, messages: { @@ -221,6 +225,7 @@ export const translations = { migration: enUSMigration, command: enUSCommand, subagent: enUSSubagent, + sandbox: enUSSandbox, welcome: enUSWelcome }, messages: { @@ -271,6 +276,7 @@ export const translations = { migration: ruRUMigration, command: ruRUCommand, subagent: ruRUSubagent, + sandbox: ruRUSandbox, welcome: ruRUWelcome }, messages: { diff --git a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts index 1f358aa0ff..3e44563674 100644 --- a/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts +++ b/dashboard/src/layouts/full/vertical-sidebar/sidebarItem.ts @@ -116,6 +116,11 @@ const sidebarItem: menu[] = [ icon: 'mdi-vector-link', to: '/subagent' }, + { + title: 'core.navigation.sandboxes', + icon: 'mdi-cube-outline', + to: '/sandboxes' + }, { title: 'core.navigation.dashboard', icon: 'mdi-view-dashboard', diff --git a/dashboard/src/router/MainRoutes.ts b/dashboard/src/router/MainRoutes.ts index acb80d720a..335467de5a 100644 --- a/dashboard/src/router/MainRoutes.ts +++ b/dashboard/src/router/MainRoutes.ts @@ -89,6 +89,11 @@ const MainRoutes = { path: '/subagent', component: () => import('@/views/SubAgentPage.vue') }, + { + name: 'Sandboxes', + path: '/sandboxes', + component: () => import('@/views/SandboxManagementPage.vue') + }, { name: 'CronJobs', path: '/cron', diff --git a/dashboard/src/views/SandboxManagementPage.vue b/dashboard/src/views/SandboxManagementPage.vue new file mode 100644 index 0000000000..87ff573e8a --- /dev/null +++ b/dashboard/src/views/SandboxManagementPage.vue @@ -0,0 +1,892 @@ +