数据截至 (上游 commit 3309bf4e416f)
06 — 沙箱与文件操作
这章讲什么: agent 会执行模型生成的代码和命令。这一章讲 OpenManus 把它们放在哪儿跑、 隔离到什么程度,以及那个让「本地/容器」可以一键切换的抽象层长什么样。
1. 三条执行路径
先把地图摆清楚,免得混淆:
| 路径 | 在哪跑 | 谁在用 | 隔离强度 |
|---|---|---|---|
PythonExecute | 宿主机的子进程 | Manus、DataAnalysis | 只有超时,没有隔离 |
| 本地 Docker 沙箱 | 宿主机上的容器 | StrReplaceEditor(开关打开时) | 资源限额 + 默认断网 |
| Daytona 云沙箱 | 远程云容器 | SandboxManus | 完全隔离在远端 |
最容易误会的一点: config.toml 里的 use_sandbox 只影响走 FileOperator
的那些工具,不影响 PythonExecute——后者永远在宿主机跑
(app/tool/python_execute.py:61-64)。
2. 抽象层:FileOperator 协议
2.1 五个方法
FileOperator 是个 Protocol(app/tool/file_operators.py:15-39),
定义了「文件系统 + 命令执行」的最小面:
| 方法 | 语义 |
|---|---|
read_file(path) | 读文本 |
write_file(path, content) | 写文本 |
is_directory(path) | 是不是目录 |
exists(path) | 存不存在 |
run_command(cmd, timeout) | 返回 (returncode, stdout, stderr) |
2.2 两个实现
FileOperator (Protocol)
│
┌──────────┴───────────┐
▼ ▼
LocalFileOperator SandboxFileOperator
Path.read_text 走 SANDBOX_CLIENT
create_subprocess 进容器执行
本地实现直接用 pathlib 和 asyncio.create_subprocess_shell
(app/tool/file_operators.py:42-93);沙箱实现把每个调用转发给全局的
SANDBOX_CLIENT,并在第一次调用时懒创建容器
(app/tool/file_operators.py:102-105)。
2.3 一处不对称,要知道
SandboxFileOperator.run_command 的返回值是假的
(app/tool/file_operators.py:148-152):
return (
0, # Always return 0 since we don't have explicit return code from sandbox
stdout,
"", # No stderr capture in the current sandbox implementation
)
退出码恒为 0,stderr 恒为空。 依赖返回码判断成败的调用方,在沙箱模式下会失灵。 注释很坦白地写明了这一点。
2.4 判断目录靠 shell 回显
沙箱里没有 Path.is_dir(),于是用了个小技巧
(app/tool/file_operators.py:126-129):
result = await self.sandbox_client.run_command(
f"test -d {path} && echo 'true' || echo 'false'"
)
return result.strip() == "true"
简单有效,但路径没有转义——带空格或引号的路径会出问题(inferred)。
3. DockerSandbox:一个受限的容器
3.1 创建时的限制
DockerSandbox.create(app/sandbox/core/sandbox.py:49-103)组装的 host_config
是隔离的核心(:61-67):
host_config = self.client.api.create_host_config(
mem_limit=self.config.memory_limit,
cpu_period=100000,
cpu_quota=int(100000 * self.config.cpu_limit),
network_mode="none" if not self.config.network_enabled else "bridge",
binds=self._prepare_volume_bindings(),
)
默认值在 SandboxSettings(app/config.py:94-105):
| 配置项 | 默认值 | 含义 |
|---|---|---|
use_sandbox | False | 默认不开沙箱 |
image | python:3.12-slim | 基础镜像 |
work_dir | /workspace | 容器内工作目录 |
memory_limit | 512m | 内存上限 |
cpu_limit | 1.0 | 一个核 |
timeout | 300 | 命令默认超时(秒) |
network_enabled | False | 默认断网 |
容器起来后跑的是 tail -f /dev/null(:76)——一个什么都不做但不会退出的进程,
把容器当成一台常驻的小机器用。
3.2 工作目录挂的是临时目录
_ensure_host_dir(app/sandbox/core/sandbox.py:123-138)每次都在系统临时目录下
新建一个随机名字的文件夹:
host_path = os.path.join(
tempfile.gettempdir(),
f"sandbox_{os.path.basename(path)}_{os.urandom(4).hex()}",
)
所以容器里的 /workspace 不是项目根目录下的 workspace/,
而是一个一次性的临时目录。沙箱模式下产出的文件默认不落在项目里。
3.3 文件进出走 tar 流
Docker API 的 get_archive / put_archive 只收 tar。所以读文件是
「取 tar 流 → 写临时文件 → 解出内容」(:396-423),
写文件是「内存里造 tar 流 → put_archive」(:377-394)。
3.4 路径穿越检查
_safe_resolve_path(app/sandbox/core/sandbox.py:232-253)只有一条规则:
if ".." in path.split("/"):
raise ValueError("Path contains potentially unsafe patterns")
拦得住 ../../etc/passwd,拦不住绝对路径——因为绝对路径会被原样使用
(:248-252)。不过容器本身就是隔离边界,这层检查更像是纵深防御。
4. AsyncDockerizedTerminal:容器里的常驻 shell
和 03 章 讲的 Bash 工具是同一个思路,
只是搬进了容器。
4.1 建一个交互式 exec
DockerSession.create(app/sandbox/core/terminal.py:31-73)启动的命令是:
["bash", "-c", f"cd {working_dir} && PROMPT_COMMAND='' PS1='$ ' exec bash --norc --noprofile"]
三处刻意为之:
PS1='$ '—— 把提示符固定成两个字符,后面靠它判断「命令跑完了」。--norc --noprofile—— 不加载用户配置,避免输出被污染。TERM=dumb(:59)—— 不要 ANSI 转义序列。
然后拿到裸 socket 并设成非阻塞(:67-70)。
4.2 读输出:等提示符
execute(app/sandbox/core/terminal.py:139-216)发的是
f"{command}\necho $?\n",然后逐块读 socket,直到缓冲区以 "$ " 结尾
(:192-193)。中间还要跳过回显的命令行本身和那行退出码数字(:182-190)。
这是解析交互式终端的经典难题,代码里能看到不少启发式处理。
也正因如此,§2.3 里「退出码恒为 0」才成了现实——echo $? 的结果被当噪音过滤掉了。
4.3 命令过滤是黑名单
_sanitize_command(app/sandbox/core/terminal.py:218-248)拦的是七个字符串:
risky_commands = [
"rm -rf /", "rm -rf /*", "mkfs", "dd if=/dev/zero",
":(){:|:&};:", "chmod -R 777 /", "chown -R",
]
这是防手滑,不是防攻击。 稍微变形(rm -fr /、rm -rf /)就绕过了。
真正的安全边界是容器 + 断网 + 资源限额。
5. 两套生命周期管理
5.1 单例客户端( 实际在用的)
SANDBOX_CLIENT 是模块级全局单例(app/sandbox/client.py:201)。
BaseAgent.run 结束时会 await SANDBOX_CLIENT.cleanup()
(app/agent/base.py:153)——每跑完一次任务,容器就销毁一次。
5.2 多沙箱管理器(写好了但没接上)
SandboxManager(app/sandbox/core/manager.py:14-313)是一套完整的多容器管理:
| 能力 | 实现 |
|---|---|
| 数量上限 | max_sandboxes=100,超了拒绝创建(:131-135) |
| 空闲回收 | idle_timeout=3600,后台任务每 300 秒扫一遍(:174-204) |
| 并发控制 | 每个沙箱一把 asyncio.Lock + 一个全局锁(:88-112) |
| 镜像预拉 | ensure_image 找不到就 pull(:65-86) |
| 优雅关停 | 等活跃操作完成,最多等 5 秒(:244-276) |
但仓库里没有生产代码引用它——只有 tests/sandbox/test_sandbox_manager.py。
它是为「一个服务同时服务多个会话」准备的,当前 CLI 场景用不上。
6. Daytona:另一条云沙箱 路线
SandboxManus(app/agent/sandbox_agent.py)走的是完全不同的一套:
工具本身就跑在远程云沙箱里。
6.1 启动流程
initialize_sandbox_tools(app/agent/sandbox_agent.py:72-111):
create_sandbox(password) ← app/daytona/sandbox.py:102
│
├─ get_preview_link(6080) → VNC 地址(能看见桌面)
├─ get_preview_link(8080) → 网站预览地址
▼
装上四个沙箱工具:
SandboxBrowserTool / SandboxFilesTool / SandboxShellTool / SandboxVisionTool
6.2 和本地沙箱的区别
| 维度 | 本地 Docker | Daytona |
|---|---|---|
| 谁跑容器 | 你的机器 | 云服务 |
| 工具在哪 | 工具在宿主机,只有文件操作进容器 | 工具本身就在容器里 |
| 能看见吗 | 看不见 | 有 VNC 链接可以围观 |
| 依赖 | 本机 Docker | daytona SDK + API key |
| 谁清理 | run() 结束时 | cleanup() 里删沙箱(:188-196) |
配置项在 DaytonaSettings(app/config.py:108-124),
默认镜像 whitezxj/sandbox:0.1.0,默认 VNC 密码 123456——别把这套配置直接上生产。
7. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 文件操作协议 | app/tool/file_operators.py | FileOperator |
| 本地实现 | app/tool/file_operators.py | LocalFileOperator |
| 沙箱实现 | app/tool/file_operators.py | SandboxFileOperator |
| 容器沙箱 | app/sandbox/core/sandbox.py | DockerSandbox、DockerSandbox.create |
| 资源限额组装 | app/sandbox/core/sandbox.py | DockerSandbox._prepare_volume_bindings、_ensure_host_dir |
| 路径穿越检查 | app/sandbox/core/sandbox.py | DockerSandbox._safe_resolve_path |
| 容器内终端 | app/sandbox/core/terminal.py | AsyncDockerizedTerminal、DockerSession |
| 命令黑名单 | app/sandbox/core/terminal.py | DockerSession._sanitize_command |
| 沙箱客户端单例 | app/sandbox/client.py | LocalSandboxClient、SANDBOX_CLIENT |
| 多沙箱管理器(未接线) | app/sandbox/core/manager.py | SandboxManager |
| 沙箱配置 | app/config.py | SandboxSettings、DaytonaSettings |
| Daytona 沙箱生命周期 | app/daytona/sandbox.py | create_sandbox、delete_sandbox、get_or_start_sandbox |
| 云沙箱工具基类 | app/daytona/tool_base.py | SandboxToolsBase |
| 云沙箱智能体 | app/agent/sandbox_agent.py | SandboxManus、initialize_sandbox_tools |