数据截至 (上游 commit e839e559ac61)
Docker 沙箱执行:把危险工具关进容器 + 路径翻译
30 秒导读: agent 会调用
bash、改文件这类能破坏宿主机的工具。本章讲 Trae Agent 的一个可选防护:把这几个高危工具单独转发进一个 Docker 容器里跑,改文件也只改容器里挂载的那份 workspace,别的工具(如搜索、CKG)照旧在宿主跑。难点是容器里可能连 Python 都没有——它靠把编辑工具用 pyinstaller 编成独立二进制塞进去解决。
1. 这是什么(零基础也能懂)
一句话定义: 一层可选的隔离层——当你在命令行加上 --docker-image 之类的参数时,agent 就把 bash 和文件编辑工具的每一次调用,改成"在一个 Docker 容器里执行",而不是直接在你的机器上执行。
解决什么问题: LLM agent 会自己决定跑什么命令、改哪个文件。这很危险——一条 rm -rf 或一个走偏的路径就可能动到宿主机上不该动的东西。把这些动作关进容器,等于给 agent 一个一次性的、可丢弃的工作间:它在里面折腾,脏了直接销毁容器,宿主机不受影响。
关键取舍——不是全隔离,是"挑危险的隔离": 只有 3 个工具进容器,其余仍在宿主跑(见 §2)。这样搜索代码、查 CKG(代码知识图谱)这些只读、无害的操作不用付容器往返的代价。
用起来什么样: 命令行给一个容器来源即可,其它照常:
# 用一个现成镜像跑,任务结束后自动删容器
trae-cli run "修复这个 bug" --docker-image ubuntu:22.04 --docker-keep false
trae_agent/cli.py:145-174 定义了这组互斥的 Docker 开关:--docker-image / --docker-container-id / --dockerfile-path / --docker-image-file 四选一,外加 --docker-keep 决定跑完删不删容器。
一句话直觉: 把它想成"给 agent 的手套"——它伸手去动文件系统时,隔着容器这层手套,脏活干在手套里,脱下(删容器)就干净了。
2. 顶层全景(它大概怎么转)
Docker 模式的核心是一个装饰器(decorator)模式:真正干活的 ToolExecutor 被一个 DockerToolExecutor 包在外面,后者拦截工具调用,按工具名决定"转发进容器"还是"放行回宿主"。
怎么读下面这张图: 从上往下是一次工具调用的分流;左边是被隔离进容器的路径,右边是留在宿主的路径。
agent 决定调用某工具
│
▼
┌───────────────────────────┐
│ DockerToolExecutor │ ← 装饰器,按工具名分流
│ (docker_tool_executor) │
└───────────────────────────┘
│ │
工具名 ∈ 白名单? 其余工具
bash / 编辑 / json编辑 (如 sequentialthinking、CKG)
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────┐
│ _execute_in_docker │ │ 原始 ToolExecutor │
│ · 翻译 path │ │ 在宿主直接跑 │
│ · 拼容器内命令 │ └──────────────────┘
│ · DockerManager. │
│ execute() │
└────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ Docker 容器 │
│ · /workspace ← 挂载宿主 workspace_dir │
│ · /agent_tools ← pyinstaller 二进制 │
│ · 持久 bash shell(pexpect 驱动) │
└────────────────────────────────────────┘
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
DockerToolExecutor | 装饰器:按工具名分流,翻译路径,拼容器命令 | trae_agent/tools/docker_tool_executor.py |
DockerManager | 管容器生命周期:建/连容器、挂 workspace、拷工具、跑命令、销毁 | trae_agent/agent/docker_manager.py |
BaseAgent.__init__ | 接线:有 docker_config 就用装饰器包住原执行器 | trae_agent/agent/base_agent.py:49-71 |
build_with_pyinstaller | 把编辑工具编成独立二进制,供容器内无 Python 也能跑 | trae_agent/cli.py:89-116 |
check_docker | 跑前体检:docker CLI 装了吗、daemon 活着吗 | trae_agent/cli.py:53-86 |
主线走一遍: CLI 拿到 Docker 参数 → 组出 docker_config → check_docker 体检、首次用则 build_with_pyinstaller 打二进制 → BaseAgent.__init__ 用 DockerToolExecutor 包住原执行器 → execute_task 里 docker_manager.start() 建容器挂 workspace → 每次工具调用经装饰器分流 → 任务结束按 docker_keep 决定 stop()。
3. 核心原理(逐个机制,由浅入深)
3.1 接线:docker_config 存在就套一层装饰器
它要解决的小问题: 怎么在不改动任何工具本身代码的前提下,让部分工具改走容器?
思路: 用装饰器。原始 ToolExecutor(self._tools) 照建,然后如果有 docker_config,就把它塞进 DockerToolExecutor 里当 original_executor。对上层(agent 主循环,见 01-agent-loop)而言,self._tool_caller 的接口没变,感知不到有没有容器。
真实实现: trae_agent/agent/base_agent.py:48-73
# trae_agent/agent/base_agent.py:48-71(节选)
original_tool_executor = ToolExecutor(self._tools)
if docker_config:
tools_dir = os.path.join(project_root, "dist") # 打包好的二进制在这
self.docker_manager = DockerManager(...)
self._tool_caller = DockerToolExecutor(
original_executor=original_tool_executor, # 原执行器被包进去
docker_manager=self.docker_manager,
docker_tools=["bash", "str_replace_based_edit_tool", "json_edit_tool"], # 白名单
host_workspace_dir=docker_config.get("workspace_dir"),
container_workspace_dir=self.docker_manager.container_workspace,
)
else:
self._tool_caller = original_tool_executor
关键细节——白名单只有 3 个:
| 工具名 | 干什么 | 为何要隔离 |
|---|---|---|
bash | 跑任意 shell 命令 | 最危险,能删文件、连网、跑任意程序 |
str_replace_based_edit_tool | 按字符串替换改文件 | 会写宿主文件系统 |
json_edit_tool | 按 JSONPath 改 JSON 文件 | 会写宿主文件系统 |
不在这三个里的工具(如 sequentialthinking、CKG 相关工具)永远在宿主跑——它们要么纯思考、要么只读,隔离没收益。这份白名单在 base_agent.py:68 硬编码。
3.2 DockerManager:容器从哪来、workspace 怎么进去
它要解决的小问题: "在容器里跑"——可这个容器从哪冒出来?agent 改的文件又怎么让宿主看得到?
镜像/容器有 4 种来源(互斥,cli.py:226-259 保证只选一个):
| 来源 | docker_config 键 | start() 里怎么处理 |
|---|---|---|
| 现成镜像名 | image | 直接 containers.run 起新容器 |
| 已存在的容器 ID | container_id | containers.get 附着,标记 _is_managed=False(不归它管、不销毁) |
| Dockerfile 路径 | dockerfile_path | 现场 images.build 编个临时镜像再起容器 |
| 镜像 tar 文件 | docker_image_file | images.load 从文件加载镜像再起容器 |
workspace 挂载是关键: 起新容器时,把宿主的 workspace_dir 以读写方式挂到容器内固定路径 /workspace。这样容器里改的文件,宿主机同一目录也立刻变——agent 的成果留得下来。
# trae_agent/agent/docker_manager.py:91-105(节选)
volumes = {
os.path.abspath(self.workspace_dir): {
"bind": self.container_workspace, # "/workspace"
"mode": "rw",
}
}
self.container = self.client.containers.run(
self.image, command="sleep infinity", # 容器空转待命
detach=True, volumes=volumes, working_dir=self.container_workspace,
)
command="sleep infinity" 让容器起来后什么都不干、只保持存活,等着 agent 一条条塞命令进去(见 §3.4 的持久 shell)。
生命周期由 agent 主循环掐两头: start() 在任务开始时调,stop() 在结束时按 docker_keep 决定调不调:
# trae_agent/agent/base_agent.py:151-152, 187-188(节选)
if self.docker_manager:
self.docker_manager.start() # execute_task 开头
...
finally:
if self.docker_manager and not self.docker_keep:
self.docker_manager.stop() # 结尾,且 docker_keep=False 才销毁
只销毁自己建的容器: stop() 里的 _is_managed 标志很重要——若用户是 --docker-container-id 附着到一个已存在的容器(docker_manager.py:87 设 _is_managed=False),agent 就算 docker_keep=False 也不会去删它,只关自己起的持久 shell。见 docker_manager.py:147。
3.3 _translate_path:宿主路径 → 容器内路径
它要解决的小问题: LLM 说"改 /home/me/proj/app.py",但这是宿主机的路径;容器里同一个文件挂在 /workspace/app.py。直接把宿主路径喂给容器内的工具会找不到文件。
思路: workspace 在宿主是 host_workspace_dir、在容器是 /workspace。所以只要判断某路径在 workspace 里,就把前缀换掉:抽出它相对 workspace 的部分,再拼到 /workspace 上。不在 workspace 里的路径原样返回(不翻译)。
原理演示(示意,非源码):
# 示意:宿主 workspace = /home/me/proj,容器 workspace = /workspace
def translate(host_path):
rel = relative_to(host_path, "/home/me/proj") # "app.py"
return join("/workspace", rel) # "/workspace/app.py"
# 重点看:只换前缀,相对结构不变;不在 workspace 内的路径不动
真实实现: trae_agent/tools/docker_tool_executor.py:35-47,函数 _translate_path。用 os.path.commonpath 判断 abs_host_path 是否落在 workspace 内,是则 relpath + join 重拼,否则原样返回。
谁触发翻译: _execute_in_docker 遍历工具参数,凡是叫 path 的字符串参数都翻译一遍(docker_tool_executor.py:85-91)。这是个约定俗成的假设——参数名 path 即视为需翻译的路径。
3.4 _execute_in_docker:把工具调用变成容器内一条命令
它要解决的小问题: 工具调用是结构化的(工具名 + 一堆参数),可容器里只认 shell 命令字符串。怎么把前者翻成后者?
思路: 按工具名分三种拼法,拼出一条命令字符串,交给 DockerManager.execute 在容器的持久 shell 里跑。
三种拼法:
| 工具 | 拼成的容器命令 |
|---|---|
bash | 直接就是 command 参数那串 shell 命令 |
str_replace_based_edit_tool | /agent_tools/edit_tool <子命令> --key 'value' ... |
json_edit_tool | /agent_tools/json_edit_tool --key 'value' ...(value 参数序列化成 JSON 串) |
注意后两个调用的是 /agent_tools/ 下的独立二进制——这就是 pyinstaller 打包产物在容器内的落点(CONTAINER_TOOLS_PATH = "/agent_tools",docker_manager.py:16)。
真实实现: trae_agent/tools/docker_tool_executor.py:77-155,函数 _execute_in_docker。片段:
# trae_agent/tools/docker_tool_executor.py:113-125(编辑工具分支,节选)
executable_path = f"{self._docker_manager.CONTAINER_TOOLS_PATH}/edit_tool"
cmd_parts = [executable_path, sub_command]
for key, value in processed_args.items():
if key == "command" or value is None:
continue
...
cmd_parts.append(f"--{key} '{str(value)}'")
command_to_run = " ".join(cmd_parts)
...
exit_code, output = self._docker_manager.execute(command_to_run) # 进容器跑
拼好后 DockerManager.execute 走 _execute_interactive(docker_manager.py:204-242):往 pexpect 驱动的持久 bash shell sendline,用一个 ---CMD_DONE---$? 标记回收退出码、清洗掉命令回显,把 (exit_code, output) 交回来。退出码 0 即 success=True。
顺序 vs 并行: DockerToolExecutor 的 parallel_tool_call 直接转调 sequential_tool_call(docker_tool_executor.py:70-75)——容器里那个单一持久 shell 是共享状态,并行会互相踩,所以一律串行。sequential_tool_call 里也是逐个分流:白名单进 _execute_in_docker,其余 await self._original_executor.sequential_tool_call([tool_call]) 回宿主(docker_tool_executor.py:57-68)。
3.5 为什么要 pyinstaller 打包编辑工具?
它要解决的小问题: 用户给的镜像可能是个极简系统——ubuntu:22.04 里连 Python 都没有,更别说 Trae Agent 的一堆依赖(jsonpath_ng 之类)。但编辑工具是用 Python 写的,怎么在这种容器里跑?
思路: 不指望容器有 Python。改用 PyInstaller(把 Python 程序连解释器一起打包成单个可执行文件的工具),在宿主上把编辑工具编成自带解释器和依赖的独立二进制,再 docker cp 拷进容器。容器里直接 ./edit_tool 就能跑,零运行时依赖。
打包做了什么: trae_agent/cli.py:89-116,函数 build_with_pyinstaller:
# trae_agent/cli.py:92-108(节选)
subprocess.run(["pyinstaller", "--name", "edit_tool",
"trae_agent/tools/edit_tool_cli.py"], check=True)
subprocess.run(["pyinstaller", "--name", "json_edit_tool",
"--hidden-import=jsonpath_ng", # 显式带上运行时才 import 的依赖
"trae_agent/tools/json_edit_tool_cli.py"], check=True)
# 之后把产物拷进 trae_agent/dist/
它编译的不是工具原文件,而是两个专门的 CLI 包装:trae_agent/tools/edit_tool_cli.py 和 json_edit_tool_cli.py。这俩把工具逻辑重写成能命令行独立跑的形态——用 argparse 收参数,还内置了个空的 override 装饰器和极简 Tool 基类,好让代码脱离 Trae Agent 主包也能编译(见 edit_tool_cli.py 开头的注释:"我们不再需要它,所以定义一个什么都不做的函数")。
产物落点与拷贝链路:
宿主构建:build_with_pyinstaller
→ trae_agent/dist/{edit_tool, json_edit_tool, _internal}
│
│ base_agent 把 dist 当 tools_dir 交给 DockerManager
▼
容器内:DockerManager._copy_tools_to_container
→ docker cp dist → 容器 /agent_tools/
│
▼
_execute_in_docker 拼出 /agent_tools/edit_tool ... 调用
_copy_tools_to_container(docker_manager.py:162-179)就是那句 docker cp:把宿主 dist/ 整个拷到容器的 /agent_tools。tools_dir 在 base_agent.py:53 被指向 dist。
只在首次构建: cli.py:272-275 检查 trae_agent/dist 和 dist/_internal 存在与否,不存在才 build_with_pyinstaller()——第一次用 Docker 模式会慢几秒打包,之后复用。
4. 巧妙之处(可借鉴的技术)
-
装饰器包执行器,零侵入工具。 不改任何一个 Tool 的实现,仅在执行器外套一层就实现了"部分工具改道"。上层主循环完全无感(
base_agent.py:65-73)。 -
白名单式隔离,而非全隔离。 只把真正危险的写操作/shell 关进容器,只读操作留宿主,省掉大量无谓的容器往返(
base_agent.py:68)。 -
pyinstaller 打二进制破解"容器无 Python"。 让沙箱镜像可以极简,不必为跑 agent 的编辑工具而预装一堆依赖(
cli.py:89-116+ 两个*_cli.py包装)。 -
_is_managed区分"我建的"和"我借的"容器。 附着到用户已有容器时绝不误删,只清理自己run出来的(docker_manager.py:87, 147)。
5. 边界与局限(诚实)
-
不是全隔离。 白名单外的工具仍在宿主执行。若某个非白名单工具将来也能写文件系统,这层保护就有缺口。隔离范围硬编码在
base_agent.py:68。 -
路径翻译靠"参数名叫
path"的约定。_execute_in_docker只翻译名为path的参数(docker_tool_executor.py:87)。若某工具用别的参数名传路径(如file_path),不会被翻译,可能在容器内路径错位。 -
命令拼接靠单引号包裹。
--{key} '{str(value)}'(docker_tool_executor.py:123)没有对含单引号的值做转义,含特殊字符的参数可能拼出错误命令(inferred,从拼接方式看出)。 -
并行工具调用被降级为串行。 容器内共享单个持久 shell,
parallel_tool_call实际串行执行(docker_tool_executor.py:70-75),Docker 模式下拿不到并行加速。 -
Docker 不可用直接退出,不是静默降级。 跑前
check_docker(cli.py:53-86)体检:docker CLI 没装、或 daemon 不通,cli.py:267-271打印错误并sys.exit(1)——不会回退到宿主执行。也就是说加了 Docker 参数就必须有可用 Docker,否则任务不启动。
6. 横向对比
同属本 shelf 的沙箱思路各有取舍:Trae Agent 这里是工具级、可选、白名单的隔离——粒度细、默认关、只挡写操作,用 pyinstaller 二进制适配极简镜像。这与"整个 agent 进程都跑在沙箱里"的粗粒度方案不同,代价是要维护路径翻译和命令拼接这层胶水。想了解不隔离时这些工具本身怎么跑,见 02-tools;容器生命周期如何嵌进主循环,见 01-agent-loop。
7. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 装饰器接线(有 docker_config 就包一层) | trae_agent/agent/base_agent.py | BaseAgent.__init__ |
| 容器生命周期在主循环的两端 | trae_agent/agent/base_agent.py | BaseAgent.execute_task |
| 按工具名分流 + 逐个转发 | trae_agent/tools/docker_tool_executor.py | DockerToolExecutor.sequential_tool_call |
| 宿主路径→容器路径翻译 | trae_agent/tools/docker_tool_executor.py | DockerToolExecutor._translate_path |
| 工具调用拼成容器内命令并执行 | trae_agent/tools/docker_tool_executor.py | DockerToolExecutor._execute_in_docker |
| 建/连容器、挂 workspace | trae_agent/agent/docker_manager.py | DockerManager.start |
销毁托管容器(区分 _is_managed) | trae_agent/agent/docker_manager.py | DockerManager.stop |
| 拷 pyinstaller 二进制进容器 | trae_agent/agent/docker_manager.py | DockerManager._copy_tools_to_container |
| 容器内持久 shell 执行命令 | trae_agent/agent/docker_manager.py | DockerManager._execute_interactive |
| 打包编辑工具为独立二进制 | trae_agent/cli.py | build_with_pyinstaller |
| Docker 可用性体检(不可用即退出) | trae_agent/cli.py | check_docker |
| 编辑工具的 CLI 独立包装(打包源) | trae_agent/tools/edit_tool_cli.py · trae_agent/tools/json_edit_tool_cli.py | — |