feat: add CUA computer-use sandbox support (#7828)

* feat: add CUA computer-use sandbox support

* fix: add CUA config metadata translations

* fix: address CUA sandbox review feedback

* fix: default CUA sandbox to local mode

* fix: harden CUA SDK method compatibility

* fix: harden CUA GUI and permission handling

* fix: refine CUA capability and shell handling

* fix: avoid inline CUA screenshot image results by default

* fix: guide CUA browser startup workflow

* feat: add CUA browser and key press tools

* fix: launch CUA browser as sandbox user

* fix: stabilize CUA browser screenshots

* fix: simplify CUA browser launch command

* fix: remove CUA open browser tool

* fix: align CUA desktop control guidance

* fix: harden CUA shell background handling

* fix: harden CUA runtime adapters

* fix: surface CUA filesystem failures

* fix: clarify CUA shell fallback support

* fix: harden CUA shell helpers

* fix: guard CUA file fallbacks

* fix: redact sensitive config log paths

* fix: guard CUA download fallback

* test: cover CUA GUI and shell env wiring

* fix: preserve CUA command result output

* fix: normalize CUA return codes

* fix: preserve foreground shell behavior

* fix: clean up failed CUA boots

* docs: add CUA sandbox runtime guide

* test: cover CUA GUI tool registration

* refactor: simplify CUA fallback handling

* refactor: simplify CUA shell helpers

* test: cover CUA screenshot result shapes
This commit is contained in:
エイカク
2026-04-28 01:40:14 +09:00
committed by GitHub
parent 67c7445d25
commit cb5c172e69
22 changed files with 3199 additions and 20 deletions
+19 -1
View File
@@ -31,6 +31,9 @@ from astrbot.core.platform.message_session import MessageSession
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,
@@ -186,7 +189,9 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
cls,
runtime: str,
tool_mgr,
booter: str | None = None,
) -> dict[str, FunctionTool]:
booter = "" if booter is None else str(booter).lower()
if runtime == "sandbox":
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
python_tool = tool_mgr.get_builtin_tool(PythonTool)
@@ -196,7 +201,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
return {
tools = {
shell_tool.name: shell_tool,
python_tool.name: python_tool,
upload_tool.name: upload_tool,
@@ -206,6 +211,18 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
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,
}
)
return tools
if runtime == "local":
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
python_tool = tool_mgr.get_builtin_tool(LocalPythonTool)
@@ -242,6 +259,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
runtime_computer_tools = cls._get_runtime_computer_tools(
runtime,
tool_mgr,
provider_settings.get("sandbox", {}).get("booter"),
)
# Keep persona semantics aligned with the main agent: tools=None means
+19
View File
@@ -47,6 +47,9 @@ from astrbot.core.tools.computer_tools import (
BrowserExecTool,
CreateSkillCandidateTool,
CreateSkillPayloadTool,
CuaKeyboardTypeTool,
CuaMouseClickTool,
CuaScreenshotTool,
EvaluateSkillCandidateTool,
ExecuteShellTool,
FileDownloadTool,
@@ -1015,6 +1018,22 @@ def _apply_sandbox_tools(
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))
req.system_prompt = f"{req.system_prompt or ''}\n{SANDBOX_MODE_PROMPT}\n"
+5
View File
@@ -1,6 +1,7 @@
from ..olayer import (
BrowserComponent,
FileSystemComponent,
GUIComponent,
PythonComponent,
ShellComponent,
)
@@ -29,6 +30,10 @@ class ComputerBooter:
def browser(self) -> BrowserComponent | None:
return None
@property
def gui(self) -> GUIComponent | None:
return None
async def boot(self, session_id: str) -> None: ...
async def shutdown(self) -> None: ...
+830
View File
@@ -0,0 +1,830 @@
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
@@ -0,0 +1,17 @@
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",
}
+17
View File
@@ -484,6 +484,15 @@ async def get_booter(
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
@@ -499,6 +508,14 @@ async def get_booter(
await _sync_skills_to_sandbox(client)
except Exception as e:
logger.error(f"Error booting sandbox for session {session_id}: {e}")
try:
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
+2
View File
@@ -1,5 +1,6 @@
from .browser import BrowserComponent
from .filesystem import FileSystemComponent
from .gui import GUIComponent
from .python import PythonComponent
from .shell import ShellComponent
@@ -8,4 +9,5 @@ __all__ = [
"ShellComponent",
"FileSystemComponent",
"BrowserComponent",
"GUIComponent",
]
+25
View File
@@ -0,0 +1,25 @@
"""
GUI automation component.
"""
from typing import Any, Protocol
class GUIComponent(Protocol):
"""Desktop GUI operations component."""
async def screenshot(self, path: str | None = None) -> dict[str, Any]:
"""Capture a screenshot, optionally saving it to path."""
...
async def click(self, x: int, y: int, button: str = "left") -> dict[str, Any]:
"""Click at screen coordinates."""
...
async def type_text(self, text: str) -> dict[str, Any]:
"""Type text into the active UI target."""
...
async def press_key(self, key: str) -> dict[str, Any]:
"""Press a keyboard key or shortcut."""
...
+3 -8
View File
@@ -103,8 +103,7 @@ class AstrBotConfig(dict):
for key, value in refer_conf.items():
if key not in conf:
# 配置项不存在,插入默认值
path_ = path + "." + key if path else key
logger.info(f"检查到配置项 {path_} 不存在,已插入默认值 {value}")
logger.info("检查到配置项不存在,已插入默认值")
new_conf[key] = value
has_new = True
elif conf[key] is None:
@@ -133,16 +132,12 @@ class AstrBotConfig(dict):
# 检查是否存在参考配置中没有的配置项
for key in list(conf.keys()):
if key not in refer_conf:
path_ = path + "." + key if path else key
logger.info(f"检查到配置项 {path_} 不存在,将从当前配置中删除")
logger.info("检查到未知配置项,将从当前配置中删除")
has_new = True
# 顺序不一致也算作变更
if list(conf.keys()) != list(new_conf.keys()):
if path:
logger.info(f"检查到配置项 {path} 的子项顺序不一致,已重新排序")
else:
logger.info("检查到配置项顺序不一致,已重新排序")
logger.info("检查到配置项顺序不一致,已重新排序")
has_new = True
# 更新原始配置
+67 -2
View File
@@ -3,6 +3,7 @@
import os
from typing import Any, TypedDict
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.23.6"
@@ -175,6 +176,12 @@ DEFAULT_CONFIG = {
"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"],
},
"image_compress_enabled": True,
"image_compress_options": {
@@ -3289,8 +3296,8 @@ CONFIG_METADATA_3 = {
"provider_settings.sandbox.booter": {
"description": "沙箱环境驱动器",
"type": "string",
"options": ["shipyard_neo", "shipyard"],
"labels": ["Shipyard Neo", "Shipyard"],
"options": ["shipyard_neo", "shipyard", "cua"],
"labels": ["Shipyard Neo", "Shipyard", "CUA"],
"condition": {
"provider_settings.computer_use_runtime": "sandbox",
},
@@ -3331,6 +3338,64 @@ CONFIG_METADATA_3 = {
"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",
@@ -1,3 +1,8 @@
from .cua import (
CuaKeyboardTypeTool,
CuaMouseClickTool,
CuaScreenshotTool,
)
from .fs import (
FileDownloadTool,
FileEditTool,
@@ -32,6 +37,9 @@ __all__ = [
"BrowserExecTool",
"CreateSkillCandidateTool",
"CreateSkillPayloadTool",
"CuaKeyboardTypeTool",
"CuaMouseClickTool",
"CuaScreenshotTool",
"EvaluateSkillCandidateTool",
"ExecuteShellTool",
"FileDownloadTool",
+177
View File
@@ -0,0 +1,177 @@
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")
+31 -3
View File
@@ -1,5 +1,7 @@
import json
import shlex
from dataclasses import dataclass, field
from typing import Any
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
@@ -49,7 +51,7 @@ class ExecuteShellTool(FunctionTool):
context: ContextWrapper[AstrAgentContext],
command: str,
background: bool = False,
env: dict = {},
env: dict[str, Any] | None = None,
) -> ToolExecResult:
if permission_error := check_admin_permission(context, "Shell execution"):
return permission_error
@@ -67,12 +69,38 @@ class ExecuteShellTool(FunctionTool):
current_workspace_root.mkdir(parents=True, exist_ok=True)
cwd = str(current_workspace_root)
env = dict(env or {})
effective_background = background and not _is_self_detached_command(command)
result = await sb.shell.exec(
command,
cwd=cwd,
background=background,
background=effective_background,
env=env,
)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return f"Error executing command: {str(e)}"
detail = str(e) or type(e).__name__
return f"Error executing command: {detail}"
def _is_self_detached_command(command: str) -> bool:
lex = shlex.shlex(command, posix=False)
lex.whitespace_split = True
lex.commenters = ""
try:
tokens = list(lex)
except ValueError:
return False
comment_index = next(
(index for index, token in enumerate(tokens) if token.startswith("#")),
None,
)
if comment_index is not None:
tokens = tokens[:comment_index]
if not tokens:
return False
first = tokens[0].lower()
if first in {"nohup", "setsid", "disown", "start", "start-process"}:
return True
return tokens[-1] == "&"
@@ -186,6 +186,30 @@
"description": "Shipyard Neo Sandbox TTL",
"hint": "Sandbox time-to-live in seconds."
},
"cua_image": {
"description": "CUA Image",
"hint": "CUA sandbox image or OS type. Defaults to linux. Supported values depend on the installed CUA SDK."
},
"cua_os_type": {
"description": "CUA OS Type",
"hint": "CUA sandbox operating system type. Defaults to linux."
},
"cua_ttl": {
"description": "CUA Sandbox TTL",
"hint": "CUA sandbox time-to-live in seconds. Actual behavior depends on the installed CUA SDK."
},
"cua_telemetry_enabled": {
"description": "CUA Telemetry",
"hint": "Allow the CUA SDK to send telemetry data. Disabled by default."
},
"cua_local": {
"description": "CUA Local Sandbox",
"hint": "Prefer a local CUA sandbox. Enabled by default to avoid requiring CUA_API_KEY for cloud sandboxes. Disable this to use CUA cloud sandboxes."
},
"cua_api_key": {
"description": "CUA API Key",
"hint": "CUA cloud sandbox API key. Required only when local sandbox is disabled. You can also provide it via the CUA_API_KEY environment variable."
},
"shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"hint": "API access address for Shipyard service."
@@ -186,6 +186,30 @@
"description": "TTL песочницы Shipyard Neo",
"hint": "Время жизни песочницы в секундах."
},
"cua_image": {
"description": "Образ CUA",
"hint": "Образ или тип ОС песочницы CUA. По умолчанию linux. Поддерживаемые значения зависят от установленного CUA SDK."
},
"cua_os_type": {
"description": "Тип ОС CUA",
"hint": "Тип операционной системы песочницы CUA. По умолчанию linux."
},
"cua_ttl": {
"description": "TTL песочницы CUA",
"hint": "Время жизни песочницы CUA в секундах. Фактическое поведение зависит от установленного CUA SDK."
},
"cua_telemetry_enabled": {
"description": "Телеметрия CUA",
"hint": "Разрешить CUA SDK отправлять телеметрию. По умолчанию выключено."
},
"cua_local": {
"description": "Локальная песочница CUA",
"hint": "Предпочитать локальную песочницу CUA. Включено по умолчанию, чтобы не требовать CUA_API_KEY для облачных песочниц. Отключите для использования облачных песочниц CUA."
},
"cua_api_key": {
"description": "CUA API Key",
"hint": "API key для облачной песочницы CUA. Требуется только если локальная песочница отключена. Также можно передать через переменную окружения CUA_API_KEY."
},
"shipyard_endpoint": {
"description": "Эндпоинт Shipyard API",
"hint": "Адрес API для доступа к сервису Shipyard."
@@ -188,6 +188,30 @@
"description": "Shipyard Neo Sandbox 存活时间(秒)",
"hint": "Shipyard Neo 沙箱的生存时间(秒)。"
},
"cua_image": {
"description": "CUA 镜像",
"hint": "CUA 沙箱镜像/系统类型,默认 linux。可填写 linux、macos、windows、android,具体取决于 CUA SDK 支持。"
},
"cua_os_type": {
"description": "CUA 操作系统类型",
"hint": "CUA 沙箱操作系统类型,默认 linux。"
},
"cua_ttl": {
"description": "CUA Sandbox 存活时间(秒)",
"hint": "CUA 沙箱生存时间(秒)。当前作为会话配置保存,具体生效取决于 CUA SDK。"
},
"cua_telemetry_enabled": {
"description": "CUA 遥测",
"hint": "是否允许 CUA SDK 发送遥测数据。默认关闭。"
},
"cua_local": {
"description": "CUA 本地沙箱",
"hint": "是否优先使用 CUA 本地沙箱。默认开启,避免云端沙箱要求 CUA_API_KEY。关闭后可使用 CUA 云端沙箱。"
},
"cua_api_key": {
"description": "CUA API Key",
"hint": "CUA 云端沙箱 API Key。仅在关闭本地沙箱时需要。也可以通过 CUA_API_KEY 环境变量提供。"
},
"shipyard_endpoint": {
"description": "Shipyard API Endpoint",
"hint": "Shipyard 服务的 API 访问地址。"
+106 -2
View File
@@ -13,11 +13,12 @@
- `Shipyard Neo`(当前推荐)
- `Shipyard`(旧方案,仍可继续使用)
- `CUA`(本地或云端电脑使用沙盒,适合需要桌面操作的场景)
在当前版本的 AstrBot 控制台中,可在“AI 配置” -> “Agent Computer Use”中选择:
- `Computer Use Runtime` = `sandbox`
- `沙箱环境驱动器` = `Shipyard Neo` 或 `Shipyard`
- `沙箱环境驱动器` = `Shipyard Neo`、`Shipyard` 或 `CUA`
其中,`Shipyard Neo` 是当前默认驱动器。它由 Bay、Ship、Gull 三部分组成:
@@ -30,6 +31,109 @@
> [!TIP]
> `Shipyard Neo` 下浏览器能力并不是所有 profile 都有。只有 profile 支持 `browser` capability 时,AstrBot 才会挂载浏览器相关工具。典型 profile 如 `browser-python`。
## CUA 运行时
`CUA` 是一个面向电脑使用(Computer Use)的沙盒运行时。它可以通过统一的 Python SDK 创建 Linux、macOS、Windows、Android 等不同类型的沙盒,并暴露 Shell、截图、鼠标、键盘、文件系统等接口。
在 AstrBot 中选择 `CUA` 驱动器后,Agent 可以在 CUA sandbox 中使用:
- Shell 工具
- Python 工具
- 文件读取、写入、编辑和搜索工具
- 截图工具
- 鼠标点击工具
- 键盘输入工具
- 沙盒文件上传与下载工具
> [!NOTE]
> CUA 是可选运行时,AstrBot 默认安装不会强制安装它。如果选择了 `CUA` 但当前 Python 环境没有安装 `cua` 包,启动沙盒时会提示安装缺失。
### 安装 CUA 依赖
如果您通过源码或虚拟环境运行 AstrBot,请在 AstrBot 使用的 Python 环境中安装 CUA:
```bash
pip install cua
```
如果您使用 `uv` 管理 AstrBot 环境,可在 AstrBot 项目目录中执行:
```bash
uv pip install cua
```
CUA 本身还依赖具体运行方式:
- 本地 Linux 容器通常需要 Docker 可用。
- 本地 Linux/Windows VM 通常需要 QEMU 或 CUA 对应的本地运行时。
- macOS VM 通常依赖 CUA/Lume 相关运行时。
- 云端 CUA 需要可用的 CUA API Key。
具体宿主机要求、镜像支持情况和本地运行时安装方式,请参考 [CUA 官方文档](https://cua.ai/docs)。
### 在 AstrBot 中配置 CUA
进入 WebUI:
- `配置 -> 普通配置 -> 使用电脑能力`
然后设置:
- `Computer Use Runtime` = `sandbox`
- `沙箱环境驱动器` = `CUA`
CUA 相关配置项包括:
- `CUA Image`:要启动的 CUA 镜像。常见值为 `linux`、`macos`、`windows`、`android`。默认 `linux`。
- `CUA OS Type`:镜像的操作系统类型。默认 `linux`。它会影响 AstrBot 对 POSIX Shell fallback 的判断。
- `CUA Sandbox TTL`:沙盒生命周期,单位为秒。默认 `3600`。
- `CUA Telemetry Enabled`:是否启用 CUA 侧遥测。默认关闭。
- `CUA Local Runtime`:是否使用本地运行时。默认开启。关闭后会按 CUA SDK 的云端方式创建沙盒。
- `CUA API Key`:云端 CUA 所需的 API Key。仅在使用云端运行时时填写。
一个最小本地 Linux 容器配置通常是:
```text
Computer Use Runtime = sandbox
沙箱环境驱动器 = CUA
CUA Image = linux
CUA OS Type = linux
CUA Local Runtime = true
CUA Sandbox TTL = 3600
```
如果使用云端 CUA,可改为:
```text
Computer Use Runtime = sandbox
沙箱环境驱动器 = CUA
CUA Image = linux
CUA OS Type = linux
CUA Local Runtime = false
CUA API Key = <your-cua-api-key>
```
> [!WARNING]
> 不要把 CUA API Key 写入公开日志、截图或 issue。AstrBot 的运行日志不会输出该字段,但部署平台、Shell 历史和容器环境变量仍需自行保护。
### 使用 CUA 时的注意事项
- `linux` 镜像通常适合 Shell、Python、文件系统和桌面自动化测试。
- 非 POSIX 镜像(如 `windows`、`android`)不一定支持 `sh`、`cat`、`ls`、`rm`、`base64` 等命令。AstrBot 对需要这些命令的 fallback 操作会返回明确错误。
- 如果需要在 CUA sandbox 中打开浏览器或 GUI 程序,通常应使用 Shell 后台执行,例如显式传入 `background=true`,避免命令阻塞后续工具调用。
- 直接把 sandbox 内的文件路径发送给用户通常不可行。应优先使用 AstrBot 的沙盒下载工具,将文件下载到 AstrBot 临时目录后再发送。
- CUA 与 Shipyard Neo 的 workspace 语义不同。Shipyard Neo 固定使用 `/workspace`;CUA 的工作目录和文件路径取决于镜像与运行时。
### 何时选择 CUA
建议在以下场景选择 `CUA`:
- 需要桌面截图、鼠标点击、键盘输入等 GUI 自动化能力。
- 需要测试不同 OS 镜像中的行为,例如 Linux、Windows、Android。
- 已经在本机或云端部署好 CUA 运行环境。
如果只是需要稳定的 Python/Shell/文件系统沙盒,且不需要桌面 GUI 操作,通常优先选择 `Shipyard Neo`。它与 AstrBot 的 workspace、Skills 同步和长期运行模式更贴合。
## 性能要求
AstrBot 给每个沙盒环境限制最高 1 CPU 和 512 MB 内存。
@@ -388,4 +492,4 @@ Shipyard 会自动将沙盒环境中的 /home 目录挂载到宿主机的 `${PWD
### luosheng520qaq/astrobot_plugin_code_executor
如果您资源有限,不希望使用沙盒环境来执行代码,可以尝试 luosheng520qaq 开发的 [astrobot_plugin_code_executor](https://github.com/luosheng520qaq/astrobot_plugin_code_executor) 插件。该插件会直接在宿主机上执行代码。插件已经尽力提升安全性,但仍需留意代码安全性问题。
如果您资源有限,不希望使用沙盒环境来执行代码,可以尝试 luosheng520qaq 开发的 [astrobot_plugin_code_executor](https://github.com/luosheng520qaq/astrobot_plugin_code_executor) 插件。该插件会直接在宿主机上执行代码。插件已经尽力提升安全性,但仍需留意代码安全性问题。
+9 -2
View File
@@ -97,7 +97,12 @@ data/workspaces/{normalized_umo}/notes/todo.txt
在沙盒中,Agent 仍然可以使用 Shell、Python、文件系统工具;如果所选沙盒 profile 支持 `browser` capability,还会挂载浏览器自动化工具。
使用 Shipyard Neo 时,沙盒 workspace 根目录通常是:
沙盒环境驱动器可在 `配置 -> 普通配置 -> 使用电脑能力` 的沙箱配置中选择。当前常用选项包括:
- `Shipyard Neo`:AstrBot 推荐的远程/独立部署沙盒服务,适合长期运行和多人使用。
- `CUA`:基于 [CUA](https://github.com/trycua/cua) 的本地或云端电脑使用沙盒,可提供桌面截图、鼠标、键盘、Shell、Python 和文件系统能力。
使用 `Shipyard Neo` 时,沙盒 workspace 根目录通常是:
```text
/workspace
@@ -115,7 +120,9 @@ result.txt
/workspace/result.txt
```
沙盒部署、profile、TTL、数据持久化、浏览器能力等内容请参考:[Agent 沙盒环境](/use/astrbot-agent-sandbox)。
使用 `CUA` 时,工作目录和可用命令取决于所选 CUA image 与运行方式。Linux CUA 容器通常提供类 Unix Shell;Windows、Android 等非 POSIX 镜像不保证支持 `sh`、`ls`、`rm`、`base64` 等命令,AstrBot 会对部分 shell fallback 操作返回明确错误。
沙盒部署、驱动器选择、CUA 配置、profile、TTL、数据持久化、浏览器能力等内容请参考:[Agent 沙盒环境](/use/astrbot-agent-sandbox)。
> [!NOTE]
> 即使在 `sandbox` 模式下,“需要 AstrBot 管理员权限”仍会影响 Shell、Python、浏览器、上传下载等工具的调用权限。具体权限取决于你的配置。
+30
View File
@@ -1561,6 +1561,36 @@ class TestApplySandboxTools:
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
+21
View File
@@ -291,6 +291,27 @@ class TestConfigValidation:
assert "level2" in config.nested["level1"]
assert config.nested["level1"]["level2"]["value"] == 42
def test_integrity_log_does_not_include_inserted_secret_value(
self, temp_config_path, monkeypatch
):
"""Default values may contain secrets and should not be logged."""
from astrbot.core.config import astrbot_config
existing_config = {}
default_config = {"api_key": "secret-value"}
messages = []
with open(temp_config_path, "w", encoding="utf-8-sig") as f:
json.dump(existing_config, f)
monkeypatch.setattr(astrbot_config.logger, "info", messages.append)
AstrBotConfig(config_path=temp_config_path, default_config=default_config)
assert messages
assert all("secret-value" not in message for message in messages)
assert all("api_key" not in message for message in messages)
assert any("配置项不存在" in message for message in messages)
class TestConfigHotReload:
"""Tests for config hot reload functionality."""
File diff suppressed because it is too large Load Diff
+283 -2
View File
@@ -1,9 +1,15 @@
import json
import pytest
from astrbot.core import sp
from astrbot.core.provider.func_tool_manager import FunctionToolManager
from astrbot.core.tools.computer_tools.shell import ExecuteShellTool
from astrbot.core.tools.message_tools import SendMessageToUserTool
from astrbot.core.tools.web_search_tools import FirecrawlExtractWebPageTool
from astrbot.core.tools.web_search_tools import FirecrawlWebSearchTool
from astrbot.core.tools.web_search_tools import (
FirecrawlExtractWebPageTool,
FirecrawlWebSearchTool,
)
def test_get_builtin_tool_by_class_returns_cached_instance():
@@ -39,9 +45,284 @@ def test_computer_tools_are_registered_as_builtin_tools():
tool = manager.get_builtin_tool(ExecuteShellTool)
assert tool.name == "astrbot_execute_shell"
assert tool.parameters["properties"]["background"]["default"] is False
assert manager.is_builtin_tool("astrbot_execute_shell") is True
@pytest.mark.asyncio
async def test_execute_shell_defaults_to_foreground(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
result = await ExecuteShellTool().call(
FakeWrapper(), command="chromium https://example.com"
)
assert json.loads(result)["success"] is True
assert calls == [{"command": "chromium https://example.com", "background": False}]
@pytest.mark.asyncio
async def test_execute_shell_uses_fresh_default_env_per_call(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
env["MUTATED_BY_FAKE_SHELL"] = command
calls.append(env)
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
tool = ExecuteShellTool()
await tool.call(FakeWrapper(), command="first")
await tool.call(FakeWrapper(), command="second")
assert calls[0] is not calls[1]
assert calls[0]["MUTATED_BY_FAKE_SHELL"] == "first"
assert calls[1] == {"MUTATED_BY_FAKE_SHELL": "second"}
@pytest.mark.asyncio
async def test_execute_shell_copies_user_env_before_execution(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
env["MUTATED_BY_FAKE_SHELL"] = command
calls.append(env)
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
original_env = {"FOO": "bar"}
await ExecuteShellTool().call(FakeWrapper(), command="first", env=original_env)
assert original_env == {"FOO": "bar"}
assert calls == [{"FOO": "bar", "MUTATED_BY_FAKE_SHELL": "first"}]
@pytest.mark.asyncio
async def test_execute_shell_avoids_double_background_for_detached_commands(
monkeypatch,
):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
command = "nohup firefox >/tmp/astrbot-firefox.log 2>&1 &"
result = await ExecuteShellTool().call(
FakeWrapper(), command=command, background=True
)
assert json.loads(result)["success"] is True
assert calls == [{"command": command, "background": False}]
@pytest.mark.asyncio
async def test_execute_shell_recognizes_commented_background_command(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
calls = []
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
calls.append({"command": command, "background": background})
return {"success": True, "stdout": "", "stderr": "", "exit_code": 0}
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
command = "firefox & # already detached"
result = await ExecuteShellTool().call(
FakeWrapper(), command=command, background=True
)
assert json.loads(result)["success"] is True
assert calls == [{"command": command, "background": False}]
@pytest.mark.parametrize(
("command", "expected"),
[
("echo '#'", False),
("echo '&'", False),
("echo foo#bar &", True),
("echo 'unterminated", False),
("firefox & # already detached", True),
("nohup firefox >/tmp/astrbot-firefox.log 2>&1 &", True),
("firefox", False),
],
)
def test_is_self_detached_command_handles_quotes_and_comments(command, expected):
from astrbot.core.tools.computer_tools.shell import _is_self_detached_command
assert _is_self_detached_command(command) is expected
@pytest.mark.asyncio
async def test_execute_shell_reports_blank_exception_type(monkeypatch):
from astrbot.core.tools.computer_tools import shell as shell_tools
class BlankError(Exception):
def __str__(self):
return ""
class FakeShell:
async def exec(self, command, cwd=None, background=False, env=None):
raise BlankError()
class FakeBooter:
shell = FakeShell()
class FakeConfig:
def get_config(self, umo):
return {"provider_settings": {"computer_use_runtime": "sandbox"}}
class FakeEvent:
unified_msg_origin = "umo"
role = "admin"
class FakeAstrContext:
context = FakeConfig()
event = FakeEvent()
class FakeWrapper:
context = FakeAstrContext()
async def fake_get_booter(context, session_id):
return FakeBooter()
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
result = await ExecuteShellTool().call(FakeWrapper(), command="firefox")
assert result == "Error executing command: BlankError"
def test_firecrawl_tools_are_registered_as_builtin_tools():
manager = FunctionToolManager()