diff --git a/astrbot/core/computer/booters/bwrap.py b/astrbot/core/computer/booters/bwrap.py new file mode 100644 index 0000000000..f972ff3aa9 --- /dev/null +++ b/astrbot/core/computer/booters/bwrap.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import asyncio +import locale +import os +import shlex +import shutil +import subprocess +import sys +from dataclasses import dataclass, field +from typing import Any + +from astrbot.core.utils.astrbot_path import ( + get_astrbot_temp_path, +) + +from ..olayer import FileSystemComponent, PythonComponent, ShellComponent +from .base import ComputerBooter + + +def _decode_shell_output(output: bytes | None) -> str: + if output is None: + return "" + + preferred = locale.getpreferredencoding(False) or "utf-8" + try: + return output.decode("utf-8") + except (LookupError, UnicodeDecodeError): + pass + + try: + return output.decode(preferred) + except (LookupError, UnicodeDecodeError): + pass + + return output.decode("utf-8", errors="replace") + + +@dataclass +class BwrapConfig: + workspace_dir: str + ro_binds: list[str] = field(default_factory=list) + rw_binds: list[str] = field(default_factory=list) + share_net: bool = True + + def __post_init__(self): + # Merge default required system binds with any additional ro_binds passed + default_ro = ["/usr", "/lib", "/lib64", "/bin", "/etc", "/opt"] + for p in default_ro: + if p not in self.ro_binds: + self.ro_binds.append(p) + + +def build_bwrap_cmd(config: BwrapConfig, script_cmd: list[str]) -> list[str]: + """Helper to build a bubblewrap command.""" + cmd = ["bwrap"] + + if not config.share_net: + cmd.append("--unshare-net") + + # Bind paths to itself so paths match + for path in config.ro_binds: + if os.path.exists(path): + cmd.extend(["--ro-bind", path, path]) + + for path in config.rw_binds: + # Avoid bind mounting dangerous host paths + if path == "/" or path.startswith("/root"): + continue + if os.path.exists(path): + cmd.extend(["--bind", path, path]) + + # Make system binds the last to avoid issues about ro `/` + cmd.extend( + [ + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--die-with-parent", + ] + ) + cmd += [ + "--dir", + "/tmp", + ] + cmd += [ + "--dir", + "/var/tmp", + ] + cmd += [ + "--proc", + "/proc", + ] + cmd += [ + "--dev", + "/dev", + ] + cmd += [ + "--bind", + config.workspace_dir, + config.workspace_dir, + ] + + cmd.extend(["--"]) + cmd.extend(script_cmd) + return cmd + + +@dataclass +class BwrapShellComponent(ShellComponent): + config: BwrapConfig + + 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]: + + def _run() -> dict[str, Any]: + run_env = os.environ.copy() + if env: + run_env.update({str(k): str(v) for k, v in env.items()}) + + working_dir = cwd if cwd else self.config.workspace_dir + + # Use /bin/sh -c to run the evaluated command + # The command must be run inside bwrap + script_cmd = ["/bin/sh", "-c", command] if shell else shlex.split(command) + bwrap_cmd = build_bwrap_cmd(self.config, script_cmd) + + if background: + proc = subprocess.Popen( + bwrap_cmd, + cwd=working_dir, + env=run_env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None} + + result = subprocess.run( + bwrap_cmd, + cwd=working_dir, + env=run_env, + timeout=timeout, + capture_output=True, + ) + return { + "stdout": _decode_shell_output(result.stdout), + "stderr": _decode_shell_output(result.stderr), + "exit_code": result.returncode, + } + + return await asyncio.to_thread(_run) + + +@dataclass +class BwrapPythonComponent(PythonComponent): + config: BwrapConfig + + async def exec( + self, + code: str, + kernel_id: str | None = None, + timeout: int = 30, + silent: bool = False, + ) -> dict[str, Any]: + def _run() -> dict[str, Any]: + bwrap_cmd = build_bwrap_cmd( + self.config, [os.environ.get("PYTHON", "python3"), "-c", code] + ) + try: + result = subprocess.run( + bwrap_cmd, + timeout=timeout, + capture_output=True, + text=True, + ) + stdout = "" if silent else result.stdout + return { + "stdout": stdout, + "stderr": result.stderr, + "exit_code": result.returncode, + } + except subprocess.TimeoutExpired as e: + return { + "stdout": e.stdout.decode() + if isinstance(e.stdout, bytes) + else str(e.stdout or ""), + "stderr": f"Execution timed out after {timeout} seconds.", + "exit_code": 1, + } + except Exception as e: + return { + "stdout": "", + "stderr": str(e), + "exit_code": 1, + } + + return await asyncio.to_thread(_run) + + +@dataclass +class HostBackedFileSystemComponent(FileSystemComponent): + """File operations happen safely on host mapping to workspace, making I/O extremely fast.""" + + workspace_dir: str + + def _safe_path(self, path: str) -> str: + # Simply maps it. In a stricter implementation, we could verify it's inside workspace_dir. + # But for this implementation, we trust the agent or restrict to workspace_dir. + if not path.startswith("/"): + path = os.path.join(self.workspace_dir, path) + return path + + async def create_file( + self, path: str, content: str = "", mode: int = 0o644 + ) -> dict[str, Any]: + p = self._safe_path(path) + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "w", encoding="utf-8") as f: + f.write(content) + os.chmod(p, mode) + return {"success": True, "path": p} + + async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]: + p = self._safe_path(path) + try: + with open(p, encoding=encoding) as f: + content = f.read() + return {"success": True, "content": content} + except Exception as e: + return {"success": False, "error": str(e)} + + async def write_file( + self, path: str, content: str, mode: str = "w", encoding: str = "utf-8" + ) -> dict[str, Any]: + p = self._safe_path(path) + os.makedirs(os.path.dirname(p), exist_ok=True) + try: + with open(p, mode, encoding=encoding) as f: + f.write(content) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def delete_file(self, path: str) -> dict[str, Any]: + p = self._safe_path(path) + try: + if os.path.isdir(p): + shutil.rmtree(p) + else: + os.remove(p) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} + + async def list_dir( + self, path: str = ".", show_hidden: bool = False + ) -> dict[str, Any]: + p = self._safe_path(path) + try: + items = os.listdir(p) + if not show_hidden: + items = [item for item in items if not item.startswith(".")] + return {"success": True, "items": items} + except Exception as e: + return {"success": False, "error": str(e), "items": []} + + +class BwrapBooter(ComputerBooter): + def __init__(self, rw_binds: list[str] = None, ro_binds: list[str] = None): + self._rw_binds = rw_binds or [] + self._ro_binds = ro_binds or [] + self._fs: HostBackedFileSystemComponent | None = None + self._python: BwrapPythonComponent | None = None + self._shell: BwrapShellComponent | None = None + self.config: BwrapConfig | None = None + + @property + def fs(self) -> FileSystemComponent: + return self._fs + + @property + def python(self) -> PythonComponent: + return self._python + + @property + def shell(self) -> ShellComponent: + return self._shell + + @property + def capabilities(self) -> tuple[str, ...]: + return ("python", "shell", "filesystem") + + async def boot(self, session_id: str) -> None: + workspace_dir = os.path.join( + get_astrbot_temp_path(), f"sandbox_workspace_{session_id}" + ) + os.makedirs(workspace_dir, exist_ok=True) + + self.config = BwrapConfig( + workspace_dir=os.path.abspath(workspace_dir), + rw_binds=self._rw_binds, + ro_binds=self._ro_binds, + ) + self._fs = HostBackedFileSystemComponent(self.config.workspace_dir) + self._python = BwrapPythonComponent(self.config) + self._shell = BwrapShellComponent(self.config) + if not await self.available(): + raise RuntimeError( + "BubbleWrap sandbox unavailable on current machine for no bwrap executable." + ) + test_shl = await self._shell.exec(command="ls > /dev/null") + if test_shl["exit_code"] != 0: + raise RuntimeError( + """BubbleWrap sandbox fails to exec test shell command "ls > /dev/null" with stderr: +{}""".format(test_shl["stderr"]) + ) + test_py = await self._python.exec(code="print('Yes')") + if test_py["exit_code"] != 0: + raise RuntimeError( + """BubbleWrap sandbox fails to exec test python code "print('Yes')" with stderr: +{}""".format(test_py["stderr"]) + ) + + async def shutdown(self) -> None: + if self.config and os.path.exists(self.config.workspace_dir): + shutil.rmtree(self.config.workspace_dir, ignore_errors=True) + + async def upload_file(self, path: str, file_name: str) -> dict: + if not self._fs: + return {"success": False, "error": "Not booted"} + target = os.path.join(self.config.workspace_dir, file_name) + try: + shutil.copy2(path, target) + return {"success": True, "file_path": target} + except Exception as e: + return {"success": False, "error": str(e)} + + async def download_file(self, remote_path: str, local_path: str) -> None: + if not self._fs: + return + if not remote_path.startswith("/"): + remote_path = os.path.join(self.config.workspace_dir, remote_path) + shutil.copy2(remote_path, local_path) + + async def available(self) -> bool: + if sys.platform == "win32": + return False + if shutil.which("bwrap") is None: + return False + return True diff --git a/astrbot/core/computer/computer_client.py b/astrbot/core/computer/computer_client.py index 7e142fa38e..b6c251857c 100644 --- a/astrbot/core/computer/computer_client.py +++ b/astrbot/core/computer/computer_client.py @@ -214,7 +214,7 @@ def parse_description(text: str) -> str: if end_idx is None: return "" - frontmatter = "\n".join(lines[1:end_idx]) + frontmatter = "\\n".join(lines[1:end_idx]) try: import yaml except ImportError: @@ -488,6 +488,12 @@ async def get_booter( from .booters.boxlite import BoxliteBooter client = BoxliteBooter() + elif booter_type == "bwrap": + from .booters.bwrap import BwrapBooter + + rw_binds = sandbox_cfg.get("bwrap_rw_binds", []) + ro_binds = sandbox_cfg.get("bwrap_ro_binds", []) + client = BwrapBooter(rw_binds=rw_binds, ro_binds=ro_binds) else: raise ValueError(f"Unknown booter type: {booter_type}") diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 5b4ea7686a..a157c95648 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -141,6 +141,8 @@ "shipyard_neo_access_token": "", "shipyard_neo_profile": "python-default", "shipyard_neo_ttl": 3600, + "bwrap_rw_binds": [], + "bwrap_ro_binds": [], }, }, # SubAgent orchestrator mode: @@ -3006,12 +3008,32 @@ class ChatProviderTemplate(TypedDict): "provider_settings.sandbox.booter": { "description": "沙箱环境驱动器", "type": "string", - "options": ["shipyard_neo", "shipyard"], - "labels": ["Shipyard Neo", "Shipyard"], + "options": ["shipyard_neo", "shipyard", "bwrap"], + "labels": ["Shipyard Neo", "Shipyard", "Bubblewrap"], "condition": { "provider_settings.computer_use_runtime": "sandbox", }, }, + "provider_settings.sandbox.bwrap_rw_binds": { + "description": "Bubblewrap Read & Write Binds", + "type": "list", + "items": {"type": "string"}, + "hint": "Bubblewrap 沙箱额外的读写挂载目录(回车添加,可添加多个。默认 /tmp, /var/tmp 已经是读写。", + "condition": { + "provider_settings.computer_use_runtime": "sandbox", + "provider_settings.sandbox.booter": "bwrap", + }, + }, + "provider_settings.sandbox.bwrap_ro_binds": { + "description": "Bubblewrap Read Only Binds", + "type": "list", + "items": {"type": "string"}, + "hint": "Bubblewrap 沙箱额外的只读挂载目录(回车添加,可添加多个。宿主机系统目录/usr, /etc, /opt默认只读挂载于沙箱内的相同位置)。", + "condition": { + "provider_settings.computer_use_runtime": "sandbox", + "provider_settings.sandbox.booter": "bwrap", + }, + }, "provider_settings.sandbox.shipyard_neo_endpoint": { "description": "Shipyard Neo API Endpoint", "type": "string", diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 2e12143725..818924077d 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -162,6 +162,14 @@ "booter": { "description": "Sandbox Environment Driver" }, + "bwrap_rw_binds": { + "description": "Bubblewrap Read & Write Binds", + "hint": "Additional read-write mount directories for the Bubblewrap sandbox (press Enter to add, multiple can be added. /tmp and /var/tmp are read-write by default)." + }, + "bwrap_ro_binds": { + "description": "Bubblewrap Read Only Binds", + "hint": "Additional read-only mount directories for the Bubblewrap sandbox (press Enter to add, multiple can be added. Host system directories /usr, /etc, /opt are mounted read-only at the same location by default)." + }, "shipyard_neo_endpoint": { "description": "Shipyard Neo API Endpoint", "hint": "Bay API address, default http://127.0.0.1:8114." @@ -193,6 +201,10 @@ "shipyard_max_sessions": { "description": "Shipyard Max Sessions", "hint": "Maximum number of Shipyard sessions an instance can handle." + }, + "bwrap_rw_binds": { + "description": "Bubblewrap RW Binds", + "hint": "Extra read-write mount directories for Bubblewrap sandbox (press Enter to add, multiple allowed. /tmp and /var/tmp are RW by default, and the entire host system is mounted RO in the same location by default)." } } } @@ -1518,4 +1530,4 @@ "helpMiddle": "or", "helpSuffix": "." } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 56d12c9838..8b7398134d 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -162,6 +162,14 @@ "booter": { "description": "Драйвер среды песочницы" }, + "bwrap_rw_binds": { + "description": "Дополнительные папки чтения-записи Bubblewrap (RW)", + "hint": "Дополнительные папки для монтирования на чтение и запись в песочнице Bubblewrap (нажмите Enter для добавления. По умолчанию /tmp и /var/tmp уже доступны для записи)." + }, + "bwrap_ro_binds": { + "description": "Дополнительные папки только для чтения Bubblewrap (RO)", + "hint": "Дополнительные папки для монтирования только для чтения в песочнице Bubblewrap (нажмите Enter для добавления. По умолчанию /usr, /etc и /opt уже смонтированы только для чтения)." + }, "shipyard_neo_endpoint": { "description": "Эндпоинт Shipyard Neo API", "hint": "Адрес Bay API, по умолчанию http://127.0.0.1:8114." @@ -193,6 +201,10 @@ "shipyard_max_sessions": { "description": "Макс. количество сессий Shipyard", "hint": "Максимальное количество сессий Shipyard, которое может поддерживать экземпляр." + }, + "bwrap_rw_binds": { + "description": "Bubblewrap RW Binds", + "hint": "Дополнительные каталоги монтирования для чтения/записи (нажмите Enter для добавления, можно добавить несколько; по умолчанию /tmp и /var/tmp доступны для записи, а вся система хоста монтируется в режиме RO в том же расположении)." } } } @@ -1523,4 +1535,4 @@ "helpMiddle": "или", "helpSuffix": "." } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 0c9148bd0b..4095ac1747 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -164,6 +164,14 @@ "booter": { "description": "沙箱环境驱动器" }, + "bwrap_rw_binds": { + "description": "Bubblewrap 额外读写挂载目录", + "hint": "Bubblewrap 沙箱额外的读写挂载目录(回车添加,可添加多个。默认 /tmp, /var/tmp 已经是读写。" + }, + "bwrap_ro_binds": { + "description": "Bubblewrap 额外只读挂载目录", + "hint": "Bubblewrap 沙箱额外的只读挂载目录(回车添加,可添加多个。宿主机系统目录/usr, /etc, /opt默认只读挂载于沙箱内的相同位置)。" + }, "shipyard_neo_endpoint": { "description": "Shipyard Neo API Endpoint", "hint": "Shipyard Neo(Bay) 服务的 API 地址,默认 http://127.0.0.1:8114。" @@ -195,6 +203,10 @@ "shipyard_max_sessions": { "description": "Shipyard Ship 会话复用上限", "hint": "决定了一个实例承载的最大会话数量。" + }, + "bwrap_rw_binds": { + "description": "Bubblewrap 读写挂载目录", + "hint": "额外的读写挂载目录(回车添加,可添加多个。默认 /tmp, /var/tmp 已经是读写,整个宿主机系统默认是只读挂载于沙箱内的相同位置)。" } } } @@ -1520,4 +1532,4 @@ "helpMiddle": "或", "helpSuffix": "。" } -} \ No newline at end of file +} diff --git a/tests/unit/test_computer.py b/tests/unit/test_computer.py index 07a5449c19..0ebc6b7daa 100644 --- a/tests/unit/test_computer.py +++ b/tests/unit/test_computer.py @@ -5,6 +5,8 @@ """ import sys +import os +import shutil from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -19,6 +21,15 @@ _is_safe_command, ) +from astrbot.core.computer.booters.bwrap import ( + BwrapBooter, + BwrapConfig, + build_bwrap_cmd, + HostBackedFileSystemComponent, + BwrapPythonComponent, + BwrapShellComponent, +) + class TestLocalBooterInit: """Tests for LocalBooter initialization.""" @@ -882,3 +893,143 @@ async def test_sync_skills_success(self): ): # Should not raise await computer_client._sync_skills_to_sandbox(mock_booter) + +class TestBwrapConfigAndBuilder: + def test_bwrap_config_defaults(self): + config = BwrapConfig(workspace_dir="/tmp/test") + # System defaults should be merged + assert "/usr" in config.ro_binds + assert "/etc" in config.ro_binds + + # Test custom additions + config2 = BwrapConfig(workspace_dir="/tmp/test", ro_binds=["/custom"]) + assert "/custom" in config2.ro_binds + assert "/usr" in config2.ro_binds + + def test_build_bwrap_cmd(self): + config = BwrapConfig(workspace_dir="/tmp/test", rw_binds=[], ro_binds=[]) + cmd = build_bwrap_cmd(config, ["echo", "hello"]) + + assert "bwrap" in cmd + assert "--unshare-pid" in cmd + assert "--bind" in cmd + assert "/tmp/test" in cmd + assert "--" in cmd + assert "echo" == cmd[-2] + assert "hello" == cmd[-1] + + +@pytest.mark.skipif(shutil.which("bwrap") is None, reason="bwrap is not installed") +class TestBwrapBooterLifecycle: + @pytest.mark.asyncio + async def test_bwrap_boot(self): + booter = BwrapBooter() + await booter.boot("test_session_123") + assert booter.config is not None + assert os.path.exists(booter.config.workspace_dir) + await booter.shutdown() + assert not os.path.exists(booter.config.workspace_dir) + + @pytest.mark.asyncio + async def test_bwrap_available(self): + booter = BwrapBooter() + avail = await booter.available() + assert avail is True # We skipped if no bwrap installed + + @pytest.mark.asyncio + async def test_bwrap_upload_download(self, tmp_path): + booter = BwrapBooter() + await booter.boot("test_session_io") + + # Test upload + host_file = tmp_path / "test_upload.txt" + host_file.write_text("hello bwrap") + + res = await booter.upload_file(str(host_file), "target.txt") + assert res.get("success") is True + + # Verify it exists in workspace + target_path = os.path.join(booter.config.workspace_dir, "target.txt") + assert os.path.exists(target_path) + + # Test download + dl_path = tmp_path / "downloaded.txt" + await booter.download_file("target.txt", str(dl_path)) + assert dl_path.exists() + assert dl_path.read_text() == "hello bwrap" + + await booter.shutdown() + +@pytest.mark.skipif(shutil.which("bwrap") is None, reason="bwrap is not installed") +class TestBwrapShellComponent: + @pytest.mark.asyncio + async def test_bwrap_shell_exec(self): + booter = BwrapBooter() + await booter.boot("test_shell") + res = await booter.shell.exec("echo 'hello bwrap'") + assert res["exit_code"] == 0 + assert "hello bwrap" in res["stdout"] + await booter.shutdown() + + @pytest.mark.asyncio + async def test_bwrap_shell_ro_slash(self): + # Testing the system-first + ro root order you mentioned + booter = BwrapBooter(ro_binds=["/"]) + await booter.boot("test_shell_ro") + + # Will it write to /dev/null correctly despite ro /? + res = await booter.shell.exec("echo xxx > /dev/null && echo success") + assert res["exit_code"] == 0 + assert "success" in res["stdout"] + + # Will it fail to write to ro /tmp? + res2 = await booter.shell.exec("echo yyy > /tmp/test_write.txt", shell=True) + # /tmp in bwrap is tmpfs by default from our flags, so this might actually succeed. + # Let's try writing to /usr instead + res3 = await booter.shell.exec("echo yyy > /usr/test_write.txt", shell=True) + assert res3["exit_code"] != 0 + + await booter.shutdown() + + +@pytest.mark.skipif(shutil.which("bwrap") is None, reason="bwrap is not installed") +class TestBwrapPythonComponent: + @pytest.mark.asyncio + async def test_bwrap_python_exec(self): + booter = BwrapBooter() + await booter.boot("test_python") + res = await booter.python.exec("print('hello python from bwrap')") + assert res["exit_code"] == 0 + assert "hello python from bwrap" in res["stdout"] + await booter.shutdown() + + +class TestHostBackedFileSystemComponent: + @pytest.mark.asyncio + async def test_fs_create_read_delete(self, tmp_path): + fs = HostBackedFileSystemComponent(str(tmp_path)) + + # create + res = await fs.create_file("test.txt", "hello fs") + assert res["success"] is True + assert (tmp_path / "test.txt").exists() + + # read + res_read = await fs.read_file("test.txt") + assert res_read["success"] is True + assert res_read["content"] == "hello fs" + + # write + res_write = await fs.write_file("test.txt", "updated fs") + assert res_write["success"] is True + assert (tmp_path / "test.txt").read_text() == "updated fs" + + # list + res_list = await fs.list_dir() + assert res_list["success"] is True + assert "test.txt" in res_list["items"] + + # delete + res_del = await fs.delete_file("test.txt") + assert res_del["success"] is True + assert not (tmp_path / "test.txt").exists()