Files
AstrBot/astrbot/core/computer/booters/bwrap.py
LIghtJUNction b4a32fbda8 fix: resolve documentation formatting issues
- Add 'config' to known zh/en doc structure differences
- Remove trailing whitespace from docs/zh/faq.md
- Remove trailing whitespace from docs/en/dev/plugin-platform-adapter.md
- Ensure all README files end with newline
2026-03-23 02:49:15 +08:00

349 lines
11 KiB
Python

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",
"--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, 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 = None, ro_binds: list[str] | None = 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 | None:
return self._fs
@property
def python(self) -> PythonComponent | None:
return self._python
@property
def shell(self) -> ShellComponent | None:
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 or not self.config:
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 or not self.config:
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