This commit is contained in:
LIghtJUNction
2026-03-22 23:38:46 +08:00
7 changed files with 535 additions and 2 deletions
+318
View File
@@ -0,0 +1,318 @@
from __future__ import annotations
import asyncio
import locale
import os
import shutil
import subprocess
import shlex
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from astrbot.api import logger
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",
"--dir", "/tmp",
"--dir", "/var/tmp",
"--proc", "/proc",
"--dev", "/dev",
"--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, "r", 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
+6
View File
@@ -506,6 +506,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}")
+24 -2
View File
@@ -190,6 +190,8 @@ DEFAULT_CONFIG = {
"shipyard_neo_access_token": "",
"shipyard_neo_profile": "python-default",
"shipyard_neo_ttl": 3600,
"bwrap_rw_binds": [],
"bwrap_ro_binds": [],
},
"image_compress_enabled": True,
"image_compress_options": {
@@ -3163,12 +3165,32 @@ CONFIG_METADATA_3 = {
"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",
@@ -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)."
}
}
}
@@ -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 в том же расположении)."
}
}
}
@@ -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 已经是读写,整个宿主机系统默认是只读挂载于沙箱内的相同位置)。"
}
}
}
+151
View File
@@ -5,6 +5,8 @@ filesystem operations, Python execution, shell execution, and security restricti
"""
import sys
import os
import shutil
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -19,6 +21,15 @@ from astrbot.core.computer.booters.local import (
_is_safe_command,
)
from astrbot.core.computer.booters.bwrap import (
BwrapBooter,
BwrapConfig,
build_bwrap_cmd,
HostBackedFileSystemComponent,
BwrapPythonComponent,
BwrapShellComponent,
)
class TestLocalBooterInit:
"""Tests for LocalBooter initialization."""
@@ -886,3 +897,143 @@ class TestSyncSkillsToSandbox:
):
# 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()