跳到主要内容

数据截至 (上游 commit 3309bf4e416f)

06 — 沙箱与文件操作

这章讲什么: agent 会执行模型生成的代码和命令。这一章讲 OpenManus 把它们放在哪儿跑、 隔离到什么程度,以及那个让「本地/容器」可以一键切换的抽象层长什么样。


1. 三条执行路径

先把地图摆清楚,免得混淆:

路径在哪跑谁在用隔离强度
PythonExecute宿主机的子进程ManusDataAnalysis只有超时,没有隔离
本地 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 进容器执行

本地实现直接用 pathlibasyncio.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_sandboxFalse默认不开沙箱
imagepython:3.12-slim基础镜像
work_dir/workspace容器内工作目录
memory_limit512m内存上限
cpu_limit1.0一个核
timeout300命令默认超时(秒)
network_enabledFalse默认断网

容器起来后跑的是 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 和本地沙箱的区别

维度本地 DockerDaytona
谁跑容器你的机器云服务
工具在哪工具在宿主机,只有文件操作进容器工具本身就在容器里
能看见吗看不见有 VNC 链接可以围观
依赖本机 Dockerdaytona 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.pyFileOperator
本地实现app/tool/file_operators.pyLocalFileOperator
沙箱实现app/tool/file_operators.pySandboxFileOperator
容器沙箱app/sandbox/core/sandbox.pyDockerSandboxDockerSandbox.create
资源限额组装app/sandbox/core/sandbox.pyDockerSandbox._prepare_volume_bindings_ensure_host_dir
路径穿越检查app/sandbox/core/sandbox.pyDockerSandbox._safe_resolve_path
容器内终端app/sandbox/core/terminal.pyAsyncDockerizedTerminalDockerSession
命令黑名单app/sandbox/core/terminal.pyDockerSession._sanitize_command
沙箱客户端单例app/sandbox/client.pyLocalSandboxClientSANDBOX_CLIENT
多沙箱管理器(未接线)app/sandbox/core/manager.pySandboxManager
沙箱配置app/config.pySandboxSettingsDaytonaSettings
Daytona 沙箱生命周期app/daytona/sandbox.pycreate_sandboxdelete_sandboxget_or_start_sandbox
云沙箱工具基类app/daytona/tool_base.pySandboxToolsBase
云沙箱智能体app/agent/sandbox_agent.pySandboxManusinitialize_sandbox_tools