mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 17:47:06 +08:00
- 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
349 lines
11 KiB
Python
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
|