feat(sandbox): 增加共享执行容量与依赖观测

This commit is contained in:
caoqianming 2026-09-02 08:15:31 +08:00
parent e47ff1f942
commit 605e4eecfe
21 changed files with 1000 additions and 72 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- Sandbox 长任务会在整机繁忙时安全排队,后台任务可查看或取消排队状态;管理员可实时查看执行容量,并分析用户临时安装的 Python 依赖及占用。
- 软件作业与轮次导航统一收在对话右侧,并增加醒目的数量徽章;既可悬停速览,也可互斥固定展开,进行中或未读作业会直接通过数字提醒。
- DeepSeek Flash 的思考过程恢复实时显示,不再等到正文开始后才集中出现。

View File

@ -253,11 +253,11 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
### 7.5 沙盒:Per-user 容器 + Per-tool exec
选型:**每 user 长驻容器**(文件模型本就以 user root 为安全边界,per-task 会切碎共享工作区)+ **每 tool 一次 docker exec**(exec 级 timeout/cwd/统计)+ 空闲 5 分钟回收 + bind mount user root→`/workspace`。
选型:**每 user 长驻容器**(文件模型本就以 user root 为安全边界,per-task 会切碎共享工作区)+ **每 tool 一次 docker exec**(exec 级 timeout/cwd/统计)+ 无执行活动 10 分钟回收 + bind mount user root→`/workspace`。
**边界划分**:Control plane 留宿主(auth/DB/files 校验/SSE/LLM/受控 web 工具/配额审计),Execution plane 进容器(shell/run_python/任意生成代码)。目标不是"所有操作进容器",是"所有不可信执行不能在宿主"——否则凭据反被带进执行面。
**硬限制**:cgroup CPU/mem、pids-limit、exec timeout、并发数、read-only rootfs、tmpfs /tmp、no-new-privileges、drop ALL caps、非 root、`--shm-size`。**软配额**:按 user 计 DB(磁盘/LLM cost/wall time/流量/并发),超额 429。**网络**:默认 deny outbound,搜索抓取走宿主受控工具。
**硬限制**:单容器 4 GiB/2 CPU、pids 1024、`/dev/shm` 512 MiB、`/tmp` 1 GiB、exec timeout、read-only rootfs、no-new-privileges、drop ALL caps、非 root。整机重型执行由宿主共享文件账本 + advisory lock 统一准入:前后台合计 6、后台最多 4、单用户合计 2蓝绿/多 Web 实例共享同一账本,前台排队不计 timeout 且可取消,宿主 MemAvailable 低于阈值时暂停新放行、不杀存量。轻量 fs 工具不占重型槽。普通容器显式跟踪 active execreaper 只回收无 active exec 且超 TTL 的容器。**软配额**:按 user 计 DB(磁盘/LLM cost/wall time/流量),超额 429。**网络**:默认 deny outbound,搜索抓取走宿主受控工具。
**落地清单(Stage C 硬协议,实施按此对账)**:
1. **网络 blocklist 硬编码段**(任一缺失=未完成):`169.254/16`(metadata)、内网三段、CGNAT `100.64/10`;**PG 实际 IP 单独再 block**(belt-and-suspenders)。**容器自身 loopback(`-o lo`)显式放行**——netns 隔离下容器内 127.0.0.1 到不了宿主,DROP 它无安全收益且误伤容器内 IPC(2026-07 实锤:puppeteer↔chromium DevTools 走 127.0.0.1,被 DROP 导致 mermaid 渲染 90 天 0 成功)。
@ -276,7 +276,7 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
| gVisor → Firecracker/e2b | 合规客户 / 单机 100+ user / 兼容墙 | 每 VM 100MB+ 不划算;e2b 与 storage_root 自持冲突 |
| docker exec → 容器内 tool-runner RPC | exec 开销 >30% 持续两周 / 长驻服务 / 单轮 >20 次调用 | 自管清理+观测损失 >> 200ms×N;美学统一 ≠ 理由 |
**Image 体积 / 多 user 资源 / 加包**(2026-05-28):① image 大 ≠ 运行时吃资源(layer 共享、不 exec 只是磁盘字节);② 瓶颈在并发 exec 不在 idle 容器,杠杆全在运行时限制;③ 新增依赖 = base 收敛 + **per-user venv**(`<user_root>/.venv/`,bind mount 回收不丢;不放共享 volume——install 脚本是任意代码,破坏隔离)+ 使用频次沉淀进 base
**Image 体积 / 多 user 资源 / 加包**(2026-09-01 修订):① image 大 ≠ 运行时吃资源(layer 共享、不 exec 只是磁盘字节);② 瓶颈在并发 exec 不在 idle 容器,杠杆在共享准入与 cgroup;③ requirements 保持全局 site-packages非 root + readonly rootfs + `HOME=/tmp` 使普通 pip 临时落到 `/tmp/.local`、cache 落 `/tmp/.cache`。不建 venv、不持久化用户包、不加额外包目录容器删除前由镜像内可信扫描器只解析 `.dist-info`,与构建期基础清单比较 added/override/reinstall空扫描不入库、失败不阻塞删除。Admin 只分析依赖会话,不自动改基础镜像
### 7.6 / 7.7 改造项与阶段
@ -407,15 +407,15 @@ scheduled_jobs(§8.5) channel_bindings(§8.7,判别列+JSONB)
**决策**:把长进程从 zcbot 进程树上摘下来,OS 就是"任务组件",不引 Celery/RQ/队列。`shell`/`run_python` 加 `background=true`(**模型判断**:预计 >~1min 走后台;前台超时报错里提示改后台——判断错了纠错路径只有一步;用户显式指令永远优先)。状态协议**纯文件**:`<user_root>/.zcbot_procs/<task_id>/<proc_id>/{proc.json, output.log, exit_code}`——dotfile 用户不可见(同 `.zcbot_tmp` 惯例),`exit_code` 文件出现是唯一终态信号,状态判定全靠文件 + 现场探测(pid / 容器 running),**无常驻登记,天然扛重启**。查询/终止走配套 `check_process` 工具(host in-process,两种 backend 通吃)。
- **host backend**:detach 独立 wrapper(`core/proc_wrapper.py`,stdlib-only,sys.executable 直跑不依赖 PYTHONPATH):限时(默认 7200s / cap 86400s)、超时杀进程树记 124、日志截尾 10MB、最后写 exit_code。
- **docker backend**:**专用容器** `zcbot-proc-<id>`(pool.run_proc_container,同款硬化 + iptables init),`product=proc` + 无 instance label —— 与 sandbox 容器的 idle reaper / shutdown_all 生命周期**解耦**,dockerd 托管,蓝绿切换/实例重启不中断。不用 `docker exec -d` 进 sandbox 容器:idle 5min reaper + 启动 shutdown_all 会把长进程随容器带走
- **回收**:check_process 见终态顺手 rm 容器;web lifespan 每小时 `procs.sweep`(终态目录 7d TTL / exited 孤儿容器),幂等,蓝绿双实例同时跑无害
- **docker backend**:**专用容器** `zcbot-proc-<id>`(pool.run_proc_container,同款硬化 + iptables init),`product=proc` + 无 instance label —— 与普通 sandbox 的 idle reaper / shutdown_all 生命周期**解耦**,dockerd 托管,蓝绿切换/实例重启不中断。无容量时先写 `state=queued`,调度器获得共享前后台槽后才启动,故排队时间不计运行 timeout`check_process` 兼容 queued/running/finished/lost并可取消 queued
- **回收**:后台仍运行即有活动,不适用普通容器 idle TTLcheck_process 见终态顺手扫描临时包并 rm 容器web lifespan 约 30 秒 sweep/dispatch终态目录仍保留 7d幂等蓝绿双实例以 proc 目录锁避免重复启动。运行中容器由 Docker 托管,服务重启恢复 queued 并保留 running
- **通知/可视**:不做服务端推送 —— 前端轮询 `GET /v1/procs`(用户级,纯文件读取,仅有 running proc 时 5s 一拉):`[Background]` 工具结果卡本身活化(spinner+跳秒+停止按钮,与前台工具卡同体验,历史重渲同样恢复;`POST .../procs/<id>/kill`)、running→终态弹 toast(跨 task 也提醒,点击跳转)。proc 完成时刻往往没有活跃 run,SSE 通道根本不在,轮询是诚实的选型。
### 8.13 扫描件 PDF 直读:方舟文档理解,不接外部 OCR(✅ 2026-07-21)
缺口:markitdown 只抽 PDF 文本层,扫描件(老标准/检测报告/红头指南,建材院高频)转出为空=死路。**选复用 seed-2.0-lite 的方舟文档理解**(chat file 内容块,PDF 整本 base64 内联)新增 `read_document`:零新供应商(敏感文档不出已有豆包面)、零新基础设施、记账复用 vision 通道;实测 ~1300 输入 token/页(约 1 厘/页)、100 页全覆盖、17MB 内联可用。**不选专用解析 API**(MinerU/Textin:版面还原最好,但申报书/专利底稿要上传新第三方 + 免费额度政策不稳);**不选本地 OCR**(PaddleOCR 类:镜像塞推理依赖,需求未量化前过度投资);**不选 file_url/file_id 传址**(前者要给用户文件开免认证公网直链=新安全面、开发机 NAT 后还跑不通;后者要接 TOS 多落一份存储;base64 是零新增面的唯一形态,行业惯例 chat 端点也不收 multipart)。防上下文爆:多页 OCR 强制 `save_md` 落盘只返预览。**升级信号**:>100 页/>30MB 巨件成高频 → 接 TOS 走 file_id;要高保真版面/公式还原 → 再评 MinerU。probe/smoke 留仓(`scripts/probe_ark_doc.py` / `smoke_read_document.py`)。
- **对话锁(前端)**:bg proc 运行期间该 task 的 composer 锁定(发送→停止,Enter 拦截),观感与前台执行完全一致 —— 后台化的收益定位为「进程扛超时/服务重启」,**不改变"一个任务同时只做一件事"的对话心智**;完成的那次轮询解锁 + toast「可继续对话」。锁只在前端,服务端不 409:「停止」入口必须可达,且多设备/渠道绕过前端锁属可接受边缘(等的是同一个进程,发了消息也不冲突)。
- **防失控**:每用户并发 running 上限(`ZCBOT_MAX_BG_PROCS` 默 3);前台默认超时不放大(它是逼模型做前台/后台选择的杠杆)。
- **防失控**:前后台统一使用整机 6 / 后台 4 / 单用户 2 的共享准入;前台默认超时不放大(它是逼模型做前台/后台选择的杠杆)。
**边界(防滑坡)**:只覆盖「单个本地长进程」。①**外部异步作业**(seedance 等 submit/poll 形态)不进这里——工具内轮询 + `resume_task_id` 续查已够;②**job 链/依赖/自动重试**不做——那是 workflow 引擎,编排的唯一归属是 agent loop(模型 check 后自己决定下一步),同 §6 拒绝编排的理由;③**完成后自动续跑 run**不做——zcbot 的长任务产物多为终点交付物(与 Claude Code"build 是中间步骤"不同),自动续跑=无人在场烧 token,通知给人、下一步由人/下次对话决定。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-31(Windows Node Host/WPF 重构阶段 07;未发版)
最后更新:2026-09-01(Sandbox 共享容量、后台队列与临时依赖观测;未发版)
---
@ -20,6 +20,8 @@
---
## 已完成关键能力
- **09-01 / Unreleased / Sandbox 执行容量与临时依赖观测**:默认单容器资源提升到 4 GiB/2 CPU、pids 1024、shm 512 MiB、tmp 1 GiB普通容器无活动 10 分钟回收;新增跨蓝绿/多 Web 实例共享的整机 6、后台 4、单用户 2 重型槽位,前台可取消排队且排队不计 timeout活动计数防长命令被 reaper 误删内存压力只暂停新放行。Docker 后台 proc 无槽时持久化 queued、约 30 秒恢复调度,运行中仍由 Docker 托管,终态目录继续保留 7 天。镜像构建基础 Python 包清单,容器删除前可信扫描 `/tmp/.local``.dist-info` 并以单会话表幂等记录非空差异Admin 新增实时容量与 7/30 天/全部依赖会话统计。新增 0038 migration未连接或迁移生产数据库、未启动真实专业软件。
- **08-31 / Unreleased / Windows Node 完整 WPF 管理界面**本机任务、ANSYS 固定验收、诊断复制和数据目录迁移全部进入 WPF任务页每秒刷新本地持久化记录并保持选择验收支持进度、停止、通过报告校验与机器级执行门数据迁移继续经过活动任务检查、停连、复制、SHA-256 校验、失败回滚和重启。新增 `JobMonitorService`、`AnsysAcceptanceService`、`DataRootManagementService` 与 `DiagnosticService`,删除经典 `ConfigurationForm` 和旧 `TrayApplicationContext`WinForms 只保留托盘及受控系统对话框互操作117 项 .NET 行为测试、28 项 Windows Node 源码专项、格式与 diff 检查通过,未启动专业软件、连接生产服务或读写真实节点数据。
- **08-31 / Unreleased / Windows Node Host/WPF 重构阶段 06 第二切片**WPF 在节点概览、首次注册和运行设置基础上新增专业软件页Origin、ANSYS、Blender 卡片通过 `SoftwareManagementService` 统一获取位置、runtime 和 probe 快照,可选择或恢复自动检测位置、安装/更新独立 Python 3.12 环境并取消安装。选择对话框仍限制在 Tray WinForms 互操作层ViewModel 不访问注册表或启动进程;全机执行门会禁用安装,`ManagedRuntimeInstaller` 入口继续二次拒绝活动任务并保持临时验证、原子替换和失败 rollback。同步完成 WPF 视觉打磨:主题 token、圆角控件模板、导航选中态、状态色带/徽标、软件可用性标识、忙碌进度和窄窗口操作换行统一落地。ANSYS 验收、本机任务和数据目录迁移仍从经典配置进入。73 项 .NET 行为测试、191 项 Origin/ANSYS/Blender/合同/Node/UI 专项、格式与 diff 检查、Release 发布包及隔离 GUI/headless 烟测通过,未安装 runtime、启动专业软件、连接生产服务或读写真实节点数据。
@ -456,10 +458,10 @@ core/ark_client.py 105 ← 火山方舟 HTTP 客户端
core/asr_xfyun.py 170 ← 讯飞语音听写 IAT wss 客户端(整段 PCM→文本;web 语音输入用,diag: scripts/diag_asr.py)
core/asr_lfasr.py 250 ← 讯飞录音文件转写 LFASR 客户端(异步订单 + 说话人分离;transcribe_audio 工具底座,diag: scripts/diag_lfasr.py)
core/agent_builder.py 649 ← 装配 lib:build_agent/system prompt(工具注册块已迁 tool_registry)
core/executor.py / sandbox/{network,pool}.py / executor_docker.py ← Executor ABC + Docker per-user 容器池
core/executor.py / sandbox/{network,pool,capacity,package_scans}.py / executor_docker.py / procs.py ← Executor ABC + Docker per-user 容器池 + 宿主共享执行槽 + bg proc 文件队列 + 临时依赖扫描
tools/{base,output,fs,shell,run_python,skill_tool,skill_authoring,media_common,seedream,seedance,gpt_image,look_at_image,read_document,image_ref,web_search,web_fetch,documents,materials_project,transcribe_audio,office_to_pdf,external_systems}.py ← media_common=媒体五工具共享原语;external_systems=host-side 外部系统元工具
main.py ~210 ← 入口:web / db / probe / user / sandbox check
db/migrations/versions/ 0001-0030
db/migrations/versions/ 0001-0038
web/app.py ~210 ← 工厂 + lifespan 编排(07-23 拆分;路由在 routers/,协程在 background 等)
web/routers/*.py ← 含 external_systems 用户连接与 software_nodes 节点路由
web/{background,scheduler_runner,wechat_runner}.py ← lifespan 后台协程按域析出

25
RUN.md
View File

@ -93,9 +93,8 @@
# 单次 LLM 请求超时(秒),默 600(与 litellm 默认一致,显式化 + 可调)。长思考模型
# 被掐("600s 无字节 → run 标 error")再调大;流式正常出 chunk 不触发。
# ZCBOT_LLM_TIMEOUT_S=600
# 后台进程(bg proc,DESIGN §8.12;shell/run_python background=true):每用户并发上限,默 3
# 后台进程与前台重型执行统一受 sandbox.max_* / ZCBOT_MAX_* 共享容量控制
# 状态锚 <user_root>/.zcbot_procs/,后台默认限时 7200s(工具 timeout 参数可调,cap 86400)。
# ZCBOT_MAX_BG_PROCS=3
# 静态多文件 Web 项目预览会话 TTL(秒),默认 7 天,平台限制 5 分钟30 天。
# 改后重启 web 生效;只影响新建/再次发布的预览。
# ZCBOT_WEB_PREVIEW_TTL_SECONDS=604800
@ -678,12 +677,18 @@ sudo -u zcbot docker network create zcbot-sandbox-net
# 容器 runtime(切 gVisor 用 runsc,Firecracker 用 kata;默 runc)
# ZCBOT_SANDBOX_RUNTIME=
# 空闲多少秒回收(默 300)
# ZCBOT_SANDBOX_IDLE_TTL=300
# ZCBOT_SANDBOX_IDLE_TTL=600
# 资源限制(优先级 env > yaml `sandbox.*` > 默);改后重启 web 新起容器生效
# ZCBOT_SANDBOX_MEMORY=2g
# ZCBOT_SANDBOX_CPUS=1.0
# ZCBOT_SANDBOX_PIDS_LIMIT=256
# ZCBOT_SANDBOX_MEMORY=4g
# ZCBOT_SANDBOX_CPUS=2.0
# ZCBOT_SANDBOX_PIDS_LIMIT=1024
# ZCBOT_SANDBOX_SHM_SIZE=512m # chromium/mmdc 渲 mermaid 的 /dev/shm(docker 默 64MB 不够会挂超时)
# ZCBOT_SANDBOX_TMP_SIZE=1g
# ZCBOT_MAX_ACTIVE_EXECS=6
# ZCBOT_MAX_BACKGROUND_EXECS=4
# ZCBOT_MAX_ACTIVE_EXECS_PER_USER=2
# 三个并发 env 只用于向下收紧,代码硬上限固定为 6 / 4 / 2。
# ZCBOT_MIN_MEM_AVAILABLE=1g # 低于阈值暂停新放行,不杀正在执行的任务
# PG 实际 IP,逗号分隔。defense-in-depth ── 即便落内网三段(§7.5 #1),
# init.sh 再加一遍 DROP 规则。生产部署必填。
ZCBOT_PG_IPS=10.1.2.3,10.1.2.4
@ -705,7 +710,7 @@ ZCBOT_SANDBOX_BACKEND=docker .venv/bin/python main.py web
# 触发任一 shell / run_python 消息后,容器应已起
sudo -u zcbot docker ps --filter label=zcbot.product=sandbox
# 应看到 zcbot-sandbox-<your-uid>,STATUS = Up ...
# 5 分钟无新消息后 reaper 自动 rm
# 10 分钟无执行活动后 reaper 自动 rm
```
也可直接起一个测试容器单验 hardening(不依赖 web 进程):
@ -717,10 +722,10 @@ sudo -u zcbot docker run -d \
--label zcbot.product=sandbox \
--label zcbot.user_id=$USER_ID \
--network zcbot-sandbox-net \
--read-only --tmpfs /tmp:exec,size=512m,mode=1777 \
--read-only --tmpfs /tmp:exec,size=1g,mode=1777 --shm-size=512m \
--cap-drop=ALL --cap-add=NET_ADMIN \
--security-opt=no-new-privileges \
--pids-limit=256 --memory=2g --cpus=1.0 \
--pids-limit=1024 --memory=4g --cpus=2.0 \
-v /opt/zcbot/workspace/users/$USER_ID:/workspace \
-e ZCBOT_PG_IPS=10.1.2.3 \
zcbot-sandbox:latest
@ -985,7 +990,7 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| prod 想把 workspace 落独立数据盘 | **别用 env / 别指 ROOT 外绝对路径**(workspace 锚定 ROOT,ROOT 外会让文件面板 / agent / 新建 task 三家分叉)。用 **bind mount**`/data/...` 接到 `ROOT/workspace`,逻辑路径不变,DB 不用改。详「workspace 落独立数据盘」段 |
| 文件面板"目录尚未创建"但文件确实在 / agent 写的文件面板看不到 | workspace 被指到了 ROOT 外(旧 `ZCBOT_WORKSPACE_DIR` 绝对路径残留)→ 文件面板走 `resolve_workspace` 看一处、agent 走 DB `from_db_path`(锚 ROOT)看另一处。删掉 env、改用 bind mount(见上段),三家归一 |
| `docker run zcbot-sandbox:latest``Unable to find image` | 镜像没 build。`sudo -u zcbot docker build -f deploy/sandbox/Dockerfile --build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) -t zcbot-sandbox:latest .` |
| 后台进程(bg proc)疑似残留 / 想手工排查 | docker 模式:`docker ps --filter label=zcbot.product=proc` 列在跑的 proc 容器,`docker rm -f zcbot-proc-<id>` 手杀;host 模式:状态锚在 `<user_root>/.zcbot_procs/<task_id>/<proc_id>/`(proc.json 有 pid,exit_code 文件在 = 已结束)。web 进程每小时 sweep 自动回收(终态目录 7 天 TTL);正常终止走前端停止按钮 / `check_process(action="kill")` |
| 后台进程(bg proc)疑似残留 / 想手工排查 | docker 模式:`docker ps --filter label=zcbot.product=proc` 列在跑的 proc 容器;状态锚在 `<user_root>/.zcbot_procs/<task_id>/<proc_id>/``proc.json` 的 `state=queued` 表示等待共享容量,`exit_code` 存在表示已结束。web 约每 30 秒调度 queued 并回收终态容器,终态目录仍保留 7 天;正常终止/取消排队走前端停止按钮或 `check_process(action="kill")`。若手工 `docker rm -f`,状态会收敛为 lost。 |
| 后台进程状态显示 lost | 进程没留退出码就没了 —— 宿主重启 / OOM killer / docker daemon 重启把它带走(bg proc 扛 zcbot 重启和蓝绿,但不扛宿主级重启)。output.log 保留到中断为止,需重新 background=true 发起 |
| 镜像 build pip 报 `THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE`(本仓 requirements 未钉 hash) | **不是被篡改、也不是 require-hashes**:镜像 index 声明的 wheel hash 与它实际吐出的文件字节不符 = 该镜像存的文件损坏 / 截断(2026-06-03 腾讯源就这么坏过 litellm-1.87.0)。换源重 build:`PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/ sudo -E bash deploy/update.sh`。验真伪:`https://pypi.org/pypi/<pkg>/<ver>/json` 看官方 sha256 是哪边对。与下面"版本滞后(Could not find)"是两回事 |
| 镜像 build pip 报 `ReadTimeoutError: HTTPSConnectionPool(host='files.pythonhosted.org', ...)` | 境内访问 PyPI 抖动。加 `--build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/`(清华,现默认)或腾讯 / 阿里源,详 RUN.md「镜像构建」段。Dockerfile 已把 pip timeout 拉到 60s,主因仍是源不通而非超时 |

View File

@ -66,14 +66,20 @@ shutdown:
cancel_grace_seconds: 15 # 超时转 cancel 后再给的退场宽限
# Sandbox 容器资源限制(docker run flag,env 可 override);改后重启 web 生效,
# 新起的容器用新值,已 running 的不变(idle 5min 回收后下次起)。
# 新起的容器用新值,已 running 的不变(idle 10min 回收后下次起)。
sandbox:
memory: 2g # --memory (env: ZCBOT_SANDBOX_MEMORY)
cpus: 1.0 # --cpus (env: ZCBOT_SANDBOX_CPUS)
memory: 4g # --memory (env: ZCBOT_SANDBOX_MEMORY)
cpus: 2.0 # --cpus (env: ZCBOT_SANDBOX_CPUS)
pids_limit: 1024 # --pids-limit (env: ZCBOT_SANDBOX_PIDS_LIMIT);线程也计数,
# chromium headless 一次 ~150-200 线程 + 超时残留进程,256 会被
# 打满致 mmdc 渲 mermaid 必崩(pthread_create EAGAIN)
shm_size: 512m # --shm-size (env: ZCBOT_SANDBOX_SHM_SIZE);chromium/mmdc 渲 mermaid 的 /dev/shm,默 64MB 不够会挂
tmp_size: 1g # /tmp tmpfs (env: ZCBOT_SANDBOX_TMP_SIZE)
idle_ttl_seconds: 600 # 普通容器无 exec 活动 10 分钟回收
max_active_execs: 6 # 宿主硬上限;前后台统一计数
max_background_execs: 4
max_active_execs_per_user: 2
min_mem_available: 1g # 低于阈值暂停新放行,不杀已运行任务
# 容器 DNS server 显式配置(docker run --dns,容器 /etc/resolv.conf 直接写,
# 绕过 docker daemon 上游 DNS 探测路径;腾讯云轻量 / 部分云上 daemon 探测
# systemd-resolved 上游会失败,导致 embedded DNS 127.0.0.11 forward 出去也跪)。

View File

@ -25,7 +25,7 @@ run_python tmp .py 落 host 侧 `<user_root>/.zcbot_tmp/<task_id>/<rand>.py`(bin
Cancel limitation(第一版接受):
- docker exec 客户端断开后,容器内 server 端进程**不会**因此终止 这是 docker 设计
- 第一版只杀 docker CLI(Popen.kill());容器内残留进程靠 idle 5min reaper / 下次
- 第一版只杀 docker CLI(Popen.kill());容器内残留进程靠 idle 10min reaper / 下次
ensure rm -f 兜底
- 升级触发(§7.5 #3 PGID 协议):用户反馈"取消了但还在烧 CPU" / 多次 cancel 后
容器内进程堆积 启用ZCBOT_EXEC_ID env + PGID 写文件 + 二次 exec kill协议
@ -145,8 +145,12 @@ class DockerExecutor(Executor):
return ToolResult(content=f"[Error] unknown tool: {name}", exit_code=2)
try:
if name == "shell":
if not args.get("background"):
return self._call_heavy_foreground(name, args, ctx)
return self._exec_shell(args, ctx)
if name == "run_python":
if not args.get("background"):
return self._call_heavy_foreground(name, args, ctx)
return self._exec_python(args, ctx)
if name in FS_TOOLS:
return self._exec_fs_tool(name, args, ctx)
@ -157,6 +161,17 @@ class DockerExecutor(Executor):
)
return ToolResult(content=f"[Error] unhandled container tool: {name}", exit_code=2)
def _call_heavy_foreground(self, name: str, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
"""前台重型工具先排共享槽;排队不进入命令 timeout。"""
with self.pool.capacity.foreground(str(self.user_id), ctx.cancel_check) as admitted:
if not admitted:
return ToolResult(content="[Error] command cancelled by user while queued", exit_code=130)
self.pool.exec_started(self.user_id)
try:
return self._exec_shell(args, ctx) if name == "shell" else self._exec_python(args, ctx)
finally:
self.pool.exec_finished(self.user_id)
# ── shell ────────────────────────────────────────────────
def _exec_shell(self, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
@ -248,7 +263,7 @@ class DockerExecutor(Executor):
def _exec_background(self, name: str, args: Dict[str, Any], ctx: ExecCtx) -> ToolResult:
"""background=true:专用容器跑长进程,立即返回 proc_id。
为什么不是 `docker exec -d` sandbox 容器:sandbox idle 5min reaper +
为什么不是 `docker exec -d` sandbox 容器:sandbox idle 10min reaper +
启动时 shutdown_all,长进程会随容器陪葬专用容器 product=proc 与这两条
生命周期解耦(pool.run_proc_container),状态协议同 host 模式(core/procs.py):
runner.sh 结束时写 exit_code,check_process/sweep 负责回收容器
@ -256,15 +271,6 @@ class DockerExecutor(Executor):
from core import procs
anchor = self.user_root
if procs.count_running(anchor) >= procs.MAX_RUNNING_PER_USER:
return ToolResult(
content=(
f"[Error] 已有 {procs.MAX_RUNNING_PER_USER} 个后台进程在跑(上限)。"
f"用 check_process 查看,等待完成或 kill 掉不需要的再启动。"
),
exit_code=2,
)
raw_timeout = args.get("timeout")
fg_default = 60 if name == "shell" else 120
try:
@ -334,29 +340,16 @@ class DockerExecutor(Executor):
"timeout_s": timeout_s,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"created_ts": time.time(),
"user_id": str(self.user_id),
"exec_user": self.exec_user,
"state": "queued",
}
procs.write_meta(d, meta)
try:
container = self.pool.run_proc_container(self.user_id, proc_id, str(d))
except Exception as e:
return ToolResult(content=f"[Error] 后台容器启动失败: {e}", exit_code=1)
meta["container"] = container
procs.write_meta(d, meta)
argv = self._docker_exec_argv(
container, extra_env=_sandbox_env({"PYTHONIOENCODING": "utf-8"}), detach=True
) + ["bash", f"{cdir}/runner.sh"]
r = subprocess.run(argv, capture_output=True, text=True, timeout=60)
if r.returncode != 0:
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
return ToolResult(
content=f"[Error] 后台进程启动失败: {(r.stderr or '').strip()[:300]}",
exit_code=1,
)
started = procs.start_queued_docker(meta, d, self.pool)
return ToolResult(
content=(
f"[Background] 已启动后台进程 proc_id={proc_id}({display[:150]}),"
f"[Background] 已{'启动' if started else '排队'}后台进程 proc_id={proc_id}({display[:150]}),"
f"最长运行 {timeout_s}s。\n"
f"用 check_process(proc_id=\"{proc_id}\") 查进度和日志。进程独立于本轮对话运行,"
f"服务重启也不中断。现在可以继续其他工作;若无事可做,结束回合并告知用户稍后询问进度。"

View File

@ -45,9 +45,6 @@ PROCS_SUBDIR = ".zcbot_procs"
DEFAULT_TIMEOUT_S = 7200
MAX_TIMEOUT_S = 86400
# 每用户并发 bg 进程上限(跨 task 合计);防模型失控起一堆
MAX_RUNNING_PER_USER = int(os.getenv("ZCBOT_MAX_BG_PROCS", "3"))
# 终态 proc 目录保留时长,sweep 超期删除
FINISHED_TTL_S = 7 * 86400
@ -151,12 +148,14 @@ def _container_running(name: str) -> bool:
def status_of(meta: Dict[str, Any], d: Path) -> Tuple[str, Optional[int]]:
"""("running"|"finished"|"lost", exit_code|None)。exit_code 文件是唯一终态锚。"""
"""queued/running/finished/lost。exit_code 文件是唯一终态锚。"""
try:
raw = (d / "exit_code").read_text(encoding="utf-8").strip()
return "finished", int(raw)
except (OSError, ValueError):
pass
if meta.get("state") == "queued":
return "queued", None
if meta.get("backend") == "docker":
if _container_running(str(meta.get("container") or "")):
return "running", None
@ -304,10 +303,24 @@ def kill_proc(meta: Dict[str, Any], d: Path) -> str:
st, _ = status_of(meta, d)
if st == "finished":
return "进程已结束,无需终止"
if st == "queued":
meta["state"] = "finished"
meta["killed"] = True
write_meta(d, meta)
(d / "exit_code").write_text("137", encoding="utf-8")
return "已取消排队"
if meta.get("backend") == "docker":
name = str(meta.get("container") or "")
if name:
_scan_proc_container(meta, d, name)
subprocess.run(["docker", "rm", "-f", name], capture_output=True, timeout=30)
try:
from core.sandbox import get_pool
pool = get_pool()
if pool is not None:
pool.capacity.release(meta.get("capacity_lease_id"))
except Exception:
pass
else:
wrapper_pid = int(meta.get("wrapper_pid") or 0)
child_pid = int(meta.get("child_pid") or 0)
@ -338,6 +351,69 @@ def kill_proc(meta: Dict[str, Any], d: Path) -> str:
return "已终止"
def start_queued_docker(meta: Dict[str, Any], d: Path, pool) -> bool:
"""尝试为 queued proc 获取共享槽并启动;无槽返回 False保持排队。"""
from core.file_store import interprocess_file_lock
with interprocess_file_lock(d / "dispatch.lock", timeout_seconds=None):
meta = read_meta(d) or meta
if status_of(meta, d)[0] != "queued":
return False
uid = str(meta.get("user_id") or "")
lease_id = f"bg-{meta.get('proc_id')}"
lease = pool.capacity.try_acquire(uid, "background", lease_id=lease_id,
proc_id=str(meta.get("proc_id")))
if not lease:
return False
try:
from uuid import UUID
container = pool.run_proc_container(UUID(uid), str(meta["proc_id"]), str(d))
cdir = f"/workspace/{PROCS_SUBDIR}/{meta['task_id']}/{meta['proc_id']}"
argv = ["docker", "exec", "--user", str(meta.get("exec_user") or "zcbot"),
"--workdir", str(meta.get("cwd") or "/workspace"), "-d",
"-e", "PYTHONPATH=/sandbox:/workspace", "-e", "HOME=/tmp",
"-e", "PYTHONIOENCODING=utf-8", container, "bash", f"{cdir}/runner.sh"]
r = subprocess.run(argv, capture_output=True, text=True, timeout=60)
if r.returncode != 0:
subprocess.run(["docker", "rm", "-f", container], capture_output=True)
raise RuntimeError((r.stderr or "").strip()[:300])
meta.update({"container": container, "capacity_lease_id": lease,
"state": "running", "started_ts": time.time()})
write_meta(d, meta)
return True
except Exception as exc:
pool.capacity.release(lease)
meta["last_start_error"] = f"{type(exc).__name__}: {exc}"
write_meta(d, meta)
return False
def dispatch_queued(user_root_base: Path, pool) -> int:
started = 0
base = Path(user_root_base)
if not base.is_dir():
return 0
candidates = []
running_ids: set[str] = set()
for uroot in base.iterdir():
root = uroot / PROCS_SUBDIR
if not root.is_dir():
continue
for meta_file in root.glob("*/*/proc.json"):
d = meta_file.parent
meta = read_meta(d)
if meta:
status = status_of(meta, d)[0]
if status == "queued":
candidates.append((float(meta.get("created_ts") or 0), meta, d))
elif status == "running":
running_ids.add(str(meta.get("proc_id") or d.name))
pool.capacity.reconcile_background(running_ids)
for _, meta, d in sorted(candidates, key=lambda x: x[0]):
if start_queued_docker(meta, d, pool):
started += 1
return started
# ───────────── 清扫(web lifespan 周期调用) ─────────────
def sweep(user_root_base: Path, ttl_s: int = FINISHED_TTL_S) -> Dict[str, int]:
@ -365,16 +441,27 @@ def sweep(user_root_base: Path, ttl_s: int = FINISHED_TTL_S) -> Dict[str, int]:
if not meta:
continue
st, _ = status_of(meta, d)
if st != "running" and meta.get("backend") == "docker":
if st in {"finished", "lost"} and meta.get("capacity_lease_id"):
try:
from core.sandbox import get_pool
pool = get_pool()
if pool is not None:
pool.capacity.release(meta.get("capacity_lease_id"))
meta.pop("capacity_lease_id", None)
write_meta(d, meta)
except Exception:
pass
if st not in {"running", "queued"} and meta.get("backend") == "docker":
name = str(meta.get("container") or "")
if name and _container_exists(name):
_scan_proc_container(meta, d, name)
subprocess.run(
["docker", "rm", "-f", name],
capture_output=True, timeout=30,
)
reaped_containers += 1
age = now - float(meta.get("created_ts") or now)
if st != "running" and age > ttl_s:
if st not in {"running", "queued"} and age > ttl_s:
shutil.rmtree(d, ignore_errors=True)
removed_dirs += 1
try:
@ -410,6 +497,16 @@ def _container_exists(name: str) -> bool:
return False
def _scan_proc_container(meta: Dict[str, Any], d: Path, name: str) -> None:
try:
from uuid import UUID
from core.sandbox.package_scans import scan_and_persist
uid = UUID(str(meta.get("user_id")))
scan_and_persist(name, uid, "background", str(meta.get("proc_id") or d.name))
except Exception as exc:
print(f"[sandbox-package-scan] proc finalizer failed container={name}: {type(exc).__name__}: {exc}")
# ───────────── LLM 面向的描述格式化(check_process / 启动返回共用) ─────────────
def format_proc_line(meta: Dict[str, Any]) -> str:
@ -418,7 +515,7 @@ def format_proc_line(meta: Dict[str, Any]) -> str:
elapsed = ""
ts = meta.get("created_ts")
if ts:
elapsed = f" · 已运行 {_fmt_elapsed(time.time() - float(ts))}" if st == "running" else ""
elapsed = f" · 已运行 {_fmt_elapsed(time.time() - float(meta.get('started_ts') or ts))}" if st == "running" else ""
tail = f"(exit {ec})" if st == "finished" else ""
return (
f"- {meta.get('proc_id')} [{st}{tail}] {meta.get('kind')}: "

222
core/sandbox/capacity.py Normal file
View File

@ -0,0 +1,222 @@
"""宿主级 Sandbox 重型执行容量。
状态保存在 workspace/.sandbox 下并由 advisory file lock 串行化因此蓝绿和多
Web 进程共享同一组槽位业务 DB 不承载实时状态后台排队事实源仍是
``.zcbot_procs``这里只记录已经获得槽位的租约和短暂的前台候选
"""
from __future__ import annotations
import json
import os
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Callable, Dict, Iterator, Optional
from core.file_store import atomic_write_text, interprocess_file_lock
DEFAULT_MAX_ACTIVE_EXECS = 6
DEFAULT_MAX_BACKGROUND_EXECS = 4
DEFAULT_MAX_ACTIVE_EXECS_PER_USER = 2
DEFAULT_MIN_MEM_AVAILABLE_BYTES = 1024 ** 3
def _parse_bytes(value: object, default: int) -> int:
text = str(value or "").strip().lower()
if not text:
return default
units = {"k": 1024, "kb": 1024, "m": 1024**2, "mb": 1024**2,
"g": 1024**3, "gb": 1024**3}
for suffix, factor in sorted(units.items(), key=lambda x: -len(x[0])):
if text.endswith(suffix):
return int(float(text[:-len(suffix)]) * factor)
return int(text)
def mem_available_bytes() -> Optional[int]:
try:
for line in Path("/proc/meminfo").read_text(encoding="ascii").splitlines():
if line.startswith("MemAvailable:"):
return int(line.split()[1]) * 1024
except (OSError, ValueError, IndexError):
return None
return None
class ExecCapacity:
def __init__(self, state_dir: Path, cfg: Optional[dict] = None) -> None:
cfg = cfg or {}
self.state_dir = Path(state_dir)
self.state_path = self.state_dir / "exec-capacity.json"
self.lock_path = self.state_dir / "exec-capacity.lock"
self.max_active = max(1, min(DEFAULT_MAX_ACTIVE_EXECS, int(os.getenv("ZCBOT_MAX_ACTIVE_EXECS") or cfg.get("max_active_execs") or DEFAULT_MAX_ACTIVE_EXECS)))
self.max_background = max(1, min(DEFAULT_MAX_BACKGROUND_EXECS, int(os.getenv("ZCBOT_MAX_BACKGROUND_EXECS") or cfg.get("max_background_execs") or DEFAULT_MAX_BACKGROUND_EXECS)))
self.max_per_user = max(1, min(DEFAULT_MAX_ACTIVE_EXECS_PER_USER, int(os.getenv("ZCBOT_MAX_ACTIVE_EXECS_PER_USER") or cfg.get("max_active_execs_per_user") or DEFAULT_MAX_ACTIVE_EXECS_PER_USER)))
self.min_mem_available = _parse_bytes(
os.getenv("ZCBOT_MIN_MEM_AVAILABLE") or cfg.get("min_mem_available"),
DEFAULT_MIN_MEM_AVAILABLE_BYTES,
)
def _read(self) -> dict:
try:
data = json.loads(self.state_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
data.setdefault("leases", {})
data.setdefault("foreground_queue", [])
data.setdefault("containers", {})
return data
except (OSError, ValueError):
pass
return {"leases": {}, "foreground_queue": [], "containers": {}}
def _write(self, state: dict) -> None:
atomic_write_text(self.state_path, json.dumps(state, ensure_ascii=False, sort_keys=True))
def _prune(self, state: dict) -> None:
leases = state["leases"]
for key, lease in list(leases.items()):
if lease.get("kind") == "foreground":
pid = int(lease.get("owner_pid") or 0)
if pid and not self._pid_alive(pid):
leases.pop(key, None)
state["foreground_queue"] = [
q for q in state["foreground_queue"]
if self._pid_alive(int(q.get("owner_pid") or 0))
]
@staticmethod
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except PermissionError:
return True
except OSError:
return False
def _can_admit(self, state: dict, user_id: str, kind: str) -> bool:
leases = list(state["leases"].values())
if len(leases) >= self.max_active:
return False
if sum(1 for x in leases if x.get("user_id") == user_id) >= self.max_per_user:
return False
if kind == "background" and sum(1 for x in leases if x.get("kind") == "background") >= self.max_background:
return False
available = mem_available_bytes()
return available is None or available >= self.min_mem_available
def try_acquire(self, user_id: str, kind: str, *, lease_id: Optional[str] = None, proc_id: Optional[str] = None) -> Optional[str]:
lease_id = lease_id or uuid.uuid4().hex
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read()
self._prune(state)
if lease_id in state["leases"]:
return None
if not self._can_admit(state, str(user_id), kind):
self._write(state)
return None
state["leases"][lease_id] = {
"lease_id": lease_id, "user_id": str(user_id), "kind": kind,
"proc_id": proc_id, "owner_pid": os.getpid(), "started_ts": time.time(),
}
self._write(state)
return lease_id
def acquire_foreground(self, user_id: str, cancel_check: Optional[Callable[[], bool]] = None) -> Optional[str]:
ticket = uuid.uuid4().hex
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read(); self._prune(state)
state["foreground_queue"].append({"ticket": ticket, "user_id": str(user_id), "created_ts": time.time(), "owner_pid": os.getpid()})
self._write(state)
while True:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read(); self._prune(state)
queue = state["foreground_queue"]
eligible = next((q for q in queue if q.get("user_id") == str(user_id)), None)
if eligible and eligible.get("ticket") == ticket and self._can_admit(state, str(user_id), "foreground"):
queue[:] = [q for q in queue if q.get("ticket") != ticket]
state["leases"][ticket] = {"lease_id": ticket, "user_id": str(user_id), "kind": "foreground", "owner_pid": os.getpid(), "started_ts": time.time()}
self._write(state)
return ticket
self._write(state)
if cancel_check is not None and cancel_check():
self.cancel_waiter(ticket)
return None
time.sleep(0.1)
def cancel_waiter(self, ticket: str) -> None:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read()
state["foreground_queue"] = [q for q in state["foreground_queue"] if q.get("ticket") != ticket]
self._write(state)
def release(self, lease_id: Optional[str]) -> None:
if not lease_id:
return
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read(); state["leases"].pop(lease_id, None); self._write(state)
def reconcile_background(self, running_proc_ids: set[str]) -> None:
"""服务重启后保留 Docker 仍在跑的租约,清启动窗口遗留的孤儿租约。"""
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read()
for key, lease in list(state["leases"].items()):
if (lease.get("kind") == "background"
and str(lease.get("proc_id") or "") not in running_proc_ids
and not self._pid_alive(int(lease.get("owner_pid") or 0))):
state["leases"].pop(key, None)
self._write(state)
def touch_container(self, name: str, *, active_delta: int = 0) -> None:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read()
row = state["containers"].setdefault(name, {"active_execs": 0})
row["active_execs"] = max(0, int(row.get("active_execs") or 0) + active_delta)
row["last_active_ts"] = time.time()
self._write(state)
def remove_container(self, name: str) -> None:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read(); state["containers"].pop(name, None); self._write(state)
def reconcile_containers(self, running_names: set[str]) -> None:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read()
for name in list(state["containers"]):
if name not in running_names:
state["containers"].pop(name, None)
self._write(state)
@contextmanager
def foreground(self, user_id: str, cancel_check: Optional[Callable[[], bool]] = None) -> Iterator[bool]:
lease = self.acquire_foreground(user_id, cancel_check)
try:
yield lease is not None
finally:
self.release(lease)
def snapshot(self) -> dict:
with interprocess_file_lock(self.lock_path, timeout_seconds=None):
state = self._read(); self._prune(state); self._write(state)
leases = list(state["leases"].values())
fg = sum(x.get("kind") == "foreground" for x in leases)
bg = sum(x.get("kind") == "background" for x in leases)
by_user: Dict[str, int] = {}
for x in leases:
by_user[x["user_id"]] = by_user.get(x["user_id"], 0) + 1
available_mem = mem_available_bytes()
memory_paused = available_mem is not None and available_mem < self.min_mem_available
containers = state.get("containers", {})
now = time.time()
return {
"limits": {"active": self.max_active, "background": self.max_background, "per_user": self.max_per_user},
"foreground_running": fg, "foreground_queued": len(state["foreground_queue"]),
"background_running": bg, "per_user": by_user,
"admit_available": 0 if memory_paused else max(0, self.max_active - len(leases)),
"memory_paused": memory_paused,
"mem_available_bytes": available_mem,
"cpu_load": list(os.getloadavg()) if hasattr(os, "getloadavg") else None,
"active_sandbox_containers": len(containers),
"idle_reap_candidates": sum(int(x.get("active_execs") or 0) == 0 and float(x.get("last_active_ts") or now) < now - getattr(self, "idle_ttl_seconds", 600) for x in containers.values()),
}

View File

@ -0,0 +1,57 @@
"""Sandbox 临时 Python 包扫描与 best-effort 持久化。"""
from __future__ import annotations
import json
import subprocess
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
def scan_container(container: str) -> Optional[dict]:
try:
scan = subprocess.run(["docker", "exec", "--user", "zcbot", "--workdir", "/sandbox", container,
"python", "-I", "-S", "/sandbox/package_scan.py"], capture_output=True,
text=True, timeout=30)
if scan.returncode != 0:
raise RuntimeError(scan.stderr.strip())
packages = json.loads(scan.stdout)
if not isinstance(packages, list):
raise ValueError("scanner output is not a package list")
inspect = subprocess.run(["docker", "inspect", "--format={{.Image}}", container],
capture_output=True, text=True, timeout=15)
py = subprocess.run(["docker", "exec", "--workdir", "/sandbox", container, "python", "-I", "-S", "-c",
"import sys;print('.'.join(map(str,sys.version_info[:3])))"],
capture_output=True, text=True, timeout=15)
if inspect.returncode != 0 or py.returncode != 0:
raise RuntimeError("failed to inspect image or Python version")
return {"packages": packages, "image_digest": inspect.stdout.strip(),
"python_version": py.stdout.strip(),
"total_installed_bytes": sum(int(x.get("installed_bytes") or 0) for x in packages)}
except Exception as exc:
print(f"[sandbox-package-scan] failed container={container}: {type(exc).__name__}: {exc}")
return None
def persist_scan(*, container_session_id: str, user_id: UUID, execution_kind: str,
result: Optional[dict]) -> None:
if not result or not result.get("packages"):
return
try:
from core.storage import session_scope
from core.storage.models import SandboxPackageScan
with session_scope() as s:
s.merge(SandboxPackageScan(
container_session_id=container_session_id, user_id=user_id,
execution_kind=execution_kind, image_digest=result["image_digest"],
python_version=result["python_version"], packages=result["packages"],
package_count=len(result["packages"]),
total_installed_bytes=result["total_installed_bytes"],
finished_at=datetime.now(timezone.utc),
))
except Exception as exc:
print(f"[sandbox-package-scan] persist failed session={container_session_id}: {type(exc).__name__}: {exc}")
def scan_and_persist(container: str, user_id: UUID, kind: str, session_id: str) -> None:
persist_scan(container_session_id=session_id, user_id=user_id,
execution_kind=kind, result=scan_container(container))

View File

@ -56,11 +56,11 @@ LABEL_INSTANCE_KEY = "zcbot.instance"
INSTANCE = os.getenv("ZCBOT_INSTANCE", "").strip()
DEFAULT_IMAGE = "zcbot-sandbox:latest"
DEFAULT_IDLE_TTL_SECONDS = 300
DEFAULT_IDLE_TTL_SECONDS = 600
# 容器资源限制默认值(可被 yaml `sandbox.*` / env override,详 SandboxPool ctor)
DEFAULT_MEMORY = "2g"
DEFAULT_CPUS = "1.0"
DEFAULT_MEMORY = "4g"
DEFAULT_CPUS = "2.0"
# pids-limit 把**线程**也计数:chromium headless 一次启动(browser+GPU+network+renderer
# 多进程)就 ~150-200 线程,256 本来就贴边;shell 超时又只杀 host 侧 docker CLI,容器内
# mmdc+chromium 树留着(见 executor_docker.py 头注 Cancel limitation),残留几棵就把配额
@ -70,8 +70,9 @@ DEFAULT_PIDS_LIMIT = 1024
# chromium(mmdc 渲 mermaid / puppeteer)默认走 /dev/shm,docker 不传 --shm-size 时
# 只给 64MB,起不来就一直挂到 timeout。镜像备的 puppeteer-config 有 --disable-dev-shm-usage,
# 但模型不一定用那份;这里从根上把 /dev/shm 撑到够用,任何 chromium 路径都不再挂。
# 从 --memory(默 2g)里切,512m 是上限非占用(tmpfs 按需用)。
# 从 --memory(默 4g)里切,512m 是上限非占用(tmpfs 按需用)。
DEFAULT_SHM_SIZE = "512m"
DEFAULT_TMP_SIZE = "1g"
def container_name(user_id: UUID) -> str:
@ -102,6 +103,11 @@ def _container_running(name: str) -> bool:
return r.returncode == 0 and r.stdout.strip() == "true"
def _container_session_id(name: str) -> str:
r = subprocess.run(["docker", "inspect", "--format={{.Id}}", name], capture_output=True, text=True)
return r.stdout.strip() if r.returncode == 0 else name
class SandboxPool:
def __init__(
self,
@ -115,7 +121,9 @@ class SandboxPool:
cpus: Optional[str] = None,
pids_limit: Optional[int] = None,
shm_size: Optional[str] = None,
tmp_size: Optional[str] = None,
dns: Optional[List[str]] = None,
capacity_cfg: Optional[Dict[str, object]] = None,
) -> None:
"""
user_root_base: per-user 子树父目录,典型 `<workspace>/users`bind mount
@ -134,9 +142,9 @@ class SandboxPool:
pg_ips: 逗号分隔的 PG IP ,塞容器 `ZCBOT_PG_IPS` env,init.sh DROP 规则
(env `ZCBOT_PG_IPS`)defense-in-depth 即便落内网三段
memory/cpus/pids_limit/shm_size:
容器资源限制, 2g/1.0/256/512m;env(`ZCBOT_SANDBOX_MEMORY` )
容器资源限制, 4g/2.0/1024/512m;env(`ZCBOT_SANDBOX_MEMORY` )
override caller 参数 override 默认改后重启 web 生效,新起的
容器用新值; running 不变(idle 5min 回收后下次起按新值)
容器用新值; running 不变(idle 10min 回收后下次起按新值)
shm_size chromium /dev/shm( 64MB 不够,mmdc 渲图会挂)
"""
self.user_root_base = user_root_base
@ -153,6 +161,7 @@ class SandboxPool:
self.memory = os.getenv("ZCBOT_SANDBOX_MEMORY") or memory or DEFAULT_MEMORY
self.cpus = os.getenv("ZCBOT_SANDBOX_CPUS") or cpus or DEFAULT_CPUS
self.shm_size = os.getenv("ZCBOT_SANDBOX_SHM_SIZE") or shm_size or DEFAULT_SHM_SIZE
self.tmp_size = os.getenv("ZCBOT_SANDBOX_TMP_SIZE") or tmp_size or DEFAULT_TMP_SIZE
self.pids_limit = int(
os.getenv("ZCBOT_SANDBOX_PIDS_LIMIT")
or (pids_limit if pids_limit is not None else DEFAULT_PIDS_LIMIT)
@ -166,6 +175,10 @@ class SandboxPool:
self._dict_lock = threading.Lock() # 保护 _locks / _last_active 的字典级 race
self._locks: Dict[UUID, threading.Lock] = {}
self._last_active: Dict[UUID, int] = {}
self._active_execs: Dict[UUID, int] = {}
from .capacity import ExecCapacity
self.capacity = ExecCapacity(self.user_root_base.parent / ".sandbox", capacity_cfg)
self.capacity.idle_ttl_seconds = self.idle_ttl
def _lock_for(self, user_id: UUID) -> threading.Lock:
with self._dict_lock:
@ -179,15 +192,18 @@ class SandboxPool:
name = container_name(user_id)
if _container_running(name):
self._last_active[user_id] = _now()
self.capacity.touch_container(name)
return name
if _container_exists(name):
# stopped / crashed ── rm 重起。iptables 规则随容器生命周期重新 apply。
self._scan_before_remove(name, user_id, "foreground")
subprocess.run(
["docker", "rm", "-f", name],
capture_output=True, check=False,
)
self._docker_run(user_id, name)
self._last_active[user_id] = _now()
self.capacity.touch_container(name)
return name
def _ensure_resolv_conf_file(self) -> Optional[Path]:
@ -255,7 +271,7 @@ class SandboxPool:
"--network", NETWORK_NAME,
# §7.5 硬限制(任一缺失视为 hardening 未完成)
"--read-only", # rootfs read-only
"--tmpfs", "/tmp:exec,size=512m,mode=1777", # 可写临时区,exec 允许 (run_python 写脚本)
"--tmpfs", f"/tmp:exec,size={self.tmp_size},mode=1777",
f"--shm-size={self.shm_size}", # chromium/mmdc 的 /dev/shm,默 64MB 不够会挂(DEFAULT_SHM_SIZE)
"--cap-drop=ALL", # 默全丢
"--cap-add=NET_ADMIN", # init.sh 配 iptables 需要;exec 进来的 uid 1000 拿不到
@ -305,20 +321,48 @@ class SandboxPool:
def mark_active(self, user_id: UUID) -> None:
"""每次 `docker exec` 完调一次,刷新 idle 计时。"""
self._last_active[user_id] = _now()
self.capacity.touch_container(container_name(user_id))
def exec_started(self, user_id: UUID) -> None:
"""普通容器内 exec 开始;显式计数防止 idle reaper 误删长命令。"""
with self._dict_lock:
self._active_execs[user_id] = self._active_execs.get(user_id, 0) + 1
self._last_active[user_id] = _now()
self.capacity.touch_container(container_name(user_id), active_delta=1)
def exec_finished(self, user_id: UUID) -> None:
with self._dict_lock:
n = max(0, self._active_execs.get(user_id, 0) - 1)
if n:
self._active_execs[user_id] = n
else:
self._active_execs.pop(user_id, None)
self._last_active[user_id] = _now()
self.capacity.touch_container(container_name(user_id), active_delta=-1)
def _scan_before_remove(self, name: str, user_id: UUID, kind: str, session_id: Optional[str] = None) -> None:
"""扫描/落库均 best-effort调用方无论如何继续删除容器。"""
try:
from .package_scans import scan_and_persist
scan_and_persist(name, user_id, kind, session_id or _container_session_id(name))
except Exception as exc:
print(f"[sandbox-package-scan] finalizer failed container={name}: {type(exc).__name__}: {exc}")
def reap_idle(self) -> List[str]:
"""杀超过 idle_ttl 没活跃的容器。返回已杀容器名列表(供日志 / 审计)。"""
removed: List[str] = []
cutoff = _now() - self.idle_ttl
for uid, ts in list(self._last_active.items()):
if ts < cutoff:
if ts < cutoff and self._active_execs.get(uid, 0) == 0:
name = container_name(uid)
self._scan_before_remove(name, uid, "foreground")
r = subprocess.run(
["docker", "rm", "-f", name],
capture_output=True, text=True,
)
if r.returncode == 0:
removed.append(name)
self.capacity.remove_container(name)
# 无论 rm 成功与否,从 dict 移除 ── 失败则下次启动靠 shutdown_all 兜底
del self._last_active[uid]
return removed
@ -341,6 +385,21 @@ class SandboxPool:
if list_r.returncode != 0 or not list_r.stdout.strip():
return []
ids = list_r.stdout.strip().splitlines()
for cid in ids:
info = subprocess.run(
["docker", "inspect", "--format={{index .Config.Labels \"zcbot.user_id\"}}", cid],
capture_output=True, text=True,
)
try:
uid = UUID(info.stdout.strip())
except (ValueError, AttributeError):
continue
self._scan_before_remove(cid, uid, "foreground", cid)
try:
name_r = subprocess.run(["docker", "inspect", "--format={{.Name}}", cid], capture_output=True, text=True)
self.capacity.remove_container(name_r.stdout.strip().lstrip("/") or cid)
except Exception:
pass
subprocess.run(
["docker", "rm", "-f", *ids],
capture_output=True, text=True,
@ -349,6 +408,31 @@ class SandboxPool:
self._last_active.clear()
return ids
def runtime_snapshot(self) -> dict:
try:
listed = subprocess.run(
["docker", "ps", "--format={{.Names}}", "--filter", f"label={LABEL_PRODUCT_KEY}={LABEL_PRODUCT_VALUE}"],
capture_output=True, text=True, timeout=15,
)
if listed.returncode == 0:
self.capacity.reconcile_containers(set(listed.stdout.split()))
except (OSError, subprocess.TimeoutExpired):
pass
snap = self.capacity.snapshot()
now = _now()
snap["idle_ttl_seconds"] = self.idle_ttl
try:
from core import procs
items = []
for uroot in self.user_root_base.iterdir():
items.extend(procs.list_all_procs(uroot))
snap["background_queued"] = sum(x.get("_status") == "queued" for x in items)
snap["running_proc_containers"] = sum(x.get("_status") == "running" and x.get("backend") == "docker" for x in items)
except OSError:
snap["background_queued"] = 0
snap["running_proc_containers"] = 0
return snap
def setup_pool(
user_root_base: Path,
@ -376,12 +460,16 @@ def setup_pool(
cpus = cfg.get("cpus")
pids_limit = cfg.get("pids_limit")
shm_size = cfg.get("shm_size")
tmp_size = cfg.get("tmp_size")
return SandboxPool(
user_root_base=user_root_base,
repo_root=repo_root,
idle_ttl=int(str(cfg.get("idle_ttl_seconds"))) if cfg.get("idle_ttl_seconds") is not None else None,
memory=memory if isinstance(memory, str) else None,
cpus=str(cpus) if cpus is not None else None,
pids_limit=int(str(pids_limit)) if pids_limit is not None else None,
shm_size=shm_size if isinstance(shm_size, str) else None,
tmp_size=tmp_size if isinstance(tmp_size, str) else None,
dns=[str(x) for x in dns_cfg],
capacity_cfg=cfg,
)

View File

@ -804,3 +804,27 @@ class ExternalSystemAudit(Base):
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
class SandboxPackageScan(Base):
"""一个发现临时 Python 包的容器会话一行(空扫描不入库)。"""
__tablename__ = "sandbox_package_scans"
__table_args__ = (
Index("ix_sandbox_package_scans_finished_at", "finished_at"),
Index("ix_sandbox_package_scans_user_finished", "user_id", "finished_at"),
)
container_session_id: Mapped[str] = mapped_column(Text, primary_key=True)
user_id: Mapped[UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False
)
execution_kind: Mapped[str] = mapped_column(Text, nullable=False)
image_digest: Mapped[str] = mapped_column(Text, nullable=False)
python_version: Mapped[str] = mapped_column(Text, nullable=False)
packages: Mapped[list[Any]] = mapped_column(JSONB, nullable=False)
package_count: Mapped[int] = mapped_column(Integer, nullable=False)
total_installed_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
finished_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View File

@ -0,0 +1,37 @@
"""Add sandbox package scan sessions.
Revision ID: 0038
Revises: 0037
Create Date: 2026-09-01
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0038"
down_revision: str | None = "0037"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"sandbox_package_scans",
sa.Column("container_session_id", sa.Text(), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False),
sa.Column("execution_kind", sa.Text(), nullable=False),
sa.Column("image_digest", sa.Text(), nullable=False),
sa.Column("python_version", sa.Text(), nullable=False),
sa.Column("packages", postgresql.JSONB(), nullable=False),
sa.Column("package_count", sa.Integer(), nullable=False),
sa.Column("total_installed_bytes", sa.BigInteger(), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_sandbox_package_scans_finished_at", "sandbox_package_scans", ["finished_at"])
op.create_index("ix_sandbox_package_scans_user_finished", "sandbox_package_scans", ["user_id", "finished_at"])
def downgrade() -> None:
op.drop_table("sandbox_package_scans")

View File

@ -64,6 +64,9 @@ RUN grep -v '\[host-only\]' /tmp/requirements.txt > /tmp/requirements.sandbox.tx
-r /tmp/requirements.sandbox.txt \
&& rm /tmp/requirements.txt /tmp/requirements.sandbox.txt
# 构建期基础包清单;运行时扫描只与它比较,不 import/执行用户包。
RUN mkdir -p /sandbox && python -c "import importlib.metadata as m,json; c=lambda s:s.lower().replace('_','-').replace('.','-'); print(json.dumps({c(d.metadata['Name']):d.version for d in m.distributions() if d.metadata.get('Name')},sort_keys=True))" > /sandbox/base-python-packages.json
# 持久化 pip 源到 /etc/pip.conf ── 让运行时模型用 `pip install foo` 也走 mirror,
# 不只 build 时。zcbot user / root 都吃这个 global 配置。
RUN printf '[global]\nindex-url = %s\ntimeout = 60\n%s\n' \
@ -176,6 +179,7 @@ COPY core/__init__.py core/file_store.py core/kb_lock.py /sandbox/core/
COPY core/sandbox/tool_runner.py /sandbox/tool_runner.py
COPY deploy/sandbox/init.sh /init.sh
COPY deploy/sandbox/package_scan.py /sandbox/package_scan.py
RUN chmod +x /init.sh
# 默认 cwd /workspace ── 但每次 `docker exec --workdir /workspace/<wd>` 会覆盖

View File

@ -0,0 +1,84 @@
"""可信临时包扫描器;只解析 dist-info 元数据,不 import 用户代码。"""
from __future__ import annotations
import csv
import email.parser
import json
from pathlib import Path
BASE = Path("/sandbox/base-python-packages.json")
ROOT = Path("/tmp/.local/lib")
def safe_file(root: Path, path: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
current = root
for part in path.relative_to(root).parts:
current = current / part
if current.is_symlink():
return False
return path.is_file()
def safe_dir(root: Path, path: Path) -> bool:
try:
rel = path.relative_to(root)
except ValueError:
return False
current = root
for part in rel.parts:
current = current / part
if current.is_symlink():
return False
return path.is_dir()
def canonical(name: str) -> str:
return name.lower().replace("_", "-").replace(".", "-")
def scan_packages(root: Path, base: dict) -> list[dict]:
out = []
if root.is_dir():
for site in sorted(root.glob("python*/site-packages")):
if not safe_dir(root, site):
continue
for dist in sorted(site.glob("*.dist-info")):
if len(out) >= 5000:
return out
meta_path = dist / "METADATA"
if not safe_file(site, meta_path) or meta_path.stat().st_size > 1024 * 1024:
continue
msg = email.parser.Parser().parsestr(meta_path.read_text(encoding="utf-8", errors="replace"), headersonly=True)
name, version = msg.get("Name", "").strip(), msg.get("Version", "").strip()
if not name:
continue
total = 0
record = dist / "RECORD"
if safe_file(site, record) and record.stat().st_size <= 32 * 1024 * 1024:
for index, row in enumerate(csv.reader(record.read_text(encoding="utf-8", errors="replace").splitlines())):
if index >= 200_000:
break
if not row:
continue
candidate = site / row[0]
if safe_file(site, candidate):
try: total += candidate.stat().st_size
except OSError: pass
key = canonical(name)
old = base.get(key)
change = "added" if old is None else ("reinstall" if old == version else "override")
out.append({"name": name, "version": version, "base_version": old,
"change": change, "direct_requested": safe_file(site, dist / "REQUESTED") or safe_file(site, dist / "direct_url.json"),
"installed_bytes": total})
return out
def main() -> None:
base = json.loads(BASE.read_text(encoding="utf-8"))
print(json.dumps(scan_packages(ROOT, base), ensure_ascii=False))
if __name__ == "__main__":
main()

View File

@ -18,6 +18,7 @@ import tempfile
import threading
import time
import unittest
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import MagicMock, patch
from uuid import uuid4
@ -35,6 +36,18 @@ class FakePool:
def __init__(self):
self.ensure_calls = []
self.mark_active_calls = []
self.active = 0
self.capacity = self
@contextmanager
def foreground(self, user_id, cancel_check=None):
yield True
def exec_started(self, user_id):
self.active += 1
def exec_finished(self, user_id):
self.active -= 1
def ensure(self, user_id):
name = f"zcbot-sandbox-{user_id}"

View File

@ -0,0 +1,183 @@
from __future__ import annotations
import json
import os
import tempfile
import threading
import time
import unittest
from datetime import datetime, timezone
from types import SimpleNamespace
from pathlib import Path
from unittest.mock import MagicMock, patch
from uuid import uuid4
from core.sandbox.capacity import ExecCapacity
from core.sandbox.pool import SandboxPool
from core.sandbox.package_scans import persist_scan
from deploy.sandbox.package_scan import scan_packages
from core import procs
from web.admin import _sandbox_package_stats
class CapacityTests(unittest.TestCase):
def test_env_overrides_capacity_config(self):
with tempfile.TemporaryDirectory() as td, patch.dict(os.environ, {
"ZCBOT_MAX_ACTIVE_EXECS": "5", "ZCBOT_MAX_BACKGROUND_EXECS": "3",
"ZCBOT_MAX_ACTIVE_EXECS_PER_USER": "1", "ZCBOT_MIN_MEM_AVAILABLE": "2g",
}, clear=False):
cap = ExecCapacity(Path(td), {"max_active_execs": 9, "max_background_execs": 8,
"max_active_execs_per_user": 7, "min_mem_available": "4g"})
self.assertEqual((cap.max_active, cap.max_background, cap.max_per_user, cap.min_mem_available),
(5, 3, 1, 2 * 1024**3))
def test_config_cannot_raise_hard_limits(self):
with tempfile.TemporaryDirectory() as td:
cap = ExecCapacity(Path(td), {"max_active_execs": 99, "max_background_execs": 99,
"max_active_execs_per_user": 99})
self.assertEqual((cap.max_active, cap.max_background, cap.max_per_user), (6, 4, 2))
def test_global_per_user_and_background_limits(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=10**12):
cap = ExecCapacity(Path(td), {"max_active_execs": 3, "max_background_execs": 1, "max_active_execs_per_user": 2})
a = cap.try_acquire("u1", "background", lease_id="a")
b = cap.try_acquire("u1", "foreground", lease_id="b")
self.assertIsNotNone(a); self.assertIsNotNone(b)
self.assertIsNone(cap.try_acquire("u1", "foreground", lease_id="c"))
self.assertIsNone(cap.try_acquire("u2", "background", lease_id="d"))
self.assertIsNotNone(cap.try_acquire("u2", "foreground", lease_id="e"))
self.assertIsNone(cap.try_acquire("u3", "foreground", lease_id="f"))
def test_cross_instance_state_and_cancel_queue(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=10**12):
one = ExecCapacity(Path(td), {"max_active_execs": 1})
two = ExecCapacity(Path(td), {"max_active_execs": 1})
lease = one.try_acquire("u1", "foreground", lease_id="held")
cancelled = threading.Event()
result = []
t = threading.Thread(target=lambda: result.append(two.acquire_foreground("u2", cancelled.is_set)))
t.start(); time.sleep(.15); cancelled.set(); t.join(2)
self.assertEqual(result, [None])
self.assertEqual(two.snapshot()["foreground_queued"], 0)
one.release(lease)
def test_memory_pressure_pauses_new_admission(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.capacity.mem_available_bytes", return_value=100):
cap = ExecCapacity(Path(td), {"min_mem_available": "1g"})
self.assertIsNone(cap.try_acquire("u", "foreground"))
self.assertTrue(cap.snapshot()["memory_paused"])
class PoolTests(unittest.TestCase):
def test_defaults_and_docker_tmpfs(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.pool.subprocess.run") as run:
run.return_value = MagicMock(returncode=0, stdout="")
pool = SandboxPool(Path(td) / "users")
self.assertEqual((pool.memory, pool.cpus, pool.idle_ttl, pool.tmp_size), ("4g", "2.0", 600, "1g"))
pool._docker_run(uuid4(), "box")
argv = run.call_args_list[-1].args[0]
self.assertIn("/tmp:exec,size=1g,mode=1777", argv)
def test_active_exec_not_reaped(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.pool._container_running", return_value=True), patch("core.sandbox.pool.subprocess.run") as run:
pool = SandboxPool(Path(td) / "users", idle_ttl=1)
uid = uuid4(); pool._last_active[uid] = int(time.time()) - 10
pool.exec_started(uid)
self.assertEqual(pool.reap_idle(), [])
run.assert_not_called()
def test_scan_failure_does_not_block_idle_removal(self):
with tempfile.TemporaryDirectory() as td, \
patch("core.sandbox.package_scans.scan_and_persist", side_effect=RuntimeError("db down")), \
patch("core.sandbox.pool.subprocess.run", return_value=MagicMock(returncode=0)):
pool = SandboxPool(Path(td) / "users", idle_ttl=1)
uid = uuid4(); pool._last_active[uid] = int(time.time()) - 10
self.assertEqual(pool.reap_idle(), [f"zcbot-sandbox-{uid}"])
def test_finalizer_helper_swallows_scanner_error(self):
with tempfile.TemporaryDirectory() as td, patch("core.sandbox.package_scans.scan_and_persist", side_effect=RuntimeError("db down")):
pool = SandboxPool(Path(td) / "users")
pool._scan_before_remove("box", uuid4(), "foreground")
class PackageScannerTests(unittest.TestCase):
def test_empty_scan_does_not_touch_database(self):
persist_scan(container_session_id="empty", user_id=uuid4(), execution_kind="foreground",
result={"packages": [], "image_digest": "sha256:x", "python_version": "3.12", "total_installed_bytes": 0})
def test_added_override_bytes_and_symlink_escape(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td) / "lib"; site = root / "python3.12" / "site-packages"; site.mkdir(parents=True)
pkg = site / "demo.py"; pkg.write_text("abc", encoding="utf-8")
dist = site / "Demo-2.dist-info"; dist.mkdir()
(dist / "METADATA").write_text("Name: Demo\nVersion: 2\n", encoding="utf-8")
(dist / "REQUESTED").write_text("", encoding="utf-8")
(dist / "RECORD").write_text("demo.py,,3\n", encoding="utf-8")
rows = scan_packages(root, {"demo": "1"})
self.assertEqual(rows[0]["change"], "override")
self.assertEqual(rows[0]["installed_bytes"], 3)
self.assertTrue(rows[0]["direct_requested"])
self.assertEqual(scan_packages(root, {"demo": "2"})[0]["change"], "reinstall")
self.assertEqual(scan_packages(root, {})[0]["change"], "added")
def test_metadata_symlink_is_ignored(self):
with tempfile.TemporaryDirectory() as td:
root = Path(td) / "lib"; site = root / "python3.12" / "site-packages"; site.mkdir(parents=True)
outside = Path(td) / "outside"; outside.write_text("Name: Bad\nVersion: 1\n")
dist = site / "Bad.dist-info"; dist.mkdir()
try:
(dist / "METADATA").symlink_to(outside)
except OSError:
self.skipTest("symlink unavailable")
self.assertEqual(scan_packages(root, {}), [])
class ProcQueueTests(unittest.TestCase):
def test_queued_status_and_cancel(self):
with tempfile.TemporaryDirectory() as td:
d = Path(td) / "p"; d.mkdir()
meta = {"proc_id": "p1", "backend": "docker", "state": "queued"}
procs.write_meta(d, meta)
self.assertEqual(procs.status_of(meta, d)[0], "queued")
self.assertEqual(procs.kill_proc(meta, d), "已取消排队")
self.assertEqual(procs.status_of(procs.read_meta(d), d), ("finished", 137))
def test_start_queued_respects_capacity_then_runs(self):
with tempfile.TemporaryDirectory() as td:
d = Path(td) / "p"; d.mkdir(); (d / "runner.sh").write_text("true")
uid = uuid4()
meta = {"proc_id": "p1", "task_id": "t1", "backend": "docker", "state": "queued",
"user_id": str(uid), "cwd": "/workspace/demo"}
procs.write_meta(d, meta)
pool = MagicMock(); pool.capacity.try_acquire.return_value = None
self.assertFalse(procs.start_queued_docker(meta, d, pool))
pool.capacity.try_acquire.return_value = "bg-p1"
pool.run_proc_container.return_value = "zcbot-proc-p1"
with patch("core.procs.subprocess.run", return_value=MagicMock(returncode=0, stderr="")):
self.assertTrue(procs.start_queued_docker(meta, d, pool))
saved = procs.read_meta(d)
self.assertEqual(saved["state"], "running")
self.assertEqual(saved["capacity_lease_id"], "bg-p1")
class AdminAggregationTests(unittest.TestCase):
def test_package_sessions_are_aggregated_by_name_version(self):
scans = [
SimpleNamespace(container_session_id="s1", user_id=uuid4(), execution_kind="foreground",
packages=[{"name":"Demo","version":"1","direct_requested":True,"installed_bytes":100,"change":"added","base_version":None}],
finished_at=datetime.now(timezone.utc)),
SimpleNamespace(container_session_id="s2", user_id=uuid4(), execution_kind="background",
packages=[{"name":"Demo","version":"1","direct_requested":False,"installed_bytes":300,"change":"added","base_version":None}],
finished_at=datetime.now(timezone.utc)),
]
session = MagicMock(); session.execute.return_value.scalars.return_value.all.return_value = scans
row = _sandbox_package_stats(session, None)["rows"][0]
self.assertEqual(row["session_count"], 2)
self.assertEqual(row["user_count"], 2)
self.assertEqual(row["foreground_sessions"], 1)
self.assertEqual(row["background_sessions"], 1)
self.assertEqual(row["direct_sessions"], 1)
self.assertEqual(row["average_installed_bytes"], 200)
if __name__ == "__main__":
unittest.main()

View File

@ -111,6 +111,8 @@ class AuthGateTests(unittest.TestCase):
("POST", "/v1/tasks"),
("POST", "/v1/asr/transcribe"),
("GET", "/v1/admin/overview"),
("GET", "/v1/admin/sandbox/capacity"),
("GET", "/v1/admin/sandbox/packages"),
("GET", "/v1/admin/software-nodes"),
("GET", "/v1/software-jobs"),
("POST", "/v1/software-jobs/00000000-0000-0000-0000-000000000000/cancel"),

View File

@ -90,18 +90,28 @@ class CheckProcessTool(Tool):
tail = procs.tail_log(d, max_bytes=tail_bytes)
# docker 模式:已结束但专用容器还在空转 → 顺手回收(幂等,sweep 也会兜底)
if st != "running" and meta.get("backend") == "docker":
if st not in {"running", "queued"} and meta.get("backend") == "docker":
name = str(meta.get("container") or "")
if name:
try:
procs._scan_proc_container(meta, d, name)
subprocess.run(["docker", "rm", "-f", name],
capture_output=True, timeout=30)
except (OSError, subprocess.TimeoutExpired):
pass
try:
from core.sandbox import get_pool
pool = get_pool()
if pool is not None:
pool.capacity.release(meta.get("capacity_lease_id"))
except Exception:
pass
header = f"[check_process] {proc_id} · {meta.get('kind')} · {meta.get('command', '')[:150]}"
if st == "running":
elapsed = procs._fmt_elapsed(time.time() - float(meta.get("created_ts") or time.time()))
if st == "queued":
body = "状态: queued(等待 Sandbox 执行容量;排队时间不计运行超时)\n提示:可用 action='kill' 取消排队。"
elif st == "running":
elapsed = procs._fmt_elapsed(time.time() - float(meta.get("started_ts") or meta.get("created_ts") or time.time()))
body = (
f"状态: running(已运行 {elapsed},上限 {meta.get('timeout_s')}s)\n"
f"--- 日志尾部 ---\n{tail}\n"

View File

@ -24,7 +24,7 @@ from sqlalchemy import func, select, update
from core.storage import session_scope
from core.storage import usage_report
from core.storage.models import Task, UsageEvent, User, UserDiskUsage
from core.storage.models import SandboxPackageScan, Task, UsageEvent, User, UserDiskUsage
from .broker import broker
@ -51,6 +51,37 @@ def _range_cutoff(now: datetime, range_key: str):
return None # all / 未知 → 不筛
def _sandbox_package_stats(s: Any, cutoff: datetime | None) -> dict:
stmt = select(SandboxPackageScan)
if cutoff is not None:
stmt = stmt.where(SandboxPackageScan.finished_at >= cutoff)
scans = s.execute(stmt).scalars().all()
grouped: dict[tuple[str, str], dict] = {}
for scan in scans:
for package in scan.packages:
key = (str(package.get("name") or ""), str(package.get("version") or ""))
row = grouped.setdefault(key, {"name": key[0], "version": key[1], "sessions": set(),
"users": set(), "foreground_sessions": 0, "background_sessions": 0,
"direct_sessions": 0, "total_installed_bytes": 0, "latest_at": None,
"base_versions": set(), "changes": set()})
row["sessions"].add(scan.container_session_id); row["users"].add(str(scan.user_id))
row[f"{scan.execution_kind}_sessions"] += 1
row["direct_sessions"] += int(bool(package.get("direct_requested")))
row["total_installed_bytes"] += int(package.get("installed_bytes") or 0)
row["base_versions"].add(package.get("base_version")); row["changes"].add(package.get("change"))
if row["latest_at"] is None or scan.finished_at > row["latest_at"]:
row["latest_at"] = scan.finished_at
rows = []
for row in grouped.values():
sessions = len(row.pop("sessions")); row["session_count"] = sessions
row["user_count"] = len(row.pop("users")); row["average_installed_bytes"] = row["total_installed_bytes"] // max(1, sessions)
row["base_versions"] = sorted(str(x) for x in row["base_versions"] if x is not None)
row["changes"] = sorted(row["changes"]); row["latest_at"] = row["latest_at"].isoformat()
rows.append(row)
rows.sort(key=lambda x: (-x["session_count"], x["name"].lower(), x["version"]))
return {"rows": rows, "scan_sessions": len(scans)}
def _runtime_section(app: FastAPI) -> dict:
"""实时运行态:从 app.state 读内存,无 DB。
@ -281,6 +312,22 @@ def register_admin_routes(app: FastAPI, require_admin) -> None:
"usage": usage_report.usage_overview(s, cutoff_7d),
}
@app.get("/v1/admin/sandbox/capacity", tags=["admin"])
def admin_sandbox_capacity(user_id: UUID = Depends(require_admin)):
"""宿主共享实时容量;只读文件/Docker 状态,不写 DB。"""
pool = getattr(app.state, "sandbox_pool", None)
if pool is None:
return {"enabled": False}
return {"enabled": True, **pool.runtime_snapshot()}
@app.get("/v1/admin/sandbox/packages", tags=["admin"])
def admin_sandbox_packages(range: str = "30d", user_id: UUID = Depends(require_admin)):
now = datetime.now(timezone.utc)
with session_scope() as s:
result = _sandbox_package_stats(s, _range_cutoff(now, range))
result["range"] = range
return result
@app.get("/v1/admin/external-system-definitions", tags=["admin"])
def admin_external_system_definitions(user_id: UUID = Depends(require_admin)):
from core.external_systems.service import list_external_system_definitions

View File

@ -248,7 +248,7 @@ def start_proc_sweeper(cfg: dict) -> asyncio.Task:
已结束容器/过期目录收掉
"""
from core.agent_builder import resolve_workspace
from core.procs import sweep as _procs_sweep
from core.procs import dispatch_queued, sweep as _procs_sweep
users_base = resolve_workspace(None, cfg) / "users"
async def _proc_sweeper() -> None:
@ -258,14 +258,19 @@ def start_proc_sweeper(cfg: dict) -> asyncio.Task:
stats = await loop.run_in_executor(
None, _procs_sweep, users_base
)
from core.sandbox import get_pool
pool = get_pool()
started = await loop.run_in_executor(None, dispatch_queued, users_base, pool) if pool is not None else 0
if stats["removed_dirs"] or stats["reaped_containers"]:
print(f"[proc-sweep] dirs={stats['removed_dirs']} "
f"containers={stats['reaped_containers']}")
if started:
print(f"[proc-sweep] started queued={started}")
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[proc-sweep] error: {type(e).__name__}: {e}")
await asyncio.sleep(3600)
await asyncio.sleep(30)
return asyncio.create_task(_proc_sweeper(), name="proc-sweeper")

View File

@ -14,6 +14,7 @@ const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]];
const SECTIONS = [
["s-overview", "总览"], ["s-usage", "用量趋势"], ["s-models", "按模型"],
["s-users", "各用户"], ["s-storage", "存储"],
["s-sandbox-capacity", "Sandbox 容量"], ["s-sandbox-packages", "Sandbox 依赖"],
["s-windows-node", "Windows Node"],
["s-external", "外部系统"],
["s-toolfail", "工具失败"],
@ -50,6 +51,7 @@ let timer = null;
let modelRange = "7d", modelSort = "cost";
let userRange = "7d", userSort = "cost", userPage = 0;
let storagePage = 0;
let packageRange = "30d";
let tiersData = null; // {tiers, default_tier, catalog};加载一次(改档位 / 看图例用)
let externalDefinitions = [];
let externalUsers = [];
@ -1052,6 +1054,42 @@ function renderMetrics(d) {
renderOpsSummary();
}
function renderSandboxCapacity(d) {
if (!d.enabled) {
$("s-sandbox-capacity").innerHTML = `<div class="card"><h2>Sandbox 实时容量</h2><p class="muted">Docker Sandbox 未启用。</p></div>`;
return;
}
const lim = d.limits || {};
const users = Object.entries(d.per_user || {}).sort((a,b) => b[1]-a[1])
.map(([u,n]) => `${escapeHtml(u.slice(0,8))}: ${n}`).join(" · ") || "无";
$("s-sandbox-capacity").innerHTML = `<div class="card"><h2>Sandbox 实时容量</h2>`
+ `<div class="grid"><div><b>${d.foreground_running || 0}</b><span>前台执行</span></div>`
+ `<div><b>${d.foreground_queued || 0}</b><span>前台排队</span></div>`
+ `<div><b>${d.background_running || 0}</b><span>后台执行</span></div>`
+ `<div><b>${d.background_queued || 0}</b><span>后台排队</span></div>`
+ `<div><b>${d.admit_available || 0}</b><span>当前可放行</span></div>`
+ `<div><b>${d.active_sandbox_containers || 0}</b><span>活跃普通容器</span></div>`
+ `<div><b>${d.idle_reap_candidates || 0}</b><span>空闲待回收</span></div>`
+ `<div><b>${d.running_proc_containers || 0}</b><span>运行中 proc</span></div></div>`
+ `<p class="muted">硬上限 ${lim.active || 0} · 后台 ${lim.background || 0} · 单用户 ${lim.per_user || 0}`
+ ` · MemAvailable ${humanSize(d.mem_available_bytes || 0)} · CPU load ${(d.cpu_load || []).join(" / ") || "—"}`
+ `${d.memory_paused ? " · 内存压力暂停放行" : ""}</p><p class="muted">单用户占用:${users}</p></div>`;
}
function renderSandboxPackages(d) {
const rows = d.rows || [];
const opts = RANGE_OPTS.map(([v,l]) => `<option value="${v}" ${v===packageRange?"selected":""}>${l}</option>`).join("");
const body = rows.map(r => `<tr><td>${escapeHtml(r.name)}</td><td>${escapeHtml(r.version)}</td>`
+ `<td>${r.session_count}</td><td>${r.user_count}</td><td>${r.foreground_sessions}/${r.background_sessions}</td>`
+ `<td>${r.direct_sessions}</td><td>${humanSize(r.average_installed_bytes)}</td><td>${humanSize(r.total_installed_bytes)}</td>`
+ `<td>${escapeHtml((r.changes || []).join("/"))}<br/><span class="muted">${fmtTimeAgo(r.latest_at)}</span></td></tr>`).join("");
$("s-sandbox-packages").innerHTML = `<div class="card"><div class="card-head"><h2>Sandbox 依赖统计</h2>`
+ `<select id="sandbox-package-range">${opts}</select></div><p class="muted">只分析临时安装,不自动修改基础镜像;共 ${d.scan_sessions || 0} 个安装会话。</p>`
+ `<div class="table-wrap"><table><thead><tr><th>包名</th><th>版本</th><th>安装会话</th><th>独立用户</th><th>前台/后台</th><th>直接安装会话</th><th>平均占用</th><th>累计占用</th><th>基础镜像状态 / 最近</th></tr></thead><tbody>${body || `<tr><td colspan="9">暂无临时依赖记录</td></tr>`}</tbody></table></div></div>`;
const select = $("sandbox-package-range");
if (select) select.onchange = () => { packageRange = select.value; loadSandboxPackages(); };
}
function showMsg(html) {
$("main").innerHTML = `<div class="msg">${html}</div>`; // 清骨架,错误态独占
}
@ -1199,6 +1237,13 @@ async function loadSoftwareNodes() {
} catch (e) { /* overview 统一处理鉴权 */ }
}
async function loadSandboxCapacity() {
try { renderSandboxCapacity(await apiGet("/v1/admin/sandbox/capacity")); } catch (e) { /* overview 统一处理 */ }
}
async function loadSandboxPackages() {
try { renderSandboxPackages(await apiGet(`/v1/admin/sandbox/packages?range=${packageRange}`)); } catch (e) { /* overview 统一处理 */ }
}
// overview(固定指标)轮询:拿到后建骨架、渲指标,再顺手刷新四个独立表(保持各自状态)
async function refresh() {
try {
@ -1212,6 +1257,8 @@ async function refresh() {
loadSoftwareNodes();
loadExternalDefinitions();
loadToolFailures();
loadSandboxCapacity();
loadSandboxPackages();
} catch (e) {
if (e.code !== "auth") showMsg(`加载失败:${escapeHtml(e.message || String(e))}`);
}