# 云端控制面(cloud/) 本目录是 host.py 本地编排之上的云端架构实施:控制面 server + 已就绪的模块(认证、队列、沙箱、记忆、知识库、模型路由、成本、 webhook)。零第三方依赖,只用 Python 标准库。 > 诚实边界:这是一个本地可跑的控制面演示,不是已建成的真实云服务。 > 本 server 不跑模型回合、不连真实 GitHub/Slack/云服务。生产前仍缺 > 的外部条件见文末“生产前仍缺什么”一节,不要绕过去。 ## 架构:谁负责什么 ``` 客户端 / GitHub / Slack │ HTTP + X-API-Key(webhook 靠签名,OAuth 靠 state,见下) ▼ cloud/server.py(控制面,ThreadingHTTPServer) ├─ 项目 / 任务:建、查、取消、心跳、stale 回收、任务成本 ├─ 记忆 / 知识库 / 成本 / 模型:已接成 HTTP 端点(见 API 表) ├─ OAuth:cloud/oauth.py(state + 授权 URL + 回调,脚手架) ├─ 推送订阅与分发:cloud/push.py(订阅表 + 偏好过滤) ├─ 认证 / 权限 / 审计:cloud/auth.py(AuthStore) ├─ 数据库接口:cloud/db.py(Database 接口 + SQLite 实现, │ WAL + busy_timeout + 有限重试;Postgres 只是骨架,见下) ├─ 并发:cloud/db.py(WAL + busy_timeout + 有限重试) └─ webhook 入站:cloud/webhooks.py(验签 + 解析,只解析不执行) ``` 状态库:单一 SQLite 文件。AuthStore 用 `users` / `project_members` / `audit_log` 三张表;server 用 `projects` / `tasks` 两张表,表名不冲突。 同一个 db_path 传给两边即可。不要再用共享 JSON 文件当多用户状态库。 并发:ThreadingHTTPServer 多线程处理请求,每个请求内新建 SQLite 连接(server 表用新连接,AuthStore 每次请求新建一个实例),绝不在 多线程 handler 间共用一个连接。注意:AuthStore 自身持有一个连接, server 因此不在 handler 间共享 AuthStore 实例。 任务状态机(与 host.py 对齐): ``` queued → running → completed / failed / interrupted / merged / merge-failed ``` 本版 server 只流转 queued → running(心跳)→ interrupted(取消 / sweep 回收),以及 completed → merged(见下表 `POST /tasks/{id}/merge`)。 `completed` 由 worker 在跑完模型回合后回写;`merged` 只由合并 状态接口同步,不执行合并本身——合并仍由 host.py merge(本地 或 Docker 容器内)执行,成功后再调本接口,云端不该在任务 completed 时自动改主分支。 ## 启动方式 ```bash cd ~/workspace/codex-app-server python3 -m cloud.server [--host 127.0.0.1] [--port 8765] [--db <路径>] # 默认:host 127.0.0.1,port 8765,db 为当前目录下的 cloud-control-plane.db ``` 测试用(不自动 serve): ```python from cloud.server import create_server server = create_server(db_path, host="127.0.0.1", port=8765) server.serve_forever() ``` 用户与 API key 由调用方先用 AuthStore 建(明文 key 只返回一次, 库里只存 sha256 哈希,绝不把 key 写进文件或聊天记录): ```python from cloud.auth import AuthStore store = AuthStore(db_path) user_id, api_key_plain = store.create_user("alice") ``` ## API 列表 全部返回 JSON,`Content-Type: application/json`。除 `GET /health`、 `/webhooks/*`、`/oauth/*` 外,所有端点要求请求头 `X-API-Key`,无 key 或 key 错返回 `401 {"error": "unauthorized"}`。`/webhooks/*` 不走 X-API-Key,因为外部服务无法持本站 API key,改靠各自的签名认证。 `/oauth/*` 不走 X-API-Key,因为 OAuth 登录的起点就是用户还没有 本站 API key——它的认证靠随机 state(10 分钟过期、一次性)加 client_secret(只走环境变量),不是无认证后门。 ### OAuth 接线步骤(脚手架,已实现,未接真实应用) 1. 注册真实 OAuth 应用(GitHub 或 Google),拿到 client_id, 回调地址填本站的 `/oauth/{provider}/callback` 公网地址。 2. `GET /oauth/{provider}/login?client_id=...&redirect_uri=...` 拿 authorization_url,把用户送到 provider 授权页。 3. provider 回调本站并带回 code + state,调 `POST /oauth/{provider}/callback`,验 state → 换 token → 映射 本站用户并记审计。client_secret 只从环境变量读 (`GITHUB_OAUTH_CLIENT_SECRET` / `GOOGLE_OAUTH_CLIENT_SECRET`), 绝不写文件、绝不进 URL、换 token 前未配好就明确报错。 4. 仍缺:真实应用注册、回调公网地址、已有本站账号的绑定流程 (目前是确定性用户名新建/复用,不做账号绑定)。 ### 推送凭证清单 - webhook / slack:需要可注入 sender(worker 侧),HTTP 层没有 sender 时明确报错,不静默成功。 - webpush:需要 VAPID 密钥对(私钥绝不进仓库)。 - apns:需要 APNs 证书。 - fcm:需要 FCM key。 以上三者目前只有接口 + 缺凭证报错,没有真实发送端。 ### SQLite 并发上限 server 的连接统一走 `cloud/db.py`:WAL 模式、busy_timeout 5000ms、 写锁冲突有限重试 3 次(退避 0.05s/0.1s/0.2s,重试耗尽抛原始错误, 绝不无限循环)。多线程并发写测试已覆盖。但这只是单机缓解: 写仍是单写者串行,写密集或跨机器时必须换 Postgres,这是下一阶段, 不是本轮假装解决。 ### 数据库后端选择(接口层) `cloud/db.py` 已抽成接口层,不改旧调用: - `Database` 接口的方法就是原有 SQLite 实现对外提供的全部方法, 按实际代码为准:`connect`(连接 + WAL 设置)、 `execute_with_retry`(执行)、`read_all`(查询)、 `transaction`(事务)、`is_locked_error`(锁冲突判定)。 - `SQLiteDatabase`:现有实现改名后的类封装,原样保留 WAL / busy_timeout / 有界重试;模块级函数 `connect` / `execute_with_retry` / `read_all` / `transaction` / `is_locked_error` 仍可用,行为不变。 - `PostgresDatabase`:骨架,不是可用后端。即使驱动已装,其 `connect` / 执行 / 查询 / 事务方法仍抛 `NotImplementedError`。 - 工厂 `create_database(backend, db_path)` / `database_from_env()`: 两个后端提供同一组接口方法与签名,见 `cloud/tests/test_db_backend.py` 的接口契约测试。 后端选择环境变量(server 启动时生效,默认仍是 SQLite): | 环境变量 | 说明 | 默认 | | --- | --- | --- | | `CLOUD_DB_BACKEND` | `sqlite` 或 `postgres`(`postgresql` 同义);其它值启动即报 `ValueError`,不静默当 sqlite | `sqlite` | | `CLOUD_DB_HOST` | Postgres 主机,全部走环境变量,不硬编码地址 | 无(未设即 None) | | `CLOUD_DB_PORT` | Postgres 端口,须为整数 | 无(未设即 None,走驱动默认) | | `CLOUD_DB_NAME` | Postgres 库名 | 无 | | `CLOUD_DB_USER` | Postgres 用户名 | 无 | | `CLOUD_DB_PASSWORD` | Postgres 密码,只走环境变量,绝不写文件、绝不进日志与 `repr` | 无 | 选 `postgres` 且驱动缺失时启动即明确报错:`PostgresDatabase` 初始化时探测 `psycopg`(优先)/ `psycopg2`,两者都缺就抛 `PostgresDriverMissingError`(`RuntimeError` 子类,文案含 psycopg,写明“需安装 psycopg”),绝不静默回落到 SQLite, 也绝不假装已接通 Postgres。`create_server` 在建表之前就做这 一步校验,所以失败时不会先建出一个 SQLite 库假装可用。 > 高并发换 Postgres 是下一阶段的运维项,不是本轮代码一改就 > 自动变生产。接口层 + 骨架只解决了“以后能换”,真正上生产还 > 需要:真实 Postgres 部署(含备份、连接数与权限)、安装 > psycopg 驱动、写迁移脚本并把现有 SQLite 表结构与数据迁过去、 > 把 server / AuthStore / MemoryStore / KBStore / CostStore 等 > 目前仍直连 SQLite 的调用点逐一接到新后端并重测。在这些完成 > 之前,即使把 `CLOUD_DB_BACKEND` 设成 `postgres`,server 也只会 > 在启动时明确报错,不会跑起来——这是有意为之的诚实边界。 | 方法与路径 | 权限 | 说明 | | --- | --- | --- | | `GET /health` | 无需认证 | `200 {"ok": true, "service": "cloud-control-plane"}` | | `POST /projects` | 认证即可 | body `{name, id?}`;无 id 则生成 uuid;调用者记为 owner(调 `AuthStore.create_project`,同时写 projects 表并记审计 `create_project`);返回 `201` + 项目 | | `GET /projects` | 认证即可 | 只返回调用者是成员的项目:`{"projects": [...]}` | | `POST /tasks` | `create_task` | body `{project_id, prompt, depends_on?, priority?, idempotency_key?}`;无权限 `403`;同 `project_id + idempotency_key` 已存在则返回已有任务(`200`,不建重复);否则建 `queued` 任务并记审计 `create_task`,返回 `201` | | `GET /tasks?project_id=...` | 该项目 `read` | 返回该项目任务列表:`{"tasks": [...]}`;无权限 `403` | | `GET /tasks/{id}` | 该任务所属项目 `read` | 无权限 `403`;不存在 `404` | | `POST /tasks/{id}/cancel` | 该项目 `create_task` | `queued` / `running` 改为 `interrupted` 并记审计 `cancel_task`;已是终态(completed / failed / interrupted / merged / merge-failed)返回 `400`;不存在 `404` | | `POST /tasks/{id}/merge` | 该项目 `approve_merge`(maintainer 以上) | 只同步状态,不执行合并:仅 `completed` 可改为 `merged` 并记审计 `merge_task`,其它状态(含已 merged 的重复调用)返回 `409`;不存在 `404`,越权 `403`,无 key `401` | | `POST /tasks/{id}/heartbeat` | 该项目 `create_task` | 更新 `last_heartbeat = now`;状态为 `queued` 则改为 `running`;返回任务 | | `POST /admin/sweep` | 认证即可 | 调 `sweep_stale_tasks(db_path, threshold_seconds=300)`(body 可传 `threshold_seconds` 覆盖默认值);把 `running` 且 `last_heartbeat`(无心跳则用 `updated_at`)早于 `now - threshold` 的任务改为 `interrupted` 并记审计 `sweep_interrupted`;返回 `{"swept": [ids]}` | | `POST /tasks/{id}/cost` | 该任务所属项目 `read` | 成本聚合:`{task_id, project_id, total_tokens, usd_micros, turns, fallback_count, actual_tokens, estimated_tokens}`;真数与估算分开;不存在 `404`,越权 `403` | | `POST /projects/{id}/memory` | 该项目 `create_task` | 写线程摘要 body `{thread_id?, goal, conclusion, files_changed?, open_issues?}`,返回 `201 {id}`,并记审计 `create_memory` | | `GET /projects/{id}/memory?top_k=5` | 该项目 `read` | 最近 active 摘要(默认 top_k=5)+ `prompt_context`(只含 top_k,不全量注入);不存在 `404`,越权 `403` | | `POST /projects/{id}/memory/{mid}/supersede` | 该项目 `create_task` | 把摘要标废弃(deprecated,不是物理删除),并记审计 `supersede_memory`;摘要不属于该项目则 `404` | | `POST /projects/{id}/kb/index` | 该项目 `create_task` | 索引文档 body `{path, content, kind?}`,同项目同 path 重复索引会替换旧文档,返回 `201 {id}` | | `GET /projects/{id}/kb/search?q=&top_k=5` | 该项目 `read` | FTS + LIKE 回退,仍按项目过滤,跨项目文档绝不返回;`q` 缺失 `400` | | `GET /projects/{id}/cost` | 该项目 `read` | 项目成本总览 + 按天:`?day=YYYY-MM-DD` 查单天,`?days=N` 查最近 N 天;无参数只给总览 | | `GET /models` | 认证即可 | 优先级列表(环境变量 `MODELS_PRIORITY` 逗号分隔,未设用默认)+ 只含调用者成员项目的模型统计与 fallback 事件(`fallback_count > 0` 的任务);不返回 prompt 内容 | | `GET /oauth/{provider}/login?client_id=&redirect_uri=&scope=` | 无需 X-API-Key(pre-auth) | 返回 `{provider, state, authorization_url}`,只返回 URL 不跳转;provider 仅 github/google,未知 `400`;state 已入库,10 分钟过期 | | `POST /oauth/{provider}/callback` | 无需 X-API-Key(pre-auth) | body `{code, state, client_id, redirect_uri}`;验 state → 换 token → 映射到本站用户(复用 users 表,用户名 `oauth::`)并记审计 `oauth_login`;state 无效/过期、换 token 失败、无 secret 一律 `400` 且不建会话;成功不返回 API key(发放方式由调用方另行决定) | | `POST /projects/{id}/push/subscriptions` | 该项目 `create_task` | 记订阅 body `{channel, target, preference?}`,channel 为 webhook/slack/webpush/apns/fcm,preference 为 instant/digest/silent,返回 `201` | | `GET /projects/{id}/push/subscriptions` | 该项目 `read` | 只列调用者自己的订阅(不泄露他人 target) | | `DELETE /projects/{id}/push/subscriptions/{sid}` | 该项目 `create_task` | 只能删自己的订阅,项目 owner 可删任意;不存在 `404`,他人订阅 `403` | | `POST /admin/push/dispatch` | 该项目 `read` | body `{event_type, project_id, payload?}`;只发状态变化事件(completed/failed/goal-unmet/merge-failed/review-requested);HTTP 层无注入 sender:webhook/slack 报“无 sender”,webpush/apns/fcm 报缺凭证,都不静默成功;真实发送端由 worker 注入 sender 后调 `push.dispatch_event` | | `POST /webhooks/github` | GitHub 签名 | 见“webhook 端点” | | `POST /webhooks/slack` | Slack 签名 | 见“webhook 端点” | 单个项目 / 任务以裸对象返回;列表以 `{"projects": [...]}` / `{"tasks": [...]}` 包装。任务对象字段:`id`、`project_id`、`prompt`、 `status`、`idempotency_key`、`depends_on`(list)、`priority`、 `created_by`、`created_at`、`updated_at`、`last_heartbeat`。 函数 `sweep_stale_tasks(db_path, threshold_seconds=300) -> list` 独立 可单测,不依赖 HTTP;回收动作的审计 `user_id` 记为 `"system"`。 ### webhook 端点(加分项,已实现;只解析,不执行任务) - `POST /webhooks/github`:用 `webhooks.verify_github_signature` 验签(请求头 `X-Hub-Signature-256`),secret 从环境变量 `GITHUB_WEBHOOK_SECRET` 读。未设 secret 返回 `503`;验签失败 返回 `401`;成功返回 `parse_github_pr_comment` 的结果(PR 评论含 `/fix-review` 时返回 fix_review 触发点,否则 `{"result": null}`)。 真正执行 fix-review 由控制面后续调 host.py 完成,本端点不执行。 - `POST /webhooks/slack`:用 `verify_slack_signature` 验签(请求头 `X-Slack-Signature` / `X-Slack-Request-Timestamp`,含 300 秒防重放), secret 从环境变量 `SLACK_SIGNING_SECRET` 读。未设返回 `503`; 验签失败返回 `401`;成功返回 `parse_slack_command` 的结果,不执行任务。 ## 权限表 角色高低:viewer < contributor < maintainer < owner。未加入项目一律 无权限。动作的最低角色(`AuthStore.check_permission` 的实际规则): | 动作 | viewer | contributor | maintainer | owner | | --- | --- | --- | --- | --- | | `read`(读项目任务) | ✅ | ✅ | ✅ | ✅ | | `create_task`(建任务 / 取消 / 心跳) | ❌ | ✅ | ✅ | ✅ | | `approve_merge`(批合并,worker 侧用) | ❌ | ❌ | ✅ | ✅ | | `manage_project`(仅 owner,改成员等) | ❌ | ❌ | ❌ | ✅ | 说明:`manage_project` 的最低角色是 owner 本人,不是 maintainer; 改成员(`AuthStore.set_member`)只有 owner 能做,否则抛 `PermissionError`。审计(`audit` / `list_audit`)只追加、只读,本模块 不提供删除或修改审计记录的方法。 注意一个实际签名细节:`AuthStore.create_project(project_id, owner_user_id)` 只写 `project_members` 的 owner 成员记录,不建项目 记录本身;项目记录(name 等)由 server 写 `projects` 表。两者配合 才算一个完整项目,不要只调一处。 ## 测试 ```bash cd ~/workspace/codex-app-server python3 -m unittest discover -s cloud/tests -v # 模块单测 + 集成单测 python3 -m unittest discover -s tests -v # 原有 host.py 测试 ``` `cloud/tests/test_server.py` 用 http.client 对本地临时端口的 server 做真实 HTTP 测试(临时 SQLite 文件与临时端口,跑完关闭 server), 覆盖:无 key / 错误 key 401、非成员读项目 403、viewer 建任务 403、 幂等键第二次返回同一任务且任务数不增加、cancel 把 running 任务变 interrupted 且终态任务 cancel 返回 400、心跳后 sweep 不扫刚心跳的 任务、sweep 扫掉心跳过期(手动改旧 last_heartbeat 或 threshold=0) 的 running 任务、GET /health 不认证也通、GET /projects 只返回成员项目, 以及两个 webhook 端点的 503 / 401 / 验签成功解析。 ## 生产前仍缺什么 以下每一项都是外部条件,缺了就不能算生产可用,不要绕过去: - **常驻机器 / 托管服务**:本 VM 会被平台定时回收,跑在这里只能算 演示。生产需要一台常驻机器或托管服务(以及相应的进程守护与 重启策略),否则控制面会随 VM 回收一起消失。 - **GitHub 远端 + PAT**:codex-app-server 目前仍无 git 远端,`gh` 也未登录;真实 PR 流程(host.py 的 `pr` / `pr-checks` / `pr-merge`) 需要先建远端并用 Secure Vault 供给 PAT,PAT 绝不写文件。 - **Slack App**:需要真实 Slack App(签名密钥、斜杠命令、事件订阅) 才能接通 `SLACK_SIGNING_SECRET` 背后的入站;目前只有验签与解析, 且 `NotificationService` 的 Slack 发送端仍是占位注入。 - **云容器账号**:`CloudExecutor` 仍是占位(无账号即抛错)。 Docker 执行已接通(见下文“Docker 沙箱执行器”),但真实 docker run、镜像构建、资源限额实测仍需在外部主机上做: 本 VM 没有 Docker,本轮未连外部主机、未向真实外部服务发 请求、未跑真实模型回合,不要宣称 Docker 沙箱已在真实环境验证。 - **模型 key 供给方式**:这是当前最实在的卡点。上次 real-nightly-tests automation 就卡在这里:定时进程手里没有中转站 key,key 从不存文件、只在进程环境里 transient 供给。控制面要跑 真实模型回合,必须先由用户定下供给方式(例如常驻进程的环境、 Secure Vault 注入、或其它经用户明确同意的渠道),而不是把 key 写进配置文件、数据库或代码里凑合。定下之前,定时自动化与 worker 的模型回合都只能停在“失败路径可用、模型跑不起来”的状态。 - **其它待接线(非外部条件,但同样未完成)**:OAuth 已接成脚手架 端点(state + 授权 URL + 回调),但真实应用注册、回调公网地址、 已有账号绑定仍缺;推送已接成订阅 + 分发端点,但 Web Push / APNs / FCM 的真实凭证与发送端仍缺;单机 SQLite 在高并发写下 仍有上限,生产量上来后需换 Postgres(接口层与骨架已就绪, 但真实部署、驱动安装与迁移脚本仍缺,见上文“数据库后端选择”); worker 执行已接通(见下文“worker 常驻与真取消”),云端执行、 真实通知与真实模型回合仍受 key 供给与云账号卡点限制。 ## worker 常驻与真取消(本轮新增) - 单次:`python3 -m cloud.worker --db <库> --host-py ../host.py`; 常驻:`python3 -m cloud.worker --db <库> --loop --interval 300`, 每轮 run_all,SIGTERM/SIGINT 时优雅退出,正在跑的任务先标 interrupted 再退出;CLI 起 worker 前先 init_db,全新库不再因 缺表而炸,循环内一轮异常记日志后继续,不带走整个 worker。 - 真取消:子进程以进程组启动(start_new_session),取消时先 SIGTERM 给整个进程组、等 2 秒、仍活着再 SIGKILL,杀完任务 保持 interrupted,执行结果不覆盖它;取消竞态(run_one 已先 标 interrupted)时 cancel 仍返回 True,因为中断确由本次取消触发。 - 任务结果列:tasks 表新增 result_text、error_text、actual_model、 started_at、finished_at、child_pid,init_db 用 PRAGMA 检查后 ALTER TABLE 幂等迁移,老库升级不丢数据;GET /tasks/{id} 返回 它们,缺 key 任务的 error_text 含 Missing environment variable。 ## 可观测性与备份(本轮新增) - 结构化日志:cloud/logging_config.py,JSON 行日志(时间、级别、 服务、任务 id、事件),worker 的认领/完成/失败/取消/fallback/ 缺 key 记事件日志,绝不写密钥与环境变量值。 - 指标:`GET /metrics`(需 X-API-Key,任意项目成员可读,只统计 调用者是成员的项目),返回任务总数、各状态计数、成功率、 平均耗时、fallback 总次数、成本合计(actual 与 estimated 分开)。 - 备份:`python3 -m cloud.backup --db <库> --out <目录> [--keep 3]`, 用 SQLite backup API 在线备份,轮转只留最近 3 份(命名含时间戳), 失败明确报错不静默;备份文件本身就是可打开的 SQLite 库。 - DB 接口迁移:server 及 auth/memory/kb/cost/oauth/push/webhooks 的 Store 连接已统一走 cloud.db 的 WAL + busy_timeout 连接 (Postgres 后端仍是骨架,驱动缺失时启动即失败、不先建 SQLite 文件,这一点迁移后未退化)。 ## Docker 沙箱执行器(本轮新增) - 用法:worker 选 docker 执行器—— `python3 -m cloud.worker --db <库> --executor docker [--docker-image codex-app-server:latest] [--docker-cpus 2] [--docker-memory 2g] [--docker-user 1000:1000] [--docker-worktrees-root <目录>] [--docker-timeout <秒>]`; 或在代码里直接传 `DockerExecutor(...)` 给 `Worker(executor=...)`。 选 docker 但本机无 docker 时任务明确失败,error_text 含 docker 缺失,绝不静默降级为本地执行;选 local 时行为不变。 - 命令构造(示例,任务 id 为 t1、worktree 为 /tmp/wt-t1 时): `docker run --rm --name codex-t1 --user 1000:1000 --cpus 2 --memory 2g -v /tmp/wt-t1:/work -v <代码目录>:/app:ro -w /work -e XCM_API_KEY codex-app-server:latest python3 /app/host.py dispatch --name t1 --task <提示>`。要点:只挂任务自己的 worktree(可写)和代码目录(只读),绝不挂宿主根目录、绝不 挂任何含密钥的目录;容器名由任务 id 派生(仅安全字符); `-e` 只传 env_key 名,不带值,key 缺失时容器根本不启动; 资源限额默认 2 核 / 2G,可配置;容器内进程以非 root 用户跑。 - 镜像:`cloud/docker/Dockerfile`,基于 python:3.12-slim,装 git,复制调用方提供的配对 codex 二进制(codex + codex-code-mode-host + codex-resources)。构建命令示例(在 仓库根目录,构建上下文就是仓库根): `docker build -f cloud/docker/Dockerfile -t codex-app-server:latest --build-arg CODEX_BIN_DIR=bin .` bin/ 不进仓库,构建主机上没有配对二进制就构建失败;代码 不在镜像里,运行时只读挂载到 /app,代码改了不用重新构建, codex 版本变了才需要重新构建;镜像里不含任何 key。 - 清理与取消:`docker run --rm`,正常失败也自动删容器;超时 时先 docker kill 再 docker rm -f;worker 取消时按任务派生的 容器名 docker kill,不只改数据库状态。 - 网络白名单的诚实边界:docker run 默认 bridge 网络,本执行 器不做 egress 白名单——真正的“只放行模型 API”需要宿主防火 墙或代理规则,那是部署项,不是本轮代码假装做到的,不要宣称 已实现网络白名单。另有一项待实测:codex 自带的 bubblewrap 沙箱在容器内是否可用,取决于宿主的容器权限配置,本镜像不 假装解决;若 bwrap 不可用,host.py 的 sandbox 参数仍可退到 read-only / workspace-write 的控制面约束。 - 仍需在外部主机上实测(本 VM 没有 Docker,本轮未连外部主机): 真实 docker build、真实 docker run、资源限额在真实负载下的 表现、bwrap 在容器内的可用性、以及取消时容器确实消失。