feat(computer): add shipyard_neo booter runtime and sandbox config

This commit is contained in:
zenfun committed 2026-02-11 17:14:38 +08:00
1 parent e61b29ec6a
commit d16398a0e8
8 files changed
+516 -15

No files matched your search

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