Stage C Step 3d: fs 工具进容器 + DESIGN §7.5 #6 重写(物理边界替代代码护栏)

Ubuntu dogfood 暴露 host 工具漏底:base_dir=Path.cwd() 无 user_root 校验,
模型 glob "*" 列出 host /home/lighthouse/zcbot/.git/.venv/... zcbot 源码自身。
DESIGN §7.5 #6 原写"host 工具走 paths.py::resolve_user_path 校验"是假命题
(代码里没那函数),绝对路径完全不挡。

修法:fs 工具(read/write/edit/glob/grep)也走 docker exec,物理边界替代
代码护栏(Phase B path validator 那条不做 ── 脆弱)。

- core/sandbox/tool_runner.py 新增:容器内 helper,stdin 接 JSON args,
  调 tools/fs.py 的 Tool 子类;base_dir=cwd,user_root=/workspace
- DockerExecutor 加 FS_TOOLS 信任域 + _exec_fs_tool:docker exec -i ...
  python /sandbox/tool_runner.py <name>,stdin 喂 JSON args(CJK / 引号
  透明传不被 shell metachar 切)
- _run_subprocess 加 stdin 参数 + is_fs_tool 分支返 stdout 直透(原 Tool
  返回串语义保持),exit≠0 stderr 当 ToolResult content
- SandboxPool 加 repo_root 字段 + <repo>/skills:/sandbox/skills:ro mount
  让容器内 read SKILL references 能解析
- Dockerfile COPY tools/ /sandbox/tools/ + tool_runner.py(build-time COPY
  而非 mount ── 容器内代码不应跟随 host repo 改动)
- web/app.py 透传 ROOT 给 init_pool
- 留 host 的工具:load_skill(SkillRegistry 内存查找)/ web_search /
  web_fetch / seedream / seedance(持 Bocha/ARK key 不入容器)
- DESIGN §7.5 #6 重写:"几乎所有工具进容器,host 只留持 key + 跨 user 的",
  原假命题溯源标注 2026-05-26 修正

代价:每 fs tool call +~200ms docker exec overhead,对话级 N≤15 总 1-3s,
LLM 推理 5-30s 下噪声。升级触发(§7.9 升级表)docker exec → unix socket RPC
仍按原信号(overhead/total > 30% 持续 / 长驻服务工作流)。

测试:test_executor_docker 加 4 fs 路径测试(argv 形态 / CJK stdin JSON /
exit≠0 stderr 透传 / timeout);改原 read 直通测试 → load_skill 直通
(read 现在进容器)。unittest discover 35/35 PASS。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-26 21:56:41 +08:00
parent d93cc1a949
commit 23ff996d38
9 changed files with 323 additions and 28 deletions

View File

@ -402,10 +402,11 @@ create index on usage_events (model_profile, created_at);
``` ```
Container 创建参数走 config:`ZCBOT_SANDBOX_RUNTIME=runc|runsc|...`(默 `runc`),per-user 容器起的时候 `docker run --runtime=<runtime>`。**理由**:未来切 gVisor / Firecracker / Kata / e2b 时应用层零改动(只换 backend driver + 改 config + 重启容器),避免接口形状泄漏 Docker 假设(`docker exec` / `docker cp` / `docker stats`)导致后期重写。 Container 创建参数走 config:`ZCBOT_SANDBOX_RUNTIME=runc|runsc|...`(默 `runc`),per-user 容器起的时候 `docker run --runtime=<runtime>`。**理由**:未来切 gVisor / Firecracker / Kata / e2b 时应用层零改动(只换 backend driver + 改 config + 重启容器),避免接口形状泄漏 Docker 假设(`docker exec` / `docker cp` / `docker stats`)导致后期重写。
6. **工具按信任域二分,Executor 内部 dispatch**: 6. **工具按信任域二分,Executor 内部 dispatch**(2026-05-26 修正,原"host 工具走 paths.py::resolve_user_path 校验"是假命题,代码里没那函数;Ubuntu dogfood 第一次切 docker backend 发现 glob 工具仍列 host repo `.git/.venv/...`,改物理边界替代代码护栏):
- **Host in-process backend**:`read` / `write` / `edit` / `glob` / `grep` / `load_skill` / `web_search` / `web_fetch` — 这些工具原本就在 host 持有凭据(Bocha API key)或走 `paths.py::resolve_user_path` 校验(user-rooted 安全边界已存在,`/v1/files` API 复用同一份),塞进容器既无安全收益又付 ~200ms exec overhead × N 次。 - **Container exec backend**:`shell` / `run_python` / `read` / `write` / `edit` / `glob` / `grep` — 全走 docker exec。shell/run_python 是任意代码必须隔离;fs 工具(read 等 5 个)以前在 host 跑 `base_dir = Path.cwd()` 无 user_root 校验,能读 host 全 fs(`/etc/passwd` / zcbot 源码 / `~/.ssh/`),改进容器后 `user_root=/workspace` 是物理边界。fs 工具调用形态:`docker exec --user zcbot --workdir /workspace/<wd> -i <c> python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args(CJK / 引号 / 路径分隔符透明传,不被 shell metachar 切)。`tool_runner.py` 在镜像里 `/sandbox/`,复用 `tools/fs.py` 的 Tool 子类(`COPY tools/ /sandbox/tools/`);skill references 通过额外的 `<repo>/skills:/sandbox/skills:ro` mount 暴露(只读)。
- **Container exec backend**:`shell` / `run_python` — 执行模型生成的任意代码,必须容器隔离。 - **Host in-process backend**:`load_skill` / `web_search` / `web_fetch` / `seedream` / `seedance` — 持 Bocha/ARK API key 不能塞容器 env(SaaS 时 key 泄漏面增加);`load_skill` 是 SkillRegistry 内存查找,无 fs 访问越界可能。Step 4 egress proxy 之后再讨论这几个工具的容器化方案(media tool 调远端 API 走 proxy 比 key 入容器更直)。
- Dispatcher 内部分流,使用方(`AgentLoop`)零感知。**接口形状按"未来若需全部进容器 + 内部 tool-runner"留好**:只换 host backend 实现成 unix socket RPC,接口不动。 - Dispatcher(`DockerExecutor`)内部分流,使用方(`AgentLoop`)零感知。**接口形状按"未来若需全部进容器 + 内部 tool-runner unix socket RPC"留好**,升级触发信号见下表。
- **代价**:每个 fs tool call 多 ~200ms docker exec overhead;对话级 N≤15 → 总 1-3s,LLM 推理时间 5-30s 下面噪声。镜像 build 多一步 `COPY tools/`,rebuild 增量 ~5s。
**升级触发信号(写下来防遗忘,反向兜底:无信号不升级)**: **升级触发信号(写下来防遗忘,反向兜底:无信号不升级)**:

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-05-26(Stage C Step 3 hotfix:exec_user 改 username 自动跟随镜像 build_arg + Dockerfile 加 chromium + nodejs + @mermaid-js/mermaid-cli 给 proposal/patent skill 渲图) 最后更新:2026-05-26(Stage C Step 3d:fs 工具(read/write/edit/glob/grep)进容器 + DESIGN §7.5 #6 重写,物理边界替代代码护栏)
--- ---
@ -23,6 +23,7 @@
### 2026-05-26 ### 2026-05-26
- **Stage C Step 3d:fs 工具(read/write/edit/glob/grep)进容器 + DESIGN §7.5 #6 重写**:Ubuntu dogfood 第一次切 docker backend 后发现 host 工具 `Path.cwd()` 漏底 —— 模型用 glob `*` 列出了 host `/home/lighthouse/zcbot/.git/.venv/config/core/...`,即 zcbot 源码自身。回查 DESIGN §7.5 #6 写"host 工具走 `paths.py::resolve_user_path` 校验",grep 代码**根本没那个函数**,假命题;`Tool._resolve` 实际是 `base_dir / path`,base_dir=`Path.cwd()`(= web 启动目录 = zcbot repo 根),绝对路径完全不挡,模型能 read `/etc/passwd` / write zcbot 源码自己。**修法对比**:Phase A(改 cwd → working_dir,1 行 hack)修 UX 不修安全;Phase B(host 工具加 user_root 强制校验 + skills/ 白名单,~80 行)安全但脆弱(symlink/`..`/Windows path 都得 case 挡,漏一个就破);**方案 3(fs 工具进容器)物理边界替代代码护栏,选这条**。`core/sandbox/tool_runner.py` 新增容器内 helper(~80 行,from stdin 接 JSON args 调 `tools/fs.py` Tool 子类,base_dir=cwd 走 docker exec --workdir 传入,user_root=/workspace);`DockerExecutor` 加 `FS_TOOLS = {read,write,edit,glob,grep}` 信任域 + `_exec_fs_tool` 方法 `docker exec -i ... python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args(CJK 路径透明传不被 shell metachar 切);`_run_subprocess` 加 stdin 参数 + is_fs_tool 路径返 stdout 直透(不包 [stdout]/[exit],原模型语义保持),exit≠0 把 stderr 当 ToolResult content。`SandboxPool` 加 `repo_root` 字段,`_docker_run` 加 `<repo>/skills:/sandbox/skills:ro` mount(SKILL.md 内引用 `references/foo.md` 时容器内 read 能解析);`web/app.py` lifespan 透传 `ROOT`;`Dockerfile` `COPY tools/ /sandbox/tools/ + tool_runner.py` 让镜像内有一份 tools 源(build-time COPY 而非 mount —— 容器内代码不应跟随 host repo 修改重启)。**留 host 的工具**:`load_skill`(SkillRegistry 内存查找,无 fs 越界)/ `web_search` / `web_fetch` / `seedream` / `seedance`(持 Bocha/ARK API key,key 不入容器 env;Step 4 egress proxy 后再讨论)。**测试**:`tests/test_executor_docker.py` 改 `test_load_skill_passthrough_to_host`(原 `test_read_passthrough_to_host` 不再成立 —— read 进容器了)+ 加 4 个 fs 路径测试(read argv 形态 / CJK 路径 stdin JSON 透明传 / grep exit≠0 stderr 透传 / glob timeout 杀 docker CLI),`unittest discover 35/35 PASS`。**DESIGN §7.5 #6 重写**:从"工具二分(host fs + container code)"改"几乎所有工具进容器,host 只留持 key + 跨 user 的"+ 标注 2026-05-26 修正记录(原假命题溯源)。**代价**:每个 fs tool call 多 ~200ms docker exec overhead,对话级 N≤15 总 1-3s,LLM 推理 5-30s 下噪声;镜像 build COPY tools/ ~5s 增量。**升级触发**(§7.9 升级表):若 metric `docker_exec_overhead / total_tool_time > 30%` 持续两周,或模型出现"在容器内起长驻服务"工作流,启用容器内 tool-runner unix socket RPC(消除每次 exec 开销)。否决:(a) Phase B path validator —— 跟 §7.9 § "美学统一性 ≠ 升级理由"对称,**安全要"物理 ≠ 代码"才稳**;(b) `COPY core/ tools/ ...` 把整个 zcbot core 进镜像 —— tool_runner 只需要 `tools/fs.py` + base.py,容器内多余代码增加攻击面;(c) tool_runner.py 用 argv 传 JSON args —— CJK / 引号 / 路径分隔符全是 shell metachar 切风险,stdin 喂稳;(d) 让 host backend 也保留 fs 工具走 user_root 校验作"双保险" —— 双源 = 漂移源,docker backend 是 §7.5 的全部论证基础,host backend 不在外部用户场景有它就够。
- **Stage C Step 3 hotfix:exec_user 改 username 跟随 build_arg + Dockerfile 加 Node/Chromium/mermaid-cli**:Ubuntu 上 dogfood 暴露两个真问题。① **uid 错配**:DockerExecutor 写死 `--user 1000:1000`,但镜像 `docker build --build-arg HOST_UID=$(id -u)` 跟随 host 实际 uid(腾讯云轻量 lighthouse 用户 uid=1001),docker exec 进容器 uid=1000 → bind mount `/workspace/<wd>/` owner 1001 → 写文件全 EACCES,文件落 `/tmp/`。改 `DEFAULT_EXEC_USER = "zcbot"`(username,docker 自动查容器 /etc/passwd 拿 uid),无论 HOST_UID build 成 1000/1001/其他都跟 bind mount owner 对齐,且未来切其他部署机不用改 env。② **proposal/patent skill 渲 mermaid 缺 Node**:`skills/proposal/scripts/render_diagrams.py` `render_via_mmdc``shutil.which("mmdc")`,容器没装 → 退到 mermaid.ink 公网 API → 但 sandbox 容器 `--internal` 默 deny outbound,API 也走不通 → ASCII fallback 出 docx 没图不能用。Dockerfile 加 `chromium nodejs npm` apt 装(Debian bookworm 自带 node 18.x 够新)+ `npm install -g @mermaid-js/mermaid-cli@latest`,镜像 +~400MB(接受)。容器内 chromium 缺 setuid sandbox + `/dev/shm` 不够大会跪,镜像落 `/sandbox/puppeteer-config.json`(`--no-sandbox` / `--disable-setuid-sandbox` / `--disable-dev-shm-usage` + executablePath=/usr/bin/chromium)+ ENV `MERMAID_PUPPETEER_CONFIG=/sandbox/puppeteer-config.json`,`render_via_mmdc` 改读 env 拼 `-p <config>` 注入 mmdc;host 上跑 env 没设行为零变化。`PUPPETEER_SKIP_DOWNLOAD=true` + `PUPPETEER_EXECUTABLE_PATH` 让 puppeteer 用容器 chromium 不再下载它自带的 Chrome(省 ~300MB build)。npm 源加 `--build-arg NPM_REGISTRY=https://mirrors.cloud.tencent.com/npm/`(腾讯云内网)防境内 build 慢。`DESIGN.md` 不动(纯实施层 bug fix + skill 依赖);`RUN.md` 加 NPM_REGISTRY 段 + 故障兜底 3 行(EACCES uid 错配 / mmdc 报 launch chromium / npm 慢)。否决:(a) 让 DockerExecutor 启动时探测 `os.getuid()` 自动取 host uid 作 `--user` —— 写死 username 让 docker 查 passwd 比应用层探测更直接,且 部署机 uid 偶尔变(从 1000 重装成 1001)不用改任何东西;(b) 容器走 NodeSource repo 装 Node 20 LTS —— Debian bookworm 自带 18.x 已满足 mermaid-cli 要求(>=14.x),多一步外网拖速度;(c) 不装 chromium 等 Step 4 egress proxy 后用 mermaid.ink —— proposal 是早期就要交付的能力,等 Step 4(还没动手)不现实;(d) puppeteer config 注入靠改 mmdc 启动脚本 —— mmdc 默支持 `-p`,改 render_diagrams.py 读 env 就够,不动 mmdc 内部。 - **Stage C Step 3 hotfix:exec_user 改 username 跟随 build_arg + Dockerfile 加 Node/Chromium/mermaid-cli**:Ubuntu 上 dogfood 暴露两个真问题。① **uid 错配**:DockerExecutor 写死 `--user 1000:1000`,但镜像 `docker build --build-arg HOST_UID=$(id -u)` 跟随 host 实际 uid(腾讯云轻量 lighthouse 用户 uid=1001),docker exec 进容器 uid=1000 → bind mount `/workspace/<wd>/` owner 1001 → 写文件全 EACCES,文件落 `/tmp/`。改 `DEFAULT_EXEC_USER = "zcbot"`(username,docker 自动查容器 /etc/passwd 拿 uid),无论 HOST_UID build 成 1000/1001/其他都跟 bind mount owner 对齐,且未来切其他部署机不用改 env。② **proposal/patent skill 渲 mermaid 缺 Node**:`skills/proposal/scripts/render_diagrams.py` `render_via_mmdc``shutil.which("mmdc")`,容器没装 → 退到 mermaid.ink 公网 API → 但 sandbox 容器 `--internal` 默 deny outbound,API 也走不通 → ASCII fallback 出 docx 没图不能用。Dockerfile 加 `chromium nodejs npm` apt 装(Debian bookworm 自带 node 18.x 够新)+ `npm install -g @mermaid-js/mermaid-cli@latest`,镜像 +~400MB(接受)。容器内 chromium 缺 setuid sandbox + `/dev/shm` 不够大会跪,镜像落 `/sandbox/puppeteer-config.json`(`--no-sandbox` / `--disable-setuid-sandbox` / `--disable-dev-shm-usage` + executablePath=/usr/bin/chromium)+ ENV `MERMAID_PUPPETEER_CONFIG=/sandbox/puppeteer-config.json`,`render_via_mmdc` 改读 env 拼 `-p <config>` 注入 mmdc;host 上跑 env 没设行为零变化。`PUPPETEER_SKIP_DOWNLOAD=true` + `PUPPETEER_EXECUTABLE_PATH` 让 puppeteer 用容器 chromium 不再下载它自带的 Chrome(省 ~300MB build)。npm 源加 `--build-arg NPM_REGISTRY=https://mirrors.cloud.tencent.com/npm/`(腾讯云内网)防境内 build 慢。`DESIGN.md` 不动(纯实施层 bug fix + skill 依赖);`RUN.md` 加 NPM_REGISTRY 段 + 故障兜底 3 行(EACCES uid 错配 / mmdc 报 launch chromium / npm 慢)。否决:(a) 让 DockerExecutor 启动时探测 `os.getuid()` 自动取 host uid 作 `--user` —— 写死 username 让 docker 查 passwd 比应用层探测更直接,且 部署机 uid 偶尔变(从 1000 重装成 1001)不用改任何东西;(b) 容器走 NodeSource repo 装 Node 20 LTS —— Debian bookworm 自带 18.x 已满足 mermaid-cli 要求(>=14.x),多一步外网拖速度;(c) 不装 chromium 等 Step 4 egress proxy 后用 mermaid.ink —— proposal 是早期就要交付的能力,等 Step 4(还没动手)不现实;(d) puppeteer config 注入靠改 mmdc 启动脚本 —— mmdc 默支持 `-p`,改 render_diagrams.py 读 env 就够,不动 mmdc 内部。
- **Stage C Step 5:`main.py sandbox check` 部署前置对账 + lifespan fs quota WARN**:外部用户开放是 §7.5 #4 magnetic 要求(xfs prjquota / ext4 project quota / zfs dataset quota,否则"扫描间隙打满共享 fs 拖死同节点"),且 docker backend 启动前置(daemon/镜像/HOST_UID 对齐)出错时 lifespan 直接 fail-fast、traceback 排查贵 —— 把"运维心智清单"沉淀成可执行命令。`main.py sandbox check` 跑 5 项独立探测:① docker daemon 可达(CLI 存在 + `docker version` rc=0)② `zcbot-sandbox:latest` 镜像存在 ③ `zcbot-sandbox-net` network 存在(缺也 OK,lifespan 自动 ensure,这一项 warn 不 err)④ 镜像内 zcbot uid 与 host uid 对齐(`docker run --rm --entrypoint id` 拿镜像 uid 比对 `os.getuid()`;Windows 自动 skip)⑤ workspace/users/ 所在 fs 类型可 quota(`findmnt --target ... -no FSTYPE,OPTIONS` 解析,识别 xfs+prjquota / ext4+project quota / zfs / btrfs / tmpfs / 其他)。`detect_fs_quota(path) -> (level, msg)` 抽出来给 lifespan 复用:`web/app.py` docker backend 启动时同样跑一次,WARN 打 stdout(不阻塞),应用层周期扫描仍生效。**err vs warn 分界**:err = docker backend 启动会 fail-fast 的根因(daemon/镜像/HOST_UID,exit 1);warn = 不阻塞启动但外部用户开放前要清(network 缺 / fs 不可 quota,exit 0)。`tests/test_sandbox_check.py` 19 测试覆盖各分支 + 汇总 exit code,mock subprocess 与 sys.platform(`run_sandbox_check` 改用 module-level lookup 而非固化 `CHECKS` 元组,让 unittest patch 生效);**全套 unittest discover 31/31 PASS**。RUN.md 加"部署前置对账"小节(`sandbox check` 5 项含义)+ "配额硬化"段重写(fs 类型 → 处理动作映射表 + xfs 升级 4 步)+ 故障兜底 3 行(sandbox init failed / fs quota warn / image not found)。否决:(a) lifespan 探测失败 → fail-fast 而非 WARN —— Step 5 阶段应用层周期扫描已有,OS 层 quota 是外部开放硬要求不是 dogfood 硬要求,fail-fast 会阻碍 dogfood 启动;(b) sandbox check 自带 `quota-set` 子命令直接调 `xfs_quota` —— `<pid>` 整数 ↔ user_uuid 映射要建表跟踪,且 sudo + /etc/projects 改动属于运维操作,Step 5 阶段只落 RUN.md 说明 + 命令清单,真要做时在外部开放前一步;(c) 在 sandbox check 里探测 egress proxy 状态 —— Step 4 未实施,占位会让人误以为已落地。`DESIGN.md` 不动(纯按 §7.5 #4 既有协议实施);`RUN.md` 更新如上。 - **Stage C Step 5:`main.py sandbox check` 部署前置对账 + lifespan fs quota WARN**:外部用户开放是 §7.5 #4 magnetic 要求(xfs prjquota / ext4 project quota / zfs dataset quota,否则"扫描间隙打满共享 fs 拖死同节点"),且 docker backend 启动前置(daemon/镜像/HOST_UID 对齐)出错时 lifespan 直接 fail-fast、traceback 排查贵 —— 把"运维心智清单"沉淀成可执行命令。`main.py sandbox check` 跑 5 项独立探测:① docker daemon 可达(CLI 存在 + `docker version` rc=0)② `zcbot-sandbox:latest` 镜像存在 ③ `zcbot-sandbox-net` network 存在(缺也 OK,lifespan 自动 ensure,这一项 warn 不 err)④ 镜像内 zcbot uid 与 host uid 对齐(`docker run --rm --entrypoint id` 拿镜像 uid 比对 `os.getuid()`;Windows 自动 skip)⑤ workspace/users/ 所在 fs 类型可 quota(`findmnt --target ... -no FSTYPE,OPTIONS` 解析,识别 xfs+prjquota / ext4+project quota / zfs / btrfs / tmpfs / 其他)。`detect_fs_quota(path) -> (level, msg)` 抽出来给 lifespan 复用:`web/app.py` docker backend 启动时同样跑一次,WARN 打 stdout(不阻塞),应用层周期扫描仍生效。**err vs warn 分界**:err = docker backend 启动会 fail-fast 的根因(daemon/镜像/HOST_UID,exit 1);warn = 不阻塞启动但外部用户开放前要清(network 缺 / fs 不可 quota,exit 0)。`tests/test_sandbox_check.py` 19 测试覆盖各分支 + 汇总 exit code,mock subprocess 与 sys.platform(`run_sandbox_check` 改用 module-level lookup 而非固化 `CHECKS` 元组,让 unittest patch 生效);**全套 unittest discover 31/31 PASS**。RUN.md 加"部署前置对账"小节(`sandbox check` 5 项含义)+ "配额硬化"段重写(fs 类型 → 处理动作映射表 + xfs 升级 4 步)+ 故障兜底 3 行(sandbox init failed / fs quota warn / image not found)。否决:(a) lifespan 探测失败 → fail-fast 而非 WARN —— Step 5 阶段应用层周期扫描已有,OS 层 quota 是外部开放硬要求不是 dogfood 硬要求,fail-fast 会阻碍 dogfood 启动;(b) sandbox check 自带 `quota-set` 子命令直接调 `xfs_quota` —— `<pid>` 整数 ↔ user_uuid 映射要建表跟踪,且 sudo + /etc/projects 改动属于运维操作,Step 5 阶段只落 RUN.md 说明 + 命令清单,真要做时在外部开放前一步;(c) 在 sandbox check 里探测 egress proxy 状态 —— Step 4 未实施,占位会让人误以为已落地。`DESIGN.md` 不动(纯按 §7.5 #4 既有协议实施);`RUN.md` 更新如上。
- **Stage C Step 3:DockerExecutor 集成 AgentLoop + web lifespan(`ZCBOT_SANDBOX_BACKEND=host|docker` env 切 backend)**:`core/executor_docker.py` `DockerExecutor` 组合 `HostExecutor` + `SandboxPool`,`call_tool` 按 §7.5 #6 信任域 dispatch:`shell` / `run_python``pool.ensure(user_id)` 拿容器名 + `docker exec --user 1000:1000 --workdir /workspace/<wd_name> -e PYTHONIOENCODING=utf-8 setsid bash -c <cmd>` / `python <script>`(`setsid` 走包一层进程组,§7.5 #3 PGID kill 协议留 Step 3b 启用);其他工具(read/write/edit/glob/grep/load_skill/web_*/seedream/seedance)直通 host。**run_python tmp .py 落 host 侧 `<user_root>/.zcbot_tmp/<task_id>/<rand>.py`**,容器内对应 `/workspace/.zcbot_tmp/<task_id>/<rand>.py`(bind mount 自动可见);dotfile 起头让 `/v1/files` API 天然过滤(`web/app.py:169` `startswith(".")` 已挡)。**Cancel limitation 接受**:Popen.kill() 杀 docker CLI 客户端,容器内 server 端进程不会因此终止(docker exec 设计如此);第一版靠 idle 5min reaper / 下次 `ensure``rm -f` 兜底,升级触发为"用户报取消但还在烧 CPU"。`core/sandbox/__init__.py` 暴露 module-level singleton `init_pool` / `get_pool`,`agent_builder._resolve_executor` 按 env 切 backend、docker 路径 pool 未初始化 → fail-fast(不静默退到 host 防止"以为有沙盒实则在裸跑"误判);`web/app.py` lifespan 启动钩子:`init_pool(workspace/users)` + `shutdown_all` 清前驱孤儿 + `asyncio.create_task(_reaper)`(每 60s `run_in_executor(pool.reap_idle)`),关闭钩子 cancel reaper + `shutdown_all`。**pool.py 顺手清债**:`asyncio.Lock` → `threading.Lock`(主使用方是 web BG 线程同步 tool call,asyncio.Lock 会被每次 `asyncio.run` 起的 ephemeral loop 绕过保护;reaper 改 async wrapper `loop.run_in_executor(pool.reap_idle)`,pool API 全 sync 更直)。**测试**:`tests/test_executor_docker.py` 11 测试覆盖 host 直通 / shell argv 形态 / run_python tmp 文件清理 / timeout / cancel / 未知工具 / caps.enable_run_python=False;`unittest discover -s tests` **12/12 PASS**(原 1 测试不变,新 11 测试加上)。**Windows dogfood 零变化**:默 `ZCBOT_SANDBOX_BACKEND=host`,本地不动 docker;切 docker 路径只在 Ubuntu 部署机有效,真起容器 smoke 仍按 RUN.md "Sandbox(Stage C,Ubuntu)" 段 5 条命令在部署机跑。`DESIGN.md` **不动**(纯按 §7.5 #5 #6 既有协议实施);`RUN.md` 加 `ZCBOT_SANDBOX_BACKEND` env 说明 + 切 docker backend 时的启动前置条件。否决:(a) DockerExecutor 用 `asyncio.run(pool.ensure)` 包 ephemeral loop —— 跨 loop 不共享 asyncio.Lock,失串行化保护,且每次 tool call 多 ~5ms loop 创建销毁噪声;改 pool 同步成本更低;(b) `run_python` tmp .py 放工作目录内 —— 污染用户视野,SKILL 教模型"列工作目录用 glob"时 tmp 文件干扰,crash 残留与产物混(详 §7.9 取舍记录会在下次有同款问题时考虑沉淀);(c) host 侧独立 bind mount `<workspace>/.sandbox_tmp/<uid>/` 挂成容器 `/tmp_scripts` —— 多挂一个 mount 复杂度上升,单 bind mount 协议保持更直;(d) docker backend 失败时退化到 host —— 沙盒缺失=安全模型崩,fail-fast 比"看起来在跑"重要,§7.5 硬协议"任一缺失视为部署未完成"。 - **Stage C Step 3:DockerExecutor 集成 AgentLoop + web lifespan(`ZCBOT_SANDBOX_BACKEND=host|docker` env 切 backend)**:`core/executor_docker.py` `DockerExecutor` 组合 `HostExecutor` + `SandboxPool`,`call_tool` 按 §7.5 #6 信任域 dispatch:`shell` / `run_python``pool.ensure(user_id)` 拿容器名 + `docker exec --user 1000:1000 --workdir /workspace/<wd_name> -e PYTHONIOENCODING=utf-8 setsid bash -c <cmd>` / `python <script>`(`setsid` 走包一层进程组,§7.5 #3 PGID kill 协议留 Step 3b 启用);其他工具(read/write/edit/glob/grep/load_skill/web_*/seedream/seedance)直通 host。**run_python tmp .py 落 host 侧 `<user_root>/.zcbot_tmp/<task_id>/<rand>.py`**,容器内对应 `/workspace/.zcbot_tmp/<task_id>/<rand>.py`(bind mount 自动可见);dotfile 起头让 `/v1/files` API 天然过滤(`web/app.py:169` `startswith(".")` 已挡)。**Cancel limitation 接受**:Popen.kill() 杀 docker CLI 客户端,容器内 server 端进程不会因此终止(docker exec 设计如此);第一版靠 idle 5min reaper / 下次 `ensure``rm -f` 兜底,升级触发为"用户报取消但还在烧 CPU"。`core/sandbox/__init__.py` 暴露 module-level singleton `init_pool` / `get_pool`,`agent_builder._resolve_executor` 按 env 切 backend、docker 路径 pool 未初始化 → fail-fast(不静默退到 host 防止"以为有沙盒实则在裸跑"误判);`web/app.py` lifespan 启动钩子:`init_pool(workspace/users)` + `shutdown_all` 清前驱孤儿 + `asyncio.create_task(_reaper)`(每 60s `run_in_executor(pool.reap_idle)`),关闭钩子 cancel reaper + `shutdown_all`。**pool.py 顺手清债**:`asyncio.Lock` → `threading.Lock`(主使用方是 web BG 线程同步 tool call,asyncio.Lock 会被每次 `asyncio.run` 起的 ephemeral loop 绕过保护;reaper 改 async wrapper `loop.run_in_executor(pool.reap_idle)`,pool API 全 sync 更直)。**测试**:`tests/test_executor_docker.py` 11 测试覆盖 host 直通 / shell argv 形态 / run_python tmp 文件清理 / timeout / cancel / 未知工具 / caps.enable_run_python=False;`unittest discover -s tests` **12/12 PASS**(原 1 测试不变,新 11 测试加上)。**Windows dogfood 零变化**:默 `ZCBOT_SANDBOX_BACKEND=host`,本地不动 docker;切 docker 路径只在 Ubuntu 部署机有效,真起容器 smoke 仍按 RUN.md "Sandbox(Stage C,Ubuntu)" 段 5 条命令在部署机跑。`DESIGN.md` **不动**(纯按 §7.5 #5 #6 既有协议实施);`RUN.md` 加 `ZCBOT_SANDBOX_BACKEND` env 说明 + 切 docker backend 时的启动前置条件。否决:(a) DockerExecutor 用 `asyncio.run(pool.ensure)` 包 ephemeral loop —— 跨 loop 不共享 asyncio.Lock,失串行化保护,且每次 tool call 多 ~5ms loop 创建销毁噪声;改 pool 同步成本更低;(b) `run_python` tmp .py 放工作目录内 —— 污染用户视野,SKILL 教模型"列工作目录用 glob"时 tmp 文件干扰,crash 残留与产物混(详 §7.9 取舍记录会在下次有同款问题时考虑沉淀);(c) host 侧独立 bind mount `<workspace>/.sandbox_tmp/<uid>/` 挂成容器 `/tmp_scripts` —— 多挂一个 mount 复杂度上升,单 bind mount 协议保持更直;(d) docker backend 失败时退化到 host —— 沙盒缺失=安全模型崩,fail-fast 比"看起来在跑"重要,§7.5 硬协议"任一缺失视为部署未完成"。

View File

@ -1,14 +1,21 @@
"""DockerExecutor:`shell` / `run_python` 走 docker exec,其余 in-process(§7.5 #6)。 """DockerExecutor:fs / shell / run_python 全走 docker exec,持 key 工具留 host(§7.5 #6)。
Backend 二分(§7.5 #6 信任域): Backend 二分(§7.5 #6 信任域,2026-05-26 修正:`paths.py::resolve_user_path` 校验
- host in-process:`read/write/edit/glob/grep/load_skill/web_*/seedream/seedance` 原本是 DESIGN 假命题 实际 host 工具 base_dir = Path.cwd() 无校验,模型能 read
原本就在 host 持凭据(Bocha key / ARK key)或走 `paths.py::resolve_user_path` 校验 host 整个 fs改物理边界替代代码护栏):
(user-rooted 安全边界已存),塞容器无收益付 ~200ms exec overhead × N - **container exec**:`shell` / `run_python` / `read` / `write` / `edit` / `glob` /
- container exec:`shell` / `run_python` 执行模型生成的任意代码,必须容器隔离 `grep` 全走 docker exec,容器内 user_root=/workspace 物理边界
- **host in-process**:`load_skill` / `web_*` / `seedream` / `seedance`
Bocha/ARK API key 不能入容器 env(SaaS key 泄漏面);load_skill SKILL 注册表
内存查找无 fs 访问越界
容器准入(per call): 容器准入(per call):
1. `pool.ensure(user_id)` 拿到 / `zcbot-sandbox-<uid>` 容器(per-user lock 已串行化) 1. `pool.ensure(user_id)` 拿到 / `zcbot-sandbox-<uid>` 容器(per-user lock 已串行化)
2. `docker exec --user 1000:1000 --workdir /workspace/<wd_name> <c> setsid bash -c '<cmd>'` 2. 命令分两类:
- shell/run_python:`docker exec --user zcbot --workdir /workspace/<wd> -e ... setsid bash -c '<cmd>'`
- read/write/edit/glob/grep:`docker exec --user zcbot --workdir /workspace/<wd>
<c> python /sandbox/tool_runner.py <tool_name>`,JSON args stdin
(不被 shell metachar ,CJK 路径透明传)
3. timeout docker CLI 客户端(Popen.kill()) 3. timeout docker CLI 客户端(Popen.kill())
4. 完成 `pool.mark_active(user_id)` idle 计时 4. 完成 `pool.mark_active(user_id)` idle 计时
@ -33,12 +40,20 @@ from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
import json
from .executor import ExecCtx, Executor, ToolResult from .executor import ExecCtx, Executor, ToolResult
from .executor_host import HostExecutor from .executor_host import HostExecutor
from .sandbox import SandboxPool from .sandbox import SandboxPool
CONTAINER_TOOLS = frozenset({"shell", "run_python"}) # 信任域分类(§7.5 #6,2026-05-26 修正):
# - SHELL_LIKE:执行任意代码,Popen 直接喂 cmd / script,setsid 包一层
# - FS_TOOLS:fs 操作,docker exec → /sandbox/tool_runner.py + stdin 喂 JSON args
# 二者都走 docker exec,但调用形态不同(setsid bash vs python tool_runner)
SHELL_LIKE_TOOLS = frozenset({"shell", "run_python"})
FS_TOOLS = frozenset({"read", "write", "edit", "glob", "grep"})
CONTAINER_TOOLS = SHELL_LIKE_TOOLS | FS_TOOLS
# 容器内非 root 用户:用 username 让 docker 解析容器内 /etc/passwd 自动拿 uid。 # 容器内非 root 用户:用 username 让 docker 解析容器内 /etc/passwd 自动拿 uid。
# Dockerfile 里 `useradd -u ${HOST_UID} zcbot` 已对齐 host uid,这里写死 "zcbot" # Dockerfile 里 `useradd -u ${HOST_UID} zcbot` 已对齐 host uid,这里写死 "zcbot"
@ -91,13 +106,15 @@ class DockerExecutor(Executor):
if name not in CONTAINER_TOOLS: if name not in CONTAINER_TOOLS:
return self.host.call_tool(name, args, ctx) return self.host.call_tool(name, args, ctx)
if not self.host.has_tool(name): if not self.host.has_tool(name):
# caps.enable_run_python=False 等场景下,host 没装 run_python → schema 也没暴露 # caps.enable_run_python=False 等场景下,host 没装该工具 → schema 也没暴露
return ToolResult(content=f"[Error] unknown tool: {name}", exit_code=2) return ToolResult(content=f"[Error] unknown tool: {name}", exit_code=2)
try: try:
if name == "shell": if name == "shell":
return self._exec_shell(args, ctx) return self._exec_shell(args, ctx)
if name == "run_python": if name == "run_python":
return self._exec_python(args, ctx) return self._exec_python(args, ctx)
if name in FS_TOOLS:
return self._exec_fs_tool(name, args, ctx)
except Exception as e: except Exception as e:
return ToolResult( return ToolResult(
content=f"[Error executing {name} via docker] {type(e).__name__}: {e}", content=f"[Error executing {name} via docker] {type(e).__name__}: {e}",
@ -160,16 +177,50 @@ class DockerExecutor(Executor):
except OSError: except OSError:
pass pass
# ── fs tools(read/write/edit/glob/grep)──────────────────
def _exec_fs_tool(
self, name: str, args: Dict[str, Any], ctx: ExecCtx
) -> ToolResult:
"""fs 工具走 `python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args。
fs 工具的 cancel / timeout 都用与 shell/run_python 不同的默认值:
- timeout (30s),fs 操作不会跑很久,卡住就说明撞 mount / 大目录扫描
- cancel poll(模型可能 grep user_root 然后用户停止,响应即时)
"""
timeout = int(args.get("timeout") or 30) if name == "grep" else 30
container = self.pool.ensure(self.user_id)
argv = self._docker_exec_argv(
container,
extra_env={"PYTHONIOENCODING": "utf-8"},
stdin_open=True,
) + ["python", "/sandbox/tool_runner.py", name]
# tool_runner.py 从 stdin 拿 args(JSON)── 路径含 CJK / 引号都透明传
stdin_payload = json.dumps(args, ensure_ascii=False)
result = self._run_subprocess(
argv, timeout=timeout, ctx=ctx, stdin=stdin_payload
)
self.pool.mark_active(self.user_id)
return result
# ── helpers ────────────────────────────────────────────── # ── helpers ──────────────────────────────────────────────
def _docker_exec_argv( def _docker_exec_argv(
self, container: str, extra_env: Optional[Dict[str, str]] = None self,
container: str,
extra_env: Optional[Dict[str, str]] = None,
stdin_open: bool = False,
) -> List[str]: ) -> List[str]:
"""`stdin_open=True` 时加 `-i` 让 stdin 通到容器(fs tool_runner 用)。"""
argv = [ argv = [
"docker", "exec", "docker", "exec",
"--user", self.exec_user, "--user", self.exec_user,
"--workdir", self.container_workdir, "--workdir", self.container_workdir,
] ]
if stdin_open:
argv.append("-i")
env: Dict[str, str] = {} env: Dict[str, str] = {}
if extra_env: if extra_env:
env.update(extra_env) env.update(extra_env)
@ -179,17 +230,30 @@ class DockerExecutor(Executor):
return argv return argv
def _run_subprocess( def _run_subprocess(
self, argv: List[str], timeout: int, ctx: ExecCtx self,
argv: List[str],
timeout: int,
ctx: ExecCtx,
stdin: Optional[str] = None,
) -> ToolResult: ) -> ToolResult:
"""跑 docker exec 子进程,带 cancel 协作 poll。 """跑 docker exec 子进程,带 cancel 协作 poll。
`stdin` 非空时通过 PIPE 喂给容器内进程(fs tool_runner JSON args)
cancel 命中 / timeout Popen.kill() docker CLI 客户端; cancel 命中 / timeout Popen.kill() docker CLI 客户端;
容器内 server 端进程接受 limitation(见模块头注释) 容器内 server 端进程接受 limitation(见模块头注释)
fs tool_runner 返回形态特殊处理:
- stdout Tool.execute 直接结果(纯文本, [stdout] 包装)
- exit_code != 0 stderr [Error executing ...],透传给 LLM
""" """
# 仅 shell/run_python 有 stdout/stderr 包装;fs tool_runner 输出本身就是
# LLM 拿到的最终串,不再包 [stdout]/[exit N]
is_fs_tool = stdin is not None
cancel_check = ctx.cancel_check cancel_check = ctx.cancel_check
try: try:
proc = subprocess.Popen( proc = subprocess.Popen(
argv, argv,
stdin=subprocess.PIPE if stdin is not None else None,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
@ -206,7 +270,7 @@ class DockerExecutor(Executor):
stderr: str = "" stderr: str = ""
while True: while True:
try: try:
stdout, stderr = proc.communicate(timeout=0.5) stdout, stderr = proc.communicate(input=stdin, timeout=0.5)
break break
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
if cancel_check is not None and cancel_check(): if cancel_check is not None and cancel_check():
@ -231,6 +295,15 @@ class DockerExecutor(Executor):
exit_code=130, exit_code=130,
) )
# fs tool_runner:stdout 直返;exit != 0 走 stderr 当 [Error ...] 透传
if is_fs_tool:
if proc.returncode == 0:
return ToolResult(content=stdout, exit_code=0)
# tool_runner.py 把 [Error] ... 落 stderr,exit 1=异常 / 2=参数 / unknown
err_msg = stderr.strip() or f"tool_runner exit {proc.returncode}"
return ToolResult(content=err_msg, exit_code=proc.returncode)
# shell/run_python:原 [stdout]/[stderr]/[exit] 包装
parts: List[str] = [] parts: List[str] = []
if stdout: if stdout:
parts.append(f"[stdout]\n{stdout.rstrip()}") parts.append(f"[stdout]\n{stdout.rstrip()}")

View File

@ -35,14 +35,17 @@ __all__ = [
_pool: Optional[SandboxPool] = None _pool: Optional[SandboxPool] = None
def init_pool(user_root_base: Path) -> SandboxPool: def init_pool(
user_root_base: Path, repo_root: Optional[Path] = None
) -> SandboxPool:
"""幂等初始化 module-level pool。返回 pool 实例。 """幂等初始化 module-level pool。返回 pool 实例。
lifespan 调一次;ensure_network 内部也幂等重复调用返回同一实例(不重新建) lifespan 调一次;ensure_network 内部也幂等重复调用返回同一实例(不重新建)
`repo_root` fs 工具进容器后 SKILL references ro mount( pool.py)
""" """
global _pool global _pool
if _pool is None: if _pool is None:
_pool = setup_pool(user_root_base) _pool = setup_pool(user_root_base, repo_root=repo_root)
return _pool return _pool

View File

@ -76,6 +76,7 @@ class SandboxPool:
def __init__( def __init__(
self, self,
user_root_base: Path, user_root_base: Path,
repo_root: Optional[Path] = None,
image: Optional[str] = None, image: Optional[str] = None,
runtime: Optional[str] = None, runtime: Optional[str] = None,
idle_ttl: Optional[int] = None, idle_ttl: Optional[int] = None,
@ -84,6 +85,11 @@ class SandboxPool:
""" """
user_root_base: per-user 子树父目录,典型 `<workspace>/users`bind mount user_root_base: per-user 子树父目录,典型 `<workspace>/users`bind mount
= `user_root_base / <user_id>`,目标 `/workspace` = `user_root_base / <user_id>`,目标 `/workspace`
repo_root: zcbot repo (`core/paths.py::ROOT`)**fs 工具进容器后**
(read/write/edit/glob/grep)`/sandbox/skills:ro` mount
容器内 read SKILL 内部 references path 能解析(skill
host 上是 repo 内代码,容器 user_root 是用户文件,两者
正交)None 不挂 skills,只走 user_root 边界
image: sandbox 镜像 tag( env `ZCBOT_SANDBOX_IMAGE`) image: sandbox 镜像 tag( env `ZCBOT_SANDBOX_IMAGE`)
runtime: `docker run --runtime` (runc / runsc / kata ); = 默认 runtime: `docker run --runtime` (runc / runsc / kata ); = 默认
(env `ZCBOT_SANDBOX_RUNTIME`)§7.5 #5 / §7.9 升级表 ── 切 (env `ZCBOT_SANDBOX_RUNTIME`)§7.5 #5 / §7.9 升级表 ── 切
@ -94,6 +100,7 @@ class SandboxPool:
(env `ZCBOT_PG_IPS`)defense-in-depth 即便落内网三段 (env `ZCBOT_PG_IPS`)defense-in-depth 即便落内网三段
""" """
self.user_root_base = user_root_base self.user_root_base = user_root_base
self.repo_root = repo_root
self.image = image or os.getenv("ZCBOT_SANDBOX_IMAGE", DEFAULT_IMAGE) self.image = image or os.getenv("ZCBOT_SANDBOX_IMAGE", DEFAULT_IMAGE)
self.runtime = runtime or os.getenv("ZCBOT_SANDBOX_RUNTIME") or "" self.runtime = runtime or os.getenv("ZCBOT_SANDBOX_RUNTIME") or ""
self.idle_ttl = idle_ttl if idle_ttl is not None else int( self.idle_ttl = idle_ttl if idle_ttl is not None else int(
@ -151,6 +158,13 @@ class SandboxPool:
"-e", f"ZCBOT_PG_IPS={self.pg_ips}", "-e", f"ZCBOT_PG_IPS={self.pg_ips}",
"--restart=no", "--restart=no",
] ]
# repo skills 只读 mount ── fs 工具进容器后(read/glob/grep)能 access
# SKILL.md 内引用的 references/*.md。host 上 zcbot/skills/ 是项目代码,
# 跟用户 working_dir 正交,只读防容器内进程改 skill 实现。
if self.repo_root is not None:
skills_path = (self.repo_root / "skills").resolve()
if skills_path.is_dir():
cmd += ["-v", f"{skills_path}:/sandbox/skills:ro"]
if self.runtime: if self.runtime:
cmd += ["--runtime", self.runtime] cmd += ["--runtime", self.runtime]
cmd.append(self.image) cmd.append(self.image)
@ -204,13 +218,16 @@ class SandboxPool:
return ids return ids
def setup_pool(user_root_base: Path) -> SandboxPool: def setup_pool(
user_root_base: Path, repo_root: Optional[Path] = None
) -> SandboxPool:
"""app 启动便捷入口:ensure 网络存在 + 返回 pool 实例。 """app 启动便捷入口:ensure 网络存在 + 返回 pool 实例。
典型用法(lifespan 启动钩子): 典型用法(lifespan 启动钩子):
pool = setup_pool(workspace / "users") from core.paths import ROOT
pool = setup_pool(workspace / "users", repo_root=ROOT)
pool.shutdown_all() # 清前驱孤儿 pool.shutdown_all() # 清前驱孤儿
# 后台 reaper task 周期跑 pool.reap_idle() # 后台 reaper task 周期跑 pool.reap_idle()
""" """
ensure_network() ensure_network()
return SandboxPool(user_root_base=user_root_base) return SandboxPool(user_root_base=user_root_base, repo_root=repo_root)

View File

@ -0,0 +1,80 @@
"""容器内 fs 工具 helper(DockerExecutor 通过 `docker exec python tool_runner.py` 调用)。
调用约定:
- argv[1] = tool name(read / write / edit / glob / grep)
- stdin = JSON 序列化的 args( stdin 而非 argv 是为了不被 shell metachar 切路径,
CJK / 引号 / 路径分隔符全透明传)
- stdout = tool execute 返回的文本(LLM 拿到的)
- exit code = 0 ok / 1 工具内部抛异常 / 2 参数 / unknown tool
base_dir = `os.getcwd()` docker exec --workdir /workspace/<wd> 已切到 task 工作目录
user_root = `/workspace` bind mount 边界,Tool._display 据此渲相对路径
不依赖任何 zcbot 自家包外的东西,纯用 `tools.fs` 五个 Tool 子类容器镜像里
`/sandbox/tools/` host repo `tools/` 目录的拷贝(Dockerfile `COPY tools/`)
"""
from __future__ import annotations
import json
import os
import sys
import traceback
from pathlib import Path
# 镜像里 /sandbox/ 下放了 tools/ 的拷贝,让 import 走 /sandbox/
sys.path.insert(0, "/sandbox")
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool # noqa: E402
TOOLS = {
"read": ReadTool,
"write": WriteTool,
"edit": EditTool,
"glob": GlobTool,
"grep": GrepTool,
}
def main() -> int:
if len(sys.argv) < 2:
print("[Error] tool_runner: missing tool name argv[1]", file=sys.stderr)
return 2
name = sys.argv[1]
if name not in TOOLS:
print(f"[Error] tool_runner: unknown tool: {name}", file=sys.stderr)
return 2
try:
args_raw = sys.stdin.read()
args = json.loads(args_raw) if args_raw.strip() else {}
except json.JSONDecodeError as e:
print(f"[Error] tool_runner: invalid JSON args: {e}", file=sys.stderr)
return 2
cls = TOOLS[name]
tool = cls(base_dir=Path(os.getcwd()), user_root=Path("/workspace"))
try:
result = tool.execute(**args)
except TypeError as e:
print(f"[Error] bad arguments to {name}: {e}", file=sys.stderr)
return 2
except Exception as e:
# 容器内 Tool 抛异常(IO / 权限等)── 落 stderr + 退非 0,DockerExecutor
# 兜底成 ToolResult content;traceback 限 80 行防爆 LLM context
print(f"[Error executing {name}] {type(e).__name__}: {e}", file=sys.stderr)
tb = traceback.format_exc().splitlines()
if len(tb) > 80:
tb = tb[:40] + [f"... ({len(tb) - 40} lines truncated) ..."]
print("\n".join(tb), file=sys.stderr)
return 1
if not isinstance(result, str):
result = str(result)
sys.stdout.write(result)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -75,6 +75,13 @@ RUN mkdir -p /sandbox && cat > /sandbox/puppeteer-config.json <<'EOF'
} }
EOF EOF
# fs 工具进容器(§7.5 #6,2026-05-26 修正)── tool_runner.py 在容器内通过
# `python /sandbox/tool_runner.py <name>` 调用 tools/fs.py 的 Tool 子类,read/write/
# edit/glob/grep 全在容器内执行,物理边界替代代码护栏。tools/ 目录与 host 同步
# (build 时 COPY,不挂 mount ── 容器内代码不应跟随 host repo 修改重启)。
COPY tools/ /sandbox/tools/
COPY core/sandbox/tool_runner.py /sandbox/tool_runner.py
COPY deploy/sandbox/init.sh /init.sh COPY deploy/sandbox/init.sh /init.sh
RUN chmod +x /init.sh RUN chmod +x /init.sh

View File

@ -10,6 +10,8 @@ mock subprocess(`docker exec` 命令的实际跑由部署机 smoke 验,RUN.md
""" """
from __future__ import annotations from __future__ import annotations
import json
import subprocess
import sys import sys
import tempfile import tempfile
import unittest import unittest
@ -93,13 +95,19 @@ def make_ctx(executor):
class TestHostPassthrough(unittest.TestCase): class TestHostPassthrough(unittest.TestCase):
"""非 container tool 直通 host backend,不调 pool / subprocess。""" """非 container tool 直通 host backend,不调 pool / subprocess。
def test_read_passthrough_to_host(self): 2026-05-26 修正:fs 工具(read/write/edit/glob/grep)也进容器了,host passthrough
executor, pool, _ = make_executor() load_skill / web_* / seedream / seedance( key) load_skill passthrough
"""
def test_load_skill_passthrough_to_host(self):
executor, pool, _ = make_executor(tools_dict={
"load_skill": FakeTool("load_skill", "LOAD_OUT"),
})
ctx = make_ctx(executor) ctx = make_ctx(executor)
result = executor.call_tool("read", {"file": "x"}, ctx) result = executor.call_tool("load_skill", {"name": "x"}, ctx)
self.assertEqual(result.content, "READ_OUT") self.assertEqual(result.content, "LOAD_OUT")
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
self.assertEqual(pool.ensure_calls, []) self.assertEqual(pool.ensure_calls, [])
self.assertEqual(pool.mark_active_calls, []) self.assertEqual(pool.mark_active_calls, [])
@ -264,6 +272,108 @@ class TestRunPython(unittest.TestCase):
self.assertEqual(leftover, []) self.assertEqual(leftover, [])
class TestFsToolsInContainer(unittest.TestCase):
"""fs 工具(read/write/edit/glob/grep)走 docker exec + tool_runner.py(§7.5 #6)。"""
def _setup_fs_executor(self):
return make_executor(tools_dict={
"read": FakeTool("read"),
"write": FakeTool("write"),
"edit": FakeTool("edit"),
"glob": FakeTool("glob"),
"grep": FakeTool("grep"),
})
def test_read_invokes_tool_runner(self):
executor, pool, _ = self._setup_fs_executor()
ctx = make_ctx(executor)
proc = MagicMock()
proc.communicate.return_value = ("file content here", "")
proc.returncode = 0
with patch("core.executor_docker.subprocess.Popen", return_value=proc) as popen:
result = executor.call_tool("read", {"path": "foo.txt"}, ctx)
# fs 工具:stdout 直返,不包 [stdout]/[exit]
self.assertEqual(result.content, "file content here")
self.assertEqual(result.exit_code, 0)
argv = popen.call_args[0][0]
# argv 末三:python /sandbox/tool_runner.py read
self.assertEqual(argv[-3:], ["python", "/sandbox/tool_runner.py", "read"])
# 必须有 -i(stdin 通到容器)
self.assertIn("-i", argv)
# workdir / user 正常
self.assertEqual(argv[argv.index("--workdir") + 1], "/workspace/demo")
# stdin 喂的 JSON args
kwargs = popen.call_args[1]
self.assertEqual(kwargs.get("stdin"), subprocess.PIPE)
stdin_payload = proc.communicate.call_args[1].get("input")
self.assertEqual(json.loads(stdin_payload), {"path": "foo.txt"})
# pool 调过
self.assertEqual(pool.ensure_calls, [executor.user_id])
self.assertEqual(pool.mark_active_calls, [executor.user_id])
def test_write_with_cjk_path(self):
"""CJK 路径不被 shell metachar 切(stdin 喂 JSON 的核心论据)。"""
executor, _, _ = self._setup_fs_executor()
ctx = make_ctx(executor)
proc = MagicMock()
proc.communicate.return_value = ("[wrote 100 chars to 测试.md]", "")
proc.returncode = 0
with patch("core.executor_docker.subprocess.Popen", return_value=proc):
result = executor.call_tool(
"write",
{"path": "测试目录/中文文件.md", "content": "你好"},
ctx,
)
self.assertIn("[wrote", result.content)
stdin_payload = proc.communicate.call_args[1].get("input")
parsed = json.loads(stdin_payload)
self.assertEqual(parsed["path"], "测试目录/中文文件.md")
self.assertEqual(parsed["content"], "你好")
def test_grep_error_to_stderr(self):
"""tool_runner.py exit != 0 时 stderr 当 ToolResult content 透传。"""
executor, _, _ = self._setup_fs_executor()
ctx = make_ctx(executor)
proc = MagicMock()
proc.communicate.return_value = ("", "[Error] invalid regex: ...\n")
proc.returncode = 1
with patch("core.executor_docker.subprocess.Popen", return_value=proc):
result = executor.call_tool("grep", {"pattern": "["}, ctx)
self.assertIn("[Error]", result.content)
self.assertEqual(result.exit_code, 1)
def test_fs_tool_timeout(self):
executor, _, _ = self._setup_fs_executor()
ctx = make_ctx(executor)
proc = MagicMock()
proc.communicate.side_effect = [
subprocess.TimeoutExpired(cmd="docker", timeout=0.5),
("", ""),
]
proc.returncode = -9
with patch("core.executor_docker.subprocess.Popen", return_value=proc), \
patch("core.executor_docker.time.monotonic", side_effect=[0, 1000]):
result = executor.call_tool("glob", {"pattern": "**/*"}, ctx)
self.assertIn("timed out", result.content)
self.assertEqual(result.exit_code, 124)
proc.kill.assert_called_once()
class TestUnknownTool(unittest.TestCase): class TestUnknownTool(unittest.TestCase):
def test_unknown_tool_goes_to_host(self): def test_unknown_tool_goes_to_host(self):
executor, _, _ = make_executor(tools_dict={}) # 空 host → 啥都没 executor, _, _ = make_executor(tools_dict={}) # 空 host → 啥都没

View File

@ -508,6 +508,7 @@ def create_app() -> FastAPI:
sandbox_backend = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower() sandbox_backend = os.getenv("ZCBOT_SANDBOX_BACKEND", "host").lower()
sandbox_reaper_task = None sandbox_reaper_task = None
if sandbox_backend == "docker": if sandbox_backend == "docker":
from core.paths import ROOT
from core.sandbox import init_pool from core.sandbox import init_pool
from core.sandbox.check import detect_fs_quota from core.sandbox.check import detect_fs_quota
workspace = resolve_workspace(None, _cfg) workspace = resolve_workspace(None, _cfg)
@ -520,7 +521,9 @@ def create_app() -> FastAPI:
except Exception as e: except Exception as e:
print(f"[startup] [warn] fs quota detect failed: {type(e).__name__}: {e}") print(f"[startup] [warn] fs quota detect failed: {type(e).__name__}: {e}")
try: try:
pool = init_pool(user_root_base) # repo_root=ROOT 让 SandboxPool 把 <repo>/skills 只读 mount 进容器
# (fs 工具进容器后 read SKILL references 需要)
pool = init_pool(user_root_base, repo_root=ROOT)
removed = pool.shutdown_all() removed = pool.shutdown_all()
if removed: if removed:
print(f"[startup] swept {len(removed)} stale sandbox container(s)") print(f"[startup] swept {len(removed)} stale sandbox container(s)")