feature: New sandbox booter Bubblewrap#6705
feature: New sandbox booter Bubblewrap#6705yizeyi18 wants to merge 9 commits intoAstrBotDevs:masterfrom
Conversation
- Based on Linux Namespace for resource isolation - Runs on local computer, with no privilege required - Only supports Linux as namespace & bubblewrap are not present on other platforms. TODO: - Fix dashboard presentation. Why change on src does not affect what is really displayed? - Strenghthen backend availability detection. One known issue is, on some platforms like Ubuntu 24.04, bubblewrap is banned by system guards, even when it's shipped by package manager. A complete detector may contain : 1. run the command with cmdline used by the booter. Return True if succsed. 2. If false, do bottom-up reason detection. Namespace not compiled to kernel? Specific kernel parameters not set? Banned by safety guard? The availability detector should give the user a clear information on why this sandbox backend fails. These work may require helps from frontend developers. It is tested to be usable on my computer, with non-persistent environment(forget on every command) and persistent file storage.
TODO add: - add plugin utility to change ro and rw bind in cmdline - make bind dirs dict instead of list to manually map mount point
in older commits, ro_bind = ['/'] makes skill sync crash. This commit fixes it and adds detection.
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request introduces a new sandbox booter for AstrBot using Bubblewrap, a lightweight Linux namespace wrapper. This provides a more secure and resource-efficient environment for executing code compared to the local executor, while being less resource-intensive than the Shipyard executor. The implementation leverages Linux namespaces for isolation and includes configurations and dashboard integration, though some UI issues need attention. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Hey - I've found 6 issues, and left some high level feedback:
- In
parse_descriptionyou changed"\n".join(lines[1:end_idx])to"\\n".join(...), which will produce literal backslash-n sequences instead of real newlines and is likely to break the frontmatter YAML parsing; this should probably stay as a real newline join. - In
BwrapBooter.bootyou callself.available()withoutawaiteven thoughavailableis declaredasync, so this condition will always be truthy (a coroutine object) instead of the intended boolean result; useif not await self.available():here. - In
HostBackedFileSystemComponent._safe_paththe path is joined directly without normalization or bounds checking, so inputs like../can escapeworkspace_dir; consider normalizing withos.path.realpathand rejecting paths that resolve outside the workspace.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `parse_description` you changed `"\n".join(lines[1:end_idx])` to `"\\n".join(...)`, which will produce literal backslash-n sequences instead of real newlines and is likely to break the frontmatter YAML parsing; this should probably stay as a real newline join.
- In `BwrapBooter.boot` you call `self.available()` without `await` even though `available` is declared `async`, so this condition will always be truthy (a coroutine object) instead of the intended boolean result; use `if not await self.available():` here.
- In `HostBackedFileSystemComponent._safe_path` the path is joined directly without normalization or bounds checking, so inputs like `../` can escape `workspace_dir`; consider normalizing with `os.path.realpath` and rejecting paths that resolve outside the workspace.
## Individual Comments
### Comment 1
<location path="astrbot/core/computer/computer_client.py" line_range="217" />
<code_context>
return ""
- frontmatter = "\n".join(lines[1:end_idx])
+ frontmatter = "\\n".join(lines[1:end_idx])
try:
import yaml
</code_context>
<issue_to_address>
**issue (bug_risk):** Using a literal "\n" string instead of a newline likely breaks YAML frontmatter parsing.
Previously, joining with "\n" produced actual newline characters; now the code joins with the literal backslash + n sequence. That changes the frontmatter’s serialized format and is likely to break YAML parsing unless this format change is deliberate. If not intentional, keep it as `"\n".join(...)` so YAML receives real line breaks.
</issue_to_address>
### Comment 2
<location path="astrbot/core/computer/booters/bwrap.py" line_range="278-287" />
<code_context>
+ if not self.available():
</code_context>
<issue_to_address>
**issue (bug_risk):** Async `available()` is used like a sync boolean, so the availability check never actually runs.
`available` is `async def available(self) -> bool`, but here it’s called without `await`, so the condition is always false (coroutine objects are truthy) and the runtime error will never trigger when `bwrap` is missing. Either make `available` a regular synchronous method and keep this call, or keep it async and use `if not await self.available():` instead.
</issue_to_address>
### Comment 3
<location path="astrbot/core/computer/booters/bwrap.py" line_range="181-191" />
<code_context>
+ """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]:
</code_context>
<issue_to_address>
**🚨 suggestion (security):** Absolute paths in HostBackedFileSystemComponent are not constrained to the workspace, which weakens isolation guarantees.
Because `_safe_path` returns absolute paths unchanged, callers can perform file operations anywhere on the host filesystem via this component, effectively bypassing the intended sandbox. To preserve isolation, normalize the path and enforce that it resides under `workspace_dir` (e.g., via `os.path.realpath` and checking it has `workspace_dir` as a prefix), and reject any paths that resolve outside it.
```suggestion
@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:
"""
Resolve a path safely within the workspace directory.
The returned path is a normalized, absolute path guaranteed to reside
under ``self.workspace_dir``. Any path that resolves outside the
workspace (including via absolute paths or ``..`` segments) will be
rejected.
"""
# Normalize the workspace root to an absolute, real path
workspace_root = os.path.realpath(self.workspace_dir)
# Build a candidate path: relative paths are resolved against workspace_root,
# absolute paths are kept but normalized below.
candidate = path
if not os.path.isabs(candidate):
candidate = os.path.join(workspace_root, candidate)
# Normalize the candidate path (resolve symlinks and "..")
candidate = os.path.realpath(candidate)
# Enforce that the candidate is within the workspace root
if os.path.commonpath([workspace_root, candidate]) != workspace_root:
raise PermissionError(f"Attempted access outside workspace: {path!r}")
return candidate
```
</issue_to_address>
### Comment 4
<location path="tests/unit/test_computer.py" line_range="904-907" />
<code_context>
+ 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]
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Extend `test_build_bwrap_cmd` to cover network isolation and dangerous path filtering
This test currently only checks a few basic flags and the workspace bind, but it’s missing coverage for two key behaviors of `build_bwrap_cmd`:
- `share_net=False` should add `--unshare-net` to the command (and `share_net=True` should not).
- `rw_binds` entries for `/` or paths under `/root` should be excluded from the bind list.
Please add subcases or parameterize the test so that:
- Using `BwrapConfig(workspace_dir=..., share_net=False, ...)` asserts `--unshare-net` is present, and with `share_net=True` it is absent.
- Passing `rw_binds=["/", "/root", "/root/project", "/safe"]` asserts that only `/safe` appears in the `--bind` arguments.
This will better protect against regressions in the sandbox isolation behavior.
```suggestion
# 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):
# Base behavior
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]
# Network isolation: share_net=False should add --unshare-net
config_no_net = BwrapConfig(
workspace_dir="/tmp/test",
rw_binds=[],
ro_binds=[],
share_net=False,
)
cmd_no_net = build_bwrap_cmd(config_no_net, ["true"])
assert "--unshare-net" in cmd_no_net
# Network sharing: share_net=True should NOT add --unshare-net
config_with_net = BwrapConfig(
workspace_dir="/tmp/test",
rw_binds=[],
ro_binds=[],
share_net=True,
)
cmd_with_net = build_bwrap_cmd(config_with_net, ["true"])
assert "--unshare-net" not in cmd_with_net
# Dangerous rw_binds (/, /root, /root/...) should be filtered out,
# leaving only the safe path.
dangerous_rw_binds = ["/", "/root", "/root/project", "/safe"]
config_filtered = BwrapConfig(
workspace_dir="/tmp/test",
rw_binds=dangerous_rw_binds,
ro_binds=[],
)
cmd_filtered = build_bwrap_cmd(config_filtered, ["true"])
# Collect all explicit --bind source/target pairs so we only
# reason about user rw_binds and other bind-style mounts.
bind_pairs = [
(cmd_filtered[i + 1], cmd_filtered[i + 2])
for i, arg in enumerate(cmd_filtered)
if arg == "--bind" and i + 2 < len(cmd_filtered)
]
# Ensure /safe is actually bound somewhere
assert any(src == "/safe" or dst == "/safe" for src, dst in bind_pairs)
# Ensure dangerous paths are not bound directly
assert all(src != "/" for src, _ in bind_pairs)
assert all(not src.startswith("/root") for src, _ in bind_pairs)
```
</issue_to_address>
### Comment 5
<location path="tests/unit/test_computer.py" line_range="964-930" />
<code_context>
+ 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
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for background execution and timeout/error handling in `BwrapShellComponent`
The existing tests only cover a simple success case and one read-only root scenario, leaving key branches of `BwrapShellComponent.exec` untested. Please also cover:
* The `background=True` path (using `subprocess.Popen`), asserting a non-`None` `pid` and `exit_code is None`.
* Timeout behavior, e.g. a long-running command that exceeds a small `timeout`, and how that result is surfaced.
* A non-zero exit case where `stderr` is meaningfully populated.
These additions will ensure sandboxed shell execution is validated under failure and long-running conditions, not just the happy path.
Suggested implementation:
```python
+@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_exec_background(self):
+ """
+ Ensure background=True path returns a pid and does not wait for completion.
+ """
+ booter = BwrapBooter()
+ await booter.boot("test_shell_background")
+
+ # Use a short sleep to keep the test fast while exercising background execution.
+ res = await booter.shell.exec("sleep 2", background=True)
+
+ assert res["pid"] is not None
+ assert res["exit_code"] is None
+ # Background run should not have collected full output yet.
+ assert res.get("stdout") in (None, "", b"")
+ assert res.get("stderr") in (None, "", b"")
+
+ await booter.shutdown()
+
+ @pytest.mark.asyncio
+ async def test_bwrap_shell_exec_timeout(self):
+ """
+ Ensure a long-running command exceeding timeout surfaces a timeout condition.
+ """
+ booter = BwrapBooter()
+ await booter.boot("test_shell_timeout")
+
+ # Command that should exceed the small timeout.
+ with pytest.raises(asyncio.TimeoutError):
+ await booter.shell.exec("sleep 5", timeout=0.1)
+
+ await booter.shutdown()
+
+ @pytest.mark.asyncio
+ async def test_bwrap_shell_exec_non_zero_exit_with_stderr(self):
+ """
+ Ensure non-zero exit codes propagate with meaningful stderr.
+ """
+ booter = BwrapBooter()
+ await booter.boot("test_shell_non_zero")
+
+ # Target a clearly invalid path to force an error.
+ res = await booter.shell.exec("ls /definitely_non_existent_path_for_bwrap_test")
+
+ assert res["exit_code"] != 0
+ # stderr should contain some error message
+ assert res["stderr"]
+
+ await booter.shutdown()
+
+ @pytest.mark.asyncio
```
1. Ensure `asyncio` is imported at the top of `tests/unit/test_computer.py`, e.g. `import asyncio`, since the timeout test uses `asyncio.TimeoutError`.
2. If `BwrapShellComponent.exec` surfaces timeouts differently (e.g. by returning a result dict with a `timed_out` flag or specific `exit_code`), adjust `test_bwrap_shell_exec_timeout` accordingly:
* Remove the `pytest.raises(asyncio.TimeoutError)` context manager.
* Assert on the actual fields your implementation sets (e.g. `res["timed_out"] is True` or `res["exit_code"] != 0` and `"timeout" in res["stderr"].lower()`).
3. If the result structure differs from the assumed `{"exit_code": ..., "stdout": ..., "stderr": ..., "pid": ...}`, update the key accesses in the new tests to match your actual return schema.
</issue_to_address>
### Comment 6
<location path="astrbot/core/computer/booters/bwrap.py" line_range="93" />
<code_context>
+class BwrapShellComponent(ShellComponent):
+ config: BwrapConfig
+
+ async def exec(
+ self,
+ command: str,
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting shared bwrap subprocess logic into a helper, fixing the async `available` usage, and tightening `_safe_path` to clarify boundaries and reduce duplicated, error-prone code paths.
You can reduce complexity and fix a couple of issues with small refactors without changing behavior.
### 1. Factor out shared bwrap subprocess orchestration
`BwrapShellComponent.exec` and `BwrapPythonComponent.exec` both:
- Build a bwrap command
- Run `subprocess.run`/`Popen`
- Normalize stdout/stderr/exit_code
Extracting this into a helper removes duplication and nesting and makes later changes safer:
```python
def _run_in_bwrap(
config: BwrapConfig,
script_cmd: list[str],
*,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
background: bool = False,
text: bool = False,
) -> 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 config.workspace_dir
bwrap_cmd = build_bwrap_cmd(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,
text=text,
)
if text:
stdout, stderr = result.stdout, result.stderr
else:
stdout = _decode_shell_output(result.stdout)
stderr = _decode_shell_output(result.stderr)
return {
"stdout": stdout,
"stderr": stderr,
"exit_code": result.returncode,
}
```
Then the components become much simpler and symmetric:
```python
@dataclass
class BwrapShellComponent(ShellComponent):
config: BwrapConfig
async def exec(...):
def _run() -> dict[str, Any]:
script_cmd = ["/bin/sh", "-c", command] if shell else shlex.split(command)
return _run_in_bwrap(
self.config,
script_cmd,
cwd=cwd,
env=env,
timeout=timeout,
background=background,
text=False, # still uses _decode_shell_output
)
return await asyncio.to_thread(_run)
```
```python
@dataclass
class BwrapPythonComponent(PythonComponent):
config: BwrapConfig
async def exec(...):
def _run() -> dict[str, Any]:
script_cmd = [os.environ.get("PYTHON", "python3"), "-c", code]
try:
result = _run_in_bwrap(
self.config,
script_cmd,
timeout=timeout,
background=False,
text=True,
)
if silent:
result["stdout"] = ""
return result
except subprocess.TimeoutExpired as e:
return {
"stdout": e.stdout if isinstance(e.stdout, str) 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)
```
This preserves behavior (including background handling and decoding) while centralizing the bwrap + subprocess logic.
### 2. Fix `available` async usage and simplify the check
`boot` calls `if not self.available():` but `available` is `async`, which is both a bug and adds cognitive load. Given `available` only does synchronous checks, it can be synchronous:
```python
class BwrapBooter(ComputerBooter):
...
def available(self) -> bool:
if sys.platform == "win32":
return False
if shutil.which("bwrap") is None:
return False
return True
```
And `boot` stays as-is:
```python
async def boot(self, session_id: str) -> None:
...
self._shell = BwrapShellComponent(self.config)
if not self.available():
raise RuntimeError(
"BubbleWrap sandbox unavailable on current machine for no bwrap executable."
)
...
```
If `ComputerBooter` requires `available` to be async, then keep the body synchronous and correctly await it:
```python
async def available(self) -> bool:
if sys.platform == "win32":
return False
return shutil.which("bwrap") is not None
```
```python
async def boot(self, session_id: str) -> None:
...
if not await self.available():
raise RuntimeError(
"BubbleWrap sandbox unavailable on current machine for no bwrap executable."
)
...
```
Either approach reduces confusion and fixes the current incorrect call.
### 3. Tighten `_safe_path` semantics with minimal change
Currently `_safe_path` is permissive and the comment suggests a stricter policy. You can make the policy explicit and still simple:
```python
def _safe_path(self, path: str) -> str:
if not path.startswith("/"):
path = os.path.join(self.workspace_dir, path)
# Normalize and ensure it stays under workspace_dir
abs_path = os.path.abspath(path)
if not abs_path.startswith(os.path.abspath(self.workspace_dir) + os.sep):
raise ValueError(f"Path outside workspace_dir is not allowed: {path}")
return abs_path
```
This keeps the existing “map into workspace” behavior but makes the security/ownership boundary clear and enforced, which reduces future surprises and reasoning complexity around file operations.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
Related Documentation 1 document(s) may need updating based on files changed in this PR: AstrBotTeam's Space pr4697的改动View Suggested Changes@@ -117,7 +117,7 @@
- 如果 `computer_use_runtime` 设置为 `"local"`,立即返回本地 booter,无需检查 sandbox booter 配置
- 如果 `computer_use_runtime` 设置为 `"none"`,抛出 `RuntimeError` 提示沙箱运行时已禁用
-- 只有在以上条件均不满足时,才继续检查 sandbox booter 配置(如 `shipyard_neo` 等)
+- 只有在以上条件均不满足时,才继续检查 sandbox booter 配置(如 `shipyard_neo`、`shipyard`、`bwrap` 等)
该优化避免了在运行时类型已明确时进行不必要的沙箱配置检查,提高了边缘场景的性能。
@@ -171,6 +171,32 @@
- `shipyard_neo_access_token`:Bay 的 API 密钥(`sk-bay-...`),留空时自动从 `credentials.json` 发现
- `shipyard_neo_profile`:沙箱 profile 名称,如 `python-default`(留空时自动选择能力最多的 profile)
- `shipyard_neo_ttl`:沙箱生存时间(秒),默认 3600
+
+**Bubblewrap 沙箱支持(PR #6705)**
+
+[PR #6705](https://github.com/AstrBotDevs/AstrBot/pull/6705) 新增了 Bubblewrap 作为沙箱环境驱动器(booter)选项。Bubblewrap 是一款基于 Linux namespace 的轻量级沙箱工具,提供介于本地执行器与 Shipyard 容器之间的安全性和性能平衡。
+
+- **隔离机制**:使用 Linux namespace(pid、network、ipc、mount)提供内核级资源隔离
+- **性能特性**:比 local 执行器更安全,比 Shipyard/Shipyard Neo 更轻量
+- **平台限制**:仅支持 Linux 系统,需要安装 `bwrap` 可执行文件
+- **挂载配置**:支持自定义读写和只读挂载路径
+ - `bwrap_rw_binds`:额外的读写挂载目录(默认:`/tmp`、`/var/tmp`)
+ - `bwrap_ro_binds`:额外的只读挂载目录(默认:`/usr`、`/etc`、`/opt`)
+
+**配置方式**:
+
+在 `provider_settings.sandbox.booter` 中选择 `"bwrap"` 即可启用 Bubblewrap 沙箱。配置示例:
+
+```yaml
+sandbox:
+ booter: "bwrap"
+ bwrap_rw_binds:
+ - "/home/user/data" # 自定义读写目录
+ bwrap_ro_binds:
+ - "/mnt/shared" # 自定义只读目录
+```
+
+Bubblewrap 沙箱适用于需要更高安全性但不希望使用容器的场景。系统默认隔离 pid、ipc、uts namespace,并提供独立的挂载表,确保沙箱内的操作不影响宿主机环境。
**本地沙箱增强模式(local_sandboxed)**
Note: You must be authenticated to accept/decline updates. |
There was a problem hiding this comment.
Code Review
您好,感谢您为 AstrBot 贡献新的 Bubblewrap 沙箱执行器!这是一个非常棒的功能,为用户提供了一个比 local 执行器更安全、比 shipyard(neo) 更轻量的选择。代码实现很完整,包括了 shell、Python 和文件系统组件,并添加了相应的配置和单元测试。
在审查代码时,我发现了一些需要修正的关键问题,主要集中在安全性和代码正确性上:
HostBackedFileSystemComponent和BwrapBooter中存在严重的文件路径遍历漏洞,这可能允许 agent 访问和修改沙箱工作区之外的文件。BwrapBooter.boot方法中有一个 bug,一个异步函数调用没有被正确await。computer_client.py中的一处修改可能会破坏 frontmatter 的解析。
具体的修改建议和细节请见各条评论。修复这些问题后,这个 PR 将会是一个非常出色的贡献。
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
BubbleWrap是一款 linux namespace的wrapper,可用于创建轻量化的linux namespace沙箱以隔离资源。本PR为astrbot实现了BubbleWrap沙箱执行器,作为比local执行器更安全、比shipyard(neo)执行器更轻量的沙箱环境。
具体而言,linux namespace提供的pid namespace、network namespace、ipc namespace可对上述资源做内核级隔离;mount namespace可构造独立挂载表、隔离文件系统,同时不影响父namespace中的挂载情况;由于内核对挂载表的权限限制,编辑它仍需要(某个namespace下的)root权限。user namespace为非特权用户提供了这样的工具;与此同时,它不会允许非特权用户修改那些需要特权的文件,即使在此namespace下用户的uid被map为1。linux namespace是linux容器隔离资源的主要机制,其本身的可靠性好、调用简单,因此我希望用它来为AstrBot实现一个轻量级沙箱执行器。
关于为何不直接用
unshare工具创建namespace:unshare的不可及问题:ubuntu、rhel等发行版默认允许非特权用户创建user namespace,但通过apparmor等安全工具实现白名单、禁止包括unshare在内的一般程序创建user namespace,据说这与linux namespace的一些安全隐患有关。这使得util-linux提供的unshare命令行工具在相当多发行版默认不能用于创建user namespace、由此不能由非特权用户在新的mount namespace中挂载目录(这是隔离资源所必须),因此本PR没有选择unshare作为沙箱后端。除BubbleWrap外,
runc也是一款功能类似的以linux namespace为基础、广泛预装的沙箱工具,本PR暂未为其做实现。与本PR配套,我还写了用对话命令更改沙箱配置的插件,地址是 https://github.com/yizeyi18/AstrBotSandboxEditor.git 。由于不清楚astrbot core对插件引入的政策,这个PR不包含其内容。
Modifications / 改动点
astrbot/core/computer/booters/bwrap.py文件,在其中实现了执行器所需的shell、python、文件系统操作等。astrbot/core/computer/computer_client.py中添加了相关初始化entry。astrbot/core/config/default.py中添加了dashboard entry。本机运行中description和hint显示不出来,不知道是什么原因。我不熟悉前端开发,这需要开发团队协助诊断。tests/unit/test_computer.py中添加了单元测试。由于以下截图显示的原因没能运行起来。Screenshots or Test Results / 运行截图或测试结果
前述插件的指令列表及其功能:




配置家目录为只读挂载,检查挂载情况:
配置家目录为读写挂载,检查挂载情况:
dashboard entry。即使参照local booter与shipyard_neo booter在默认配置中添加了description与hint、修改了i18n文件,dashboard也仍然不显示,这需要开发团队善后:
这些截图基本上可以说明本PR涉及的执行器可以正常初始化、执行代码,能按需隔离资源,能通过dashboard进行设置。
测试方面,我写了相关单元测试,但遇到了似乎与本PR无关的问题。我不了解pytest的工作方式,可能需要指望ci运行:

Checklist / 检查清单
😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
/ 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。
🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in
requirements.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.toml文件相应位置。😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
Summary by Sourcery
Add a Bubblewrap-based sandbox booter and wire it into the computer runtime and configuration system.
New Features:
Tests: