mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
@@ -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
|
||||
|
||||
# 更新原始配置
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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 访问地址。"
|
||||
|
||||
@@ -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) 插件。该插件会直接在宿主机上执行代码。插件已经尽力提升安全性,但仍需留意代码安全性问题。
|
||||
|
||||
@@ -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、浏览器、上传下载等工具的调用权限。具体权限取决于你的配置。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user