zcbot/RUN.md

483 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 运行手册
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`。
最后更新:2026-05-22(dev SPA 加 iframe embed 模式 — `?embed=1&parent_origin=...`,对接见 `EMBED.md`)
---
## 环境
- **Python**:虚拟环境 `.venv/`,所有依赖装在里面。一律用 `.venv/Scripts/python.exe ...`(Windows)/`.venv/bin/python ...`(Unix),不要全局 `python`(litellm/python-pptx 等会 ModuleNotFoundError)。
- **配置文件 `.env`**(项目根,git 忽略,litellm 自动加载):
```
DEEPSEEK_API_KEY=sk-...
# 用 GLM 的话再加一条;国际站 z.ai 用 ZAI_API_KEY,国内站 bigmodel.cn 用 ZHIPUAI_API_KEY(对应 config/models/glm.yaml 的 api_key_env 字段)
ZHIPUAI_API_KEY=...
# 豆包(火山方舟)图像/视频生成:可选。设了同时挂 seedream tool(0.22 元/张)与 seedance tool
# (Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s);未设两个 tool 都不出现
ARK_API_KEY=...
# documents skill(内部知识库 document_search API):可选。设了 documents skill 才能用,未设调用立即抛 RuntimeError
DOCUMENT_SEARCH_API_KEY=...
# 可选:覆盖默认 base_url(默认 https://ai.ctc-zc.com:8100/api)
# DOCUMENT_SEARCH_URL=https://ai.ctc-zc.com:8100/api
ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot
# main.py web 必填(probe/db/user 不验)
PLATFORM_KEY=<≥16 字符随机串,platform 机器对机器入口校验>
JWT_SECRET=<≥32 字符随机串,HS256 签 session token;泄漏 = 任意伪造>
# 可选:覆盖默认 7d JWT TTL
# ZCBOT_JWT_TTL_SECONDS=604800
# 可选:设了之后登录页右下角"+ 管理员添加用户"入口才工作(未设 → 接口返 503)
# ZCBOT_ADMIN_TOKEN=<≥32 字符随机串,管理员发用户共享口令>
```
> litellm 在 import 时副作用加载 .env;入口走 `main.py`,`.env` 自动生效。直跑 `python -c "from core.storage import ..."` 不经 litellm 链路时记得自己 `import litellm` 触发,或手动 `export ZCBOT_DB_URL=...`。
- **依赖**:`pip install -r requirements.txt`(已在 `.venv` 里;含 `bcrypt`)。
- **PG**:`ZCBOT_DB_URL` 必填。本地 docker compose / 远端 dev / 生产任选;未设置时启动清晰报错,不引导 docker(§7.4)。
- **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。
- **用户管理**(`users.email/password_hash`,0005 UNIQUE(email)):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。改密 / 改邮箱手动 SQL 或先 DELETE 再 add。
---
## 一次性初始化
```bash
# 1) 装依赖(若 .venv 不在)
python -m venv .venv
.venv/Scripts/python.exe -m pip install -r requirements.txt
# 2) 准备 .env(见上)
# 3) DB schema 上车
.venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe main.py db current # 应输出 0007 (head)
```
---
## 日常命令
> 入口 `main.py {web, db, probe, user}`。所有 task / 消息 / 文件交互走 `main.py web` 起服务后浏览器(dev SPA)或 platform 端 / curl 调 `/v1/*`。
### Web 服务
```bash
# 默认 127.0.0.1:8765;dev SPA 在 /,Swagger UI 在 /docs
.venv/Scripts/python.exe main.py web
.venv/Scripts/python.exe main.py web --port 9000
.venv/Scripts/python.exe main.py web --reload # 文件改动自动重启
```
### DB / probe / user
```bash
# 模型能力对账(费 token)
.venv/Scripts/python.exe main.py probe --model deepseek_v4.flash
.venv/Scripts/python.exe main.py probe --model glm.pro # 智谱 GLM-5.1(走 litellm zai provider + 国内站 bigmodel.cn)
# DB migration
.venv/Scripts/python.exe main.py db upgrade head
.venv/Scripts/python.exe main.py db downgrade -1
.venv/Scripts/python.exe main.py db current
# 发用户(两条路径,任选其一)
# a) CLI:
.venv/Scripts/python.exe main.py user add --email alice@example.com --password "atLeast6"
# → [ok] user added email=alice@example.com user_id=<uuid>
# b) 登录页右下角"+ 管理员添加用户":需先在 .env 里设 `ZCBOT_ADMIN_TOKEN`,
# 弹窗输入 email/密码/管理员口令,POST /v1/auth/admin/create_user 落库。
# 没设 env → 接口直接返 503,UI 入口会报"admin create_user disabled"。
# 可选:把已有 user_id(platform_key 入口创的)接到邮箱密码路径
.venv/Scripts/python.exe main.py user add --email bob@x.com --password "s3cret" --user-id <UUID>
# 撤用户:先清 tasks(messages CASCADE)再 DELETE user
# psql> DELETE FROM tasks WHERE user_id=(SELECT user_id FROM users WHERE email='alice@example.com');
# psql> DELETE FROM users WHERE email='alice@example.com';
```
### Auth + curl 用 token 调 /v1/*
两条 login 路径,签**同款 JWT**。所有 `/v1/tasks*``Authorization: Bearer <jwt>`
```bash
# 路径 1:邮箱密码(dev SPA / 同事试用 — 推荐)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login_password \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"atLeast6"}'
# 路径 2:platform_key + 指定 user_id(机器对机器)
curl --noproxy '*' -s -X POST http://127.0.0.1:8765/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"user_id":"<UUID>","platform_key":"<value of $PLATFORM_KEY>"}'
# 调 /v1/*
TOKEN="eyJ..."
curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/tasks
```
**dev SPA**:打开 `http://127.0.0.1:8765/`(自动 302 → `/static/dev.html`),登录页两 tab(默认"邮箱密码",备用"UUID + PLATFORM_KEY",last-used 持久化 LS)进入 3 栏(task / chat / files)。给同事试用:`main.py user add` 发用户,**不用重启** web(每次 login 都查 DB),把 URL + 邮箱密码分别发给同事。
**iframe 嵌入**(platform 主页内嵌):URL 加 `?embed=1&parent_origin=<父页面 origin>`,触发 embed 模式 —— 藏左上 brand / 退出按钮,登录页不显示,新建任务挪到任务面板;父页面通过 `postMessage` 协议推 JWT(`zcbot-ready` / `zcbot-token` / `zcbot-401`)。完整对接手册见 `EMBED.md`(URL 参数 / 协议 / 后端 SSO 示例 / 父端前端示例 / 安全 / 故障兜底)。
### 路由表
全 JSON,CORS `allow_origins=["*"]`;详细 schema 见 `/docs`
| 方法 + 路径 | 用途 | Auth |
|---|---|---|
| `GET /healthz` | `{"status":"ok"}` | 豁免 |
| `GET /` | 302 → `/static/dev.html` dev SPA | 豁免 |
| `GET /docs` `/openapi.json` | Swagger UI / OpenAPI schema | 豁免 |
| `GET /static/*` | dev.html 等静态文件 | 豁免 |
| `POST /v1/auth/login` | platform 机器对机器;body `{user_id, platform_key}``{token,expires_at,user_id,ttl_seconds}` | 豁免 |
| `POST /v1/auth/login_password` | dev SPA 邮箱密码;body `{email, password}``{token,...,email,...}`;邮箱不存在 / 密码错 / 未设密码统一 403 | 豁免 |
| `POST /v1/tasks` | 创建 task,body `{name(req), working_dir?, description?, skill?}` | 必填 |
| `GET /v1/tasks?page=&page_size=&status=&skill=&working_dir=&q=&ordering=` | 列任务,默认 `-created_at`;响应 `{page, page_size, count, results}`;`ordering` DRF 风格逗号分隔 `-field` 倒序,allowlist created_at/updated_at/name/status | 必填 |
| `GET /v1/tasks/{id}` | 单 task meta + `n_messages`;跨 user → 404 | 必填 |
| `PATCH /v1/tasks/{id}` | `{status?,description?,name?,skill?}`;active 不让从 web 切回 | 必填 |
| `DELETE /v1/tasks/{id}` | 硬删 DB 行(messages CASCADE);若 working_dir 已无其他 task 引用且 FS 目录为空 → 顺手 rmdir 清孤儿(非空 / 外部 --working-dir 静默跳过) | 必填 |
| `GET /v1/folders` | 列当前 user 工作目录 + n_tasks + last_used | 必填 |
| `GET /v1/tasks/{id}/messages` | LiteLLM payload 透传 | 必填 |
| `POST /v1/tasks/{id}/messages` | `{content, image_model?=""}` 发消息;返 `{events_url}`;**`run_status` 是 running/cancelling → 409**(单活 run;error 起新 run 时清);`image_model` 是 `config/media/doubao.yaml` image 段的 variant key(空 → 沿用 yaml 第一个),仅本 run 装配 SeedreamTool 时使用,不入 DB;UI 应 disable send 直到 SSE `done` | 必填 |
| `GET /v1/tasks/{id}/events` | SSE 流(`event: <type>` + `data: <json>`);订阅 task 当前活动 | 必填 |
| `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 | 必填 |
| `POST /v1/tasks/{id}/clear` | 清空当前 task 全部 messages + reset `tasks.tokens_prompt/completion/cost_cny` 三列累计 + `run_status='idle'`;`usage_events`(账单记账)**不动**,只 `message_id` 列变 NULL;run 活跃中(running/cancelling)→ 409(先 cancel);FS 文件保留 | 必填 |
| `POST /v1/tasks/{id}/optimize_prompt` | body `{text(req, ≤4000), image_model?=""}`;同步调当前 task model 润色草稿,返 `{optimized, model_profile, tokens_in, tokens_out, cost_cny}`;**不**写 messages、**不**累计 task 三列(顶栏数字不污染),只在 `usage_events` 写一行 `kind="prompt_optimize"`(对账可见);不与主对话 run 互斥(允许 streaming 中并行润色) | 必填 |
| `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 |
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
| `POST /v1/files/delete` | `{path, recursive?=false}`;`recursive=false` 文件或空目录(非空 → 400);`recursive=true` `shutil.rmtree` —— 顶层目录被 task 引用 → 409(先 DELETE task);空目录两种模式都可删,task.working_dir 字段不动,下次 build_agent 按需 mkdir 重建 | 必填 |
| `POST /v1/files/rename` | `{path, new_name}`;sibling 已存在 → 409;**path 顶层目录** → 同事务 UPDATE tasks.working_dir + FOR UPDATE 锁;有 running/cancelling → 409;check_no_subtask 防嵌套 → 409 | 必填 |
| `GET /v1/tasks/{id}/export` | 对话导出 .docx | 必填 |
| `GET /v1/models` | 列 chat LLM 模型清单(扫 `config/models/*.yaml`),前端顶栏切换 / 新建对话框下拉用 | 必填 |
| `GET /v1/image_models` | 列图像生成 variant 清单(扫 `config/media/doubao.yaml` image 段),前端"生图"下拉用;yaml 无 image variant → 空列表 → UI 隐藏下拉 | 必填 |
**SSE 事件**(每帧 `event: <type>` + `data: <JSON>`):`run_start{}` → `llm_start{}``text{delta}` / `tool_call{name,args,args_preview}` / `tool_result{name,preview,truncated}``llm_end{prompt_tokens,completion_tokens}``done{}`;cancel 走 `cancelled{}` 后随 `done{}` 收流;异常走 `error{msg}`。30s 无 event 服务端发 `: ping` 心跳。nginx 反代记得关 buffering(响应头已带 `X-Accel-Buffering: no` 默认起效)。
**SSE 客户端注意**:浏览器原生 `EventSource` 不支持自定义 header,无法塞 Bearer token。要么 `fetch + ReadableStream` 自解 SSE 帧(dev.html 走的就是这条),要么后端日后加 `?token=...` query(目前不支持,避免 token 进 access log)。
---
## 部署(Ubuntu / systemd)
> 单机最小拓扑:`/opt/zcbot/`(代码 + `.venv` + `.env`)+ systemd unit。按需改路径 / user / port。
### 一次性
```bash
sudo useradd -r -s /sbin/nologin -d /opt/zcbot zcbot # 跑服务的非 root 用户
sudo chown -R zcbot:zcbot /opt/zcbot
# 把 .env 权限收紧(含 JWT_SECRET / PLATFORM_KEY)
sudo chmod 600 /opt/zcbot/.env
sudo chown zcbot:zcbot /opt/zcbot/.env
```
### unit 文件 `/etc/systemd/system/zcbot.service`
```ini
[Unit]
Description=zcbot web (FastAPI/uvicorn)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=zcbot
WorkingDirectory=/opt/zcbot
# 显式让 systemd 装载 .env(KEY=value 行,不展开 ${...},不留 shell 引号)
EnvironmentFile=/opt/zcbot/.env
ExecStart=/opt/zcbot/.venv/bin/python main.py web --host 0.0.0.0 --port 8765
Restart=on-failure
RestartSec=2
KillSignal=SIGTERM
# uvicorn graceful shutdown 会等 in-flight 请求(含 SSE 长连接);
# 10s 后 systemd 兜底 SIGKILL,避免 SSE 拖住 restart 卡死
TimeoutStopSec=10
KillMode=mixed
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
```
启用 + 日常:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now zcbot
sudo systemctl status zcbot | head
sudo journalctl -u zcbot -f # 实时日志
sudo systemctl restart zcbot # 重启(REST 抖动 ~2s,SSE 连接断)
sudo systemctl stop zcbot
```
> **不要再用 `kill -HUP`**:uvicorn 不响应 SIGHUP(没装 handler,落 Python 默认),也不会 reload 代码。Ubuntu 上要么 `systemctl restart`,要么用下面 `--reload` 自动模式。
### 无感更新(对 SSE 也尽量不抖)
zcbot 现在 5 人级 + SSE 长连接,**严格"零中断"**(蓝绿 + nginx + SSE 客户端 reconnect 设计)代价高,不值得。有性价比的两挡:
**A. 简易档:`--reload`**(推荐当前规模)
ExecStart 加 `--reload`,`git pull` 后 uvicorn 监听到文件变动自动重起子进程,REST 抖动 < 1s。**代价**:SSE 连接被切断,**dev.html 自动重连 3 (1s/2s/4s 退避)**;若新进程已被启动 reaper `run_status=error`,重连立即收 done,卡片末尾追加红色"连接已断开,请重发"。期间 LLM 吐的 delta 丢失(broker 不持久化 event,接受)。3 次仍失败 同上提示,用户重发即可
```ini
ExecStart=/opt/zcbot/.venv/bin/python main.py web --host 0.0.0.0 --port 8765 --reload
```
`sudo systemctl restart zcbot` 一次生效之后** `git pull` 即可**,不用再 restart; unit 文件本身才需 daemon-reload + restart
**B. 真无感档:nginx + 蓝绿双实例**(将来流量上来再上)
两个 systemd 实例 `zcbot@blue` / `zcbot@green`(模板 unit,`--port 8765` / `--port 8766`),nginx upstream 在中间切流程:
1. 部署到空闲实例(假设 green):`sudo systemctl restart zcbot@green`
2. `curl 127.0.0.1:8766/healthz` 验新版起来
3. nginx upstream 指向 green,`nginx -s reload` **新 REST 走 green,旧 SSE 还连在 blue 不断**
4. blue SSE 自然清空(`ss -tnp | grep :8765` 为空)再关 blue
**zcbot 端额外要做的事**:消息 broker 当前在 task 进程内(`web/broker.py`),蓝绿期间同 task 不同进程会丢事件nginx 侧用 `hash $arg_task_id consistent` 保同 task 落同实例可以缓解, task 创建分布是另一回事要做这条得先把 broker 改成 Redis pub/sub10 人内**不推**,留到真有需要再上
### 部署 SOP(目前推荐:方案 A `--reload`)
```bash
# 在服务器上
cd /opt/zcbot
sudo -u zcbot git pull --ff-only # 拉新代码
sudo -u zcbot .venv/bin/python -m pip install -r requirements.txt # 依赖有变才需要
sudo -u zcbot .venv/bin/python main.py db upgrade head # migration 有新版才需要
# 这一步通常不用做:--reload 监听到 .py 文件变动会自动重起
# 但 .env / unit 改了 → 手动:
# sudo systemctl restart zcbot
sudo systemctl status zcbot | head
sudo journalctl -u zcbot -n 50 # 看新进程起没起干净
```
---
## Sandbox(Stage C,Ubuntu)
> 为外部用户开放前必须完成。当前 dogfood + 信任同事白名单阶段可跳过 ── 默 backend = host,
> `shell` / `run_python` 仍走 subprocess(未隔离)。Step 3 已接入 DockerExecutor:
> `ZCBOT_SANDBOX_BACKEND=docker` 切容器执行;`host`(默)保留本地 Windows / 同事 dogfood。
>
> 启用 docker backend 的前置条件:
> 1. 部署机有 docker daemon,zcbot 用户在 `docker` group
> 2. `zcbot-sandbox:latest` 镜像已 build(`HOST_UID/GID` 对齐)
> 3. `.env` 至少有 `ZCBOT_PG_IPS=<PG实际IP>`(§7.5 #1 PG 单独 block 一遍)
> 4. lifespan 启动失败会 fail-fast(`RuntimeError: sandbox init failed`),不静默退到 host
### 镜像构建
容器内 uid/gid host `zcbot` 用户必须对齐(bind mount host owner;错配导致 EACCES):
```bash
# 1) 确保 zcbot 用户存在,uid 拿出来
id -u zcbot # 期望:整数,后面用作 HOST_UID
id -g zcbot # 期望:整数
# 2) 构建镜像(build context = repo 根)
cd /opt/zcbot
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 .
# 3) 创建 sandbox 网络(--internal,默认无 outbound)
sudo -u zcbot docker network create --internal zcbot-sandbox-net
# 或 SandboxPool.setup_pool() 自动 ensure
```
### Sandbox 相关 env(.env 加)
```
# Backend 选择(默 host):
# host = shell/run_python 走 host subprocess(本地 Windows / dogfood)
# docker = shell/run_python 走 per-user 容器 docker exec(部署机 / 外部用户)
# ZCBOT_SANDBOX_BACKEND=docker
# 容器内 exec 用户(默 1000:1000;Dockerfile 的 HOST_UID/HOST_GID build-arg 同步对齐)
# ZCBOT_SANDBOX_EXEC_USER=1000:1000
# 容器镜像 tag(默 zcbot-sandbox:latest)
# ZCBOT_SANDBOX_IMAGE=zcbot-sandbox:latest
# 容器 runtime(切 gVisor 用 runsc,Firecracker 用 kata;默 runc)
# ZCBOT_SANDBOX_RUNTIME=
# 空闲多少秒回收(默 300)
# ZCBOT_SANDBOX_IDLE_TTL=300
# PG 实际 IP,逗号分隔。defense-in-depth ── 即便落内网三段(§7.5 #1),
# init.sh 再加一遍 DROP 规则。生产部署必填。
ZCBOT_PG_IPS=10.1.2.3,10.1.2.4
```
### 验证
Step 3 之后,推荐用集成验证(web docker backend + dev SPA `shell` / `run_python` 消息):
```bash
# 启动 web 时切 docker backend(.env 已设 PG_IPS / SANDBOX_BACKEND=docker)
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
```
也可直接起一个测试容器单验 hardening(不依赖 web 进程):
```bash
USER_ID=00000000-0000-0000-0000-000000000001
sudo -u zcbot docker run -d \
--name zcbot-sandbox-$USER_ID \
--label zcbot.product=sandbox \
--label zcbot.user_id=$USER_ID \
--network zcbot-sandbox-net \
--read-only --tmpfs /tmp:exec,size=512m,mode=1777 \
--cap-drop=ALL --cap-add=NET_ADMIN \
--security-opt=no-new-privileges \
--pids-limit=256 --memory=2g --cpus=1.0 \
-v /opt/zcbot/workspace/users/$USER_ID:/workspace \
-e ZCBOT_PG_IPS=10.1.2.3 \
zcbot-sandbox:latest
# 看 iptables 规则确实 apply 了(应 6+1 条 DROP)
sudo -u zcbot docker exec zcbot-sandbox-$USER_ID iptables -L OUTPUT -n --line-numbers
# 看 non-root 用户(uid 应 = host zcbot uid)
sudo -u zcbot docker exec --user $(id -u zcbot):$(id -g zcbot) \
zcbot-sandbox-$USER_ID id
# 看 rootfs read-only(应报 Read-only file system)
sudo -u zcbot docker exec --user $(id -u zcbot):$(id -g zcbot) \
zcbot-sandbox-$USER_ID touch /badtest
# 销毁
sudo -u zcbot docker rm -f zcbot-sandbox-$USER_ID
```
Step 4 引入 egress proxy ,完整 5 条红队用例(metadata / loopback / user / nohup
残留 / allowlist 403) `tests/test_sandbox_redteam.py` 自动化跑
### 部署前置对账
`ZCBOT_SANDBOX_BACKEND=docker` 之前跑一次:
```bash
sudo -u zcbot .venv/bin/python main.py sandbox check
```
输出形如 `[ok] / [warn] / [err]` × 5 + 汇总 `N/5 passed`,exit code 0=可启动 / 1= err
要修5 项对应: docker daemon 可达 `zcbot-sandbox:latest` 镜像存在
`zcbot-sandbox-net` network 存在(缺也能跑,lifespan 自动 ensure)④ 镜像内 zcbot
uid host uid 对齐(错配 exec `/workspace` EACCES)⑤ `workspace/users/`
所在 fs 类型可 quota
lifespan 启动时同样会打第 项的 WARN stdout(`[startup] [warn] fs quota ...`),
应用层周期扫描仍生效;**仅外部用户开放前必须把 升级到 OS quota**。
### 配额硬化(§7.5 #4,外部开放前必做)
应用层磁盘配额能挡常规超额,**但扫描间隙打满共享 fs 拖死同节点**这条硬要 OS
quota。`sandbox check` 项会探测当前 fs 状态:
| 探测结果 | 含义 | 处理 |
|---|---|---|
| `fs quota: xfs with prjquota on ...` | ok,可直接 `xfs_quota -x` user 加配额 | (无需处理) |
| `fs quota: ext4 with project quota on ...` | ok, `quota -P` | (无需处理) |
| `fs quota: zfs on ...` | ok, dataset `zfs set quota=` | (无需处理) |
| `fs quota: xfs ... NO prjquota mount option` | fs 支持但 mount 时没启 | 见下方 xfs 步骤 |
| `fs quota: ext4 ... NO project quota option` | 同上 | `sudo tune2fs -O project,quota <dev>` + remount |
| `fs quota: btrfs ...` | qgroup 配置复杂 | 生产推荐换 xfs 单独分区,或自行验 `btrfs qgroup` |
| `fs quota: tmpfs/overlay/... ` | 通常 Docker-in-Docker 或本地 dev | 生产必须挂独立分区 |
**xfs 升级步骤(推荐方案)**:
```bash
# 1) 确认 workspace 在哪个 mount(假设 /opt 是独立 xfs 分区)
findmnt --target /opt/zcbot/workspace
# 2) 启用 prjquota(写入 /etc/fstab 让 reboot 后保留)
sudo mount -o remount,prjquota /opt
# 3) 给某 user 加 project quota(<pid> 自定义整数 id,与 user_id 映射建表跟踪)
echo "1001 /opt/zcbot/workspace/users/<user_uuid>" | sudo tee -a /etc/projects
echo "zcbot_<user_uuid>:1001" | sudo tee -a /etc/projid
sudo xfs_quota -x -c "project -s zcbot_<user_uuid>" /opt
sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
```
`<pid>` `user_uuid` 映射手工维护(`/etc/projects` 是数字 id,zcbot 侧需建表追踪;
**首期外部开放前补一个 `main.py sandbox quota-set --user-id <uuid> --gb 10` 子命令**
读写 /etc/projects + xfs_quota,这是 Step 4 / 5 之后真上线前一步,当前不做)。
不做这步等于"软配额 + 信任用户不写满" -- dogfood + 信任同事白名单阶段够用,
**外部用户开放是 hard prereq**
---
## 故障兜底
| 现象 | 原因 / 处理 |
|---|---|
| `ZCBOT_DB_URL is not set` | `.env` 没写 / litellm 链路没触发直跑脚本时 `import litellm` `export ZCBOT_DB_URL=...` |
| `ModuleNotFoundError: litellm` | 用了全局 `python`, `.venv/Scripts/python.exe ...` |
| Windows 控制台 emoji | Python stdout GBK `[OK]` / `[ng]` ASCII 标签( memory) |
| `db upgrade` `column already exists` | DB 已被改过,`db current` 确认 revision,必要时手 ALTER `db downgrade base` 重来 |
| Resume 找不到 task | dev SPA 左侧 task 列表看 task_id 是否在; `curl /v1/tasks` |
| `--working-dir` 指定后 task 删了目录还在 | 两种情况: 目录非空(有用户文件) 设计如此,绝不 rmtree,手动 `rm -rf <dir>` ;② 外部 `--working-dir`(DB 存绝对路径)— 不自动清,避免误删用户外部项目ROOT + working_dir 无其他 task 引用 + FS DELETE task 时已自动 rmdir |
| Sandbox 容器内 `touch /workspace/x` `Permission denied` | 容器 uid 1000 host `zcbot` 用户 uid 不一致(bind mount host owner)。`docker build --build-arg HOST_UID=$(id -u zcbot)` 重建镜像 |
| Sandbox 容器 build 完起不来,`docker logs` 显示 iptables 报错 | NET_ADMIN cap(`--cap-add=NET_ADMIN` 漏了) kernel 不支持(WSL2 / OpenVZ 环境不能跑)。Ubuntu 物理 / KVM 正常验:`docker exec ... iptables -V` |
| 启动报 `ZCBOT_SANDBOX_BACKEND=docker but sandbox init failed: ...` | docker daemon 没起 / 用户不在 docker group / network create 失败先跑 `main.py sandbox check` 看哪一项 err |
| `[startup] [warn] fs quota: <fstype> on ...` | workspace 所在 fs 没启 OS quotadogfood 阶段忽略;外部用户开放前必须升级 xfs prjquota / ext4 project / zfs( RUN.md配额硬化) |
| `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 .` |
| Export "无可导出内容" | task messages( system 不算);先发条消息再 export |
| `NoSubtaskError: working_dir ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。**同项目多对话****完全相同** working_dir;否则改成 sibling(平级) |
| `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务 127.0.0.1,系统 proxy 拦截会 502临时 `unset HTTP_PROXY HTTPS_PROXY` `curl --noproxy '*'`验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` |
| SSE 卡住不流( nginx) | 反代要关 buffering 后端响应头已带 `X-Accel-Buffering: no`(nginx 1.5.6 默认认)。仍卡看 nginx `proxy_buffering off; proxy_read_timeout 3600s;` |
| platform CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头 |
| `POST /v1/tasks/{id}/messages` 409 `task already has an active run` | 上一条消息的 BG run 还没跑完;等流式 done 或点 stop / `POST .../cancel`;服务异常下 `run_status` `running`/`cancelling`,启动 reaper 会清 |
| `POST /v1/tasks/{id}/cancel` 409 `task not running` | `run_status` 不是 `running`(idle / cancelling / error 都不能 cancel);dev SPA 自动忽略不报错 |
| `POST /v1/tasks/{id}/clear` 409 `task has an active run` | 当前 run 还没跑完;先点停止 / `POST .../cancel` 等流式 done 再清空 |
| stop 后流式没立刻停 | streaming 改造后正常路径秒退;若仍卡可能是 httpx 连接 close 没立刻关(GC 时机)/ 模型 thinking 阶段长时间不吐 chunk,等下一个 chunk 到达才能 poll cancel(罕见) |
| `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 errorinfo ,无需处理 |
| `seedream` tool 没出现在对话里 | `.env` 没设 `ARK_API_KEY`,build_agent 跳过注册设了重启 web 即可;无需迁移无需 DB 改动 |
| 豆包调价了 | `config/media/doubao.yaml` `price_cny_per_image` 一行 重启 web。**历史 usage_events 不受影响**(units jsonb 里有当时单价 snapshot,聚合查仍按旧价);新写入按新价涨价瞬间到改 YAML 中间这段记账偏低,开发期接受 |
| `kill -HUP <pid>` `/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler, Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`, unit `--reload` uvicorn 监听文件自动重起("部署")。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` |
| `systemctl restart zcbot` 10s 才退 | SSE 长连接,uvicorn graceful shutdown in-flightunit 已设 `TimeoutStopSec=10` SIGKILL,正常现象;真急用 `systemctl kill -s KILL zcbot` |
| `POST /v1/files/rename` 409 `folder has active run(s)` | 顶层目录被某 running/cancelling task 占用; cancel 等流式 done rename |
| `POST /v1/files/rename` 409 `... 前缀嵌套` | 改名后会与其他 task working_dir 形成嵌套;换不冲突的 new_name |
| 启动报 `PLATFORM_KEY env not set` / `JWT_SECRET env not set` | D' 过渡 auth 强制双 env 必填生成 `python -c "import secrets;print(secrets.token_urlsafe(48))"` 各填一, `.env` 重起 |
| `/v1/auth/login_password` 403 `invalid email or password` | 邮箱不存在 / `password_hash` 列为空(platform_key 入口建的 user) / 密码错。`SELECT user_id, email, password_hash IS NOT NULL AS has_pw FROM users WHERE email=...` 核对;无行 `main.py user add`;有行无密码 `UPDATE users SET password_hash=...`( `.venv/Scripts/python.exe -c "from web.auth import hash_password;print(hash_password('xxx'))"` ) `user add --user-id` 接到现有 user_id |
| `main.py user add` `IntegrityError ... uq_users_email` | 邮箱已存在, email 或先 `DELETE FROM users WHERE email=...`(先清该 user tasks) |
| `main.py user add` `IntegrityError ... users_pkey` | `--user-id` 撞已有 UUID,换一个或不传让随机生成 |
| 登录页"+ 管理员添加用户"提交后 503 `admin create_user disabled` | `ZCBOT_ADMIN_TOKEN` env 未设,功能默关设了 env 重启 web 即可;或临时回退 `main.py user add` |
| 登录页"+ 管理员添加用户" 403 `invalid admin_token` | 弹窗里管理员口令栏填错或没复制完整 `.env` `ZCBOT_ADMIN_TOKEN` 比对(注意末尾空格 / 引号) |
| 改了用户邮箱 / 密码后他登不上 | `UPDATE users SET email=...` 不影响 user_id(行同一行,task 仍归属),用新邮箱登即可;DB 里应存小写(后端 lower() 后查)。改密 `UPDATE users SET password_hash=<bcrypt>` 同理 |
| `/v1/*` 全返 401 `missing Authorization: Bearer` | 没拿 token 或没带 header login token,curl `-H "Authorization: Bearer $TOKEN"` |
| `/v1/*` 401 `token expired` | JWT 7d TTL 到期, login要更长改 `ZCBOT_JWT_TTL_SECONDS` env |
| dev.html SSE 收不到流(消息发出去 UI 没动) | EventSource 不支持 header,dev.html `fetch + ReadableStream`devtools Network POST /messages 是否 202 + events_url GET 是否 200 + Content-Type text/event-stream;401 token 过期,logout login |
| dev.html 显示 "load failed" 立刻回登录页 | token 过期或 JWT_SECRET 服务端变了已自动跳登录页,按上次 tab 重登 |
| dev.html 顶栏出现"连接断开,重连中…(N/3)" | SSE 流被切(`--reload` 重启 / nginx 切换 / 网络抖)。客户端自动重连,1s/2s/4s 退避;新进程已 reaper error 则立即收 done + 卡片末尾"请重发"提示;若服务端还活着会继续看后续 delta(断开期间的丢失,broker 不持久化) |
---
## 关键路径与文件
- **入口**:`main.py`(`web / db / probe / user`)→ `core/agent_builder.py::build_agent`
- **核心**:`core/{agent_builder, loop, session, task, llm, memory, paths}.py` + `core/storage/{engine,models,utils}.py` + `db/migrations/`
- **工具**:`tools/{fs, shell, run_python, skill_tool}.py`
- **Web**:`web/{app.py, auth.py, broker.py, sinks.py}` + `web/static/dev.html`(dev SPA)+ `web/static/vendor/`(office 预览 jszip/docx-preview/xlsx)
- **配置**:`config/agent.yaml` + `config/models/*.yaml`3.2 Model Profile)
- **Skill**:`skills/{coding,ppt,proposal}/SKILL.md`(渐进披露3.5)
- **Workspace**(per-user 子树,user_id 来自 JWT `sub`):
- `workspace/users/<user_id>/.memory/{core.md, extended/}` task 记忆,FS 永久,dotfile 隔离
- `workspace/users/<user_id>/<working_dir>/` 工作目录,用户起名, working_dir task 共享
---
## 维护约定
- **改对外行为(CLI 选项 / env / 文件布局)→ 同步本文档**。bug 修不动这个,只动 PROGRESS
- 故障兜底新增:用过一次的真实坑,写一行,不预测
- DESIGN/PROGRESS 边界:DESIGN "为什么",PROGRESS "做到哪",RUN "怎么跑"。