Policy carries an operator-declared model_capabilities directory and per-tier requires (tool_calling/reasoning/vision booleans, min_context_tokens). decide_routing checks the policy tier model in active mode: capable applies, incapable falls back to a capable worker default, and if both fail or are unknown it returns capability_blocked, which the worker turns into a failed task (v3 tools-gate shape, runner never called). Shadow mode only annotates the reason, never blocks. Explicit task and worker CLI models bypass the check by design. Invalid requires drops only that tier (fail-closed, like invalid tier mode). Dry-run reports per-tier capability status and blocks on active tiers that fail or cannot be verified.
云端控制面(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 时自动改主分支。
启动方式
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):
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 写进文件或聊天记录):
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(只走环境变量),不是无认证后门。
本轮新增端点(同样要求 X-API-Key):GET /workers(注册 worker
列表,含 alive)、POST /workers/heartbeat(body 含 worker_id,
心跳即注册)、POST /tasks/{id}/pr(已 merged 任务的自动 PR,
默认关闭,缺条件时返回 pr_skipped 而不是失败;见下文“云端派发与
自动 PR”一节)。POST /tasks 新增可选 executor
(local/docker/cloud;未传且无环境继承时默认 docker——空
executor(任意 worker 可认领)会让任务落到隔离较弱的 local
worker,是多用户下的隔离缺口;CLOUD_DEFAULT_EXECUTOR 可
覆盖,空字符串恢复旧行为);
POST /projects 新增可选 auto_pr(0/1,默认 0)。
OAuth 真实浏览器登录(已实现,部署前置仍需用户动手)
流程(标准 OAuth2 授权码流程):
GET /oauth/{provider}/login?client_id=...&redirect_uri=...返回{provider, state, authorization_url};state 已入库 (10 分钟过期、一次性,provider / redirect_uri / client_id 都随 state 一起存下),把用户送到 provider 授权页。- 用户授权后 provider 重定向到本站
GET /oauth/{provider}/callback?code=...&state=...(真实浏览器 流程只带 code + state;client_id / redirect_uri 由 server 从 state 记录取回,不需要 provider 额外带回)。 - server 验 state → 用 code 换 token → 调真实 userinfo 取身份
(GitHub:
https://api.github.com/user,Bearer + User-Agent, 取 login/id;Google:https://openidconnect.googleapis.com/v1/userinfo, 取 sub/email/name)→ 映射到本站用户(确定性用户名oauth:<provider>:<login>,新建或复用)→ 签发本站 API key 并记审计oauth_login(轮换时另记api_key_rotated)。 - 成功页(GET 回调)只显示 login、user_id、是否新用户和新 API
key 一次,并写清“请立即保存,刷新后不再显示,旧 key 已失效
(如系轮换)”;成功页绝不显示 access_token、client_secret、
旧 API key。
POST /oauth/{provider}/callback(JSON 版,body 带 code/state,client_id/redirect_uri 可省、从 state 取回)同样在 成功响应里返回一次新 API key;之后任何接口都不再返回它。 失败一律 400:state 无效/过期/不匹配、换 token 失败、userinfo 失败、client_secret 缺失都有明确 reason,且不建用户、不发 key。 state 仍是一次性、10 分钟过期、provider / redirect_uri 必须匹配。
真实部署前置(以下任何一项没做,都不算真登录,绝不是“改个 环境变量就自动有公网登录”):
- 在 GitHub 上注册 OAuth App(或 Google 上注册 OAuth 客户端):
应用名自定;Homepage URL 填本站公网地址;Authorization
callback URL 必须与本站回调地址逐字一致,即
https://<你的域名>/oauth/github/callback(Google 同理); 非 localhost 时 GitHub 要求回调地址是 https,http 会被拒绝。 - client_id 由登录请求方传入(login 接口的 query 参数不变);
client_secret 只从环境变量读(GitHub
GITHUB_OAUTH_CLIENT_SECRET、GoogleGOOGLE_OAUTH_CLIENT_SECRET),绝不写文件、绝不硬编码、 绝不出现在授权 URL 或成功页里;secret 缺失时回调明确报client_secret_missing,不会以空 secret 去撞真实端点。 - 本站控制面必须有公网可达的 https 回调地址:目前 server 默认
只绑
127.0.0.1:8765,没有公网入口、没有 TLS,这一步是部署 前置——需要在部署侧配反向代理 + TLS 证书并把公网域名指到 server,再把该域名下的回调地址填进 OAuth 应用的注册信息里。 - 登录后 API key 的保存与多 key 规则:新用户拿到创建时生成的
primary key;已存在用户每次 OAuth 登录签发一个附加 key
(label 为
oauth:<provider>),旧 key(primary 与之前的附加 key)全部继续有效,不再轮换——登录不会打断已存得到处都是的 旧 key。响应里的api_key_rotated字段为向后兼容保留,恒为 False;签发附加 key 时记审计api_key_issued(不再记api_key_rotated)。key 明文只在回调成功页/响应里出现一次, 丢了只能重新走一次 OAuth 登录拿新 key(旧 key 不受影响)。 - API keys 管理(全部只操作认证用户自己的 key,须持本站 key):
GET /users/me/keys(列出 primary(id 为 "legacy")+ 附加 key, 含 label、后 4 位提示、创建/最近使用/撤销时间,绝不返回明文 与哈希)、POST /users/me/keys(body 可含 label,签发附加 key, 明文只在本次响应里出现一次)、DELETE /users/me/keys/{id}(撤销一个 key,primary 用 id "legacy",无此 key 或已撤销返回 404)、POST /users/me/keys/revoke-others(撤销除本次请求 key 之外的全部自己的 key,返回撤销数)。附加 key 认证成功时更新 last_used_at,但每小时最多写一次(认证是热路径)。 - 已有本站账号的绑定流程(已实现):持本站 key 调
POST /oauth/{provider}/bind/initiate(body 含 client_id + redirect_uri),返回{provider, state, authorization_url}, state 里带 bind_user_id(10 分钟过期、一次性);用户授权后 回调走绑定分支(GET 回调成功页只显示 provider + 外部 login, 绝不显示任何 key、也不显示本站 user_id;POST 回调返回{linked: true, provider, login, external_id, user_id, already_bound},同样不含 key)。绑定表external_identities有两个唯一约束:一个外部身份只能绑一个 本站账号、一个本站账号每个 provider 只能绑一个外部身份, 冲突返回 409(identity_bound_to_other/user_already_bound),同一用户 + 同一身份重复绑是幂等的。 登录解析顺序:先查绑定表——绑过的身份落到被绑账号并按 OAuth 登录规矩轮换 key;没绑过才走确定性 oauth 账号映射。以前 GitHub 登录建的 oauth 账号不会被绑定删掉:绑完后该身份登录 改落到被绑账号,旧 oauth 账号成孤儿(key 仍可用);解绑 (DELETE /oauth/{provider}/bindings,须持本站 key,本来就 没有返回 404)后恢复原来的 oauth 账号登录。查看自己的绑定:GET /oauth/{provider}/bindings(须持本站 key,只看得到自己的)。
GitHub App 一键安装与 App-token(安装 + token 均已实现)
目标:把手动建 webhook(填 URL、secret、选事件)变成“点一下 安装”。流程用 GitHub manifest 流程(GitHub 文档标准流程):
- 操作方持本站 key 调
POST /github-app/setup/state(body 可含 org),返回{state, setup_url};要求环境变量GITHUB_APP_PUBLIC_BASE(公网域名,如https://api.diante.me, manifest 里的 hook / 回调地址都由它拼出)。 - 用户打开 setup_url:表单页含 manifest JSON(App 名默认
codex-app-server,可由GITHUB_APP_NAME/GITHUB_APP_URL改;权限 contents:write、pull_requests:write、issues:read, 事件 issue_comment、pull_request;默认私有 App,只有所有者 能安装,GITHUB_APP_PUBLIC=1可翻成公开)与 state,点提交 到 GitHub 建 App 页(个人账号/settings/apps/new,组织账号/organizations/{org}/settings/apps/new),用户确认 Create。 - GitHub 跳回
GET /github-app/callback?code=...&state=...: server 验 setup state(一次性、1 小时过期,manifest code 同样 1 小时内有效)→ POSTapi.github.com/app-manifests/{code}/conversions换回 id、slug、 webhook_secret、pem → 存库里github_app_config表(单行)。 成功页只显示 App 身份与安装链接https://github.com/apps/<slug>/installations/new,绝不显示 webhook_secret 与 pem。 - 用户点安装:installation 事件到
/webhooks/github,记github_app_installations表(created/added 增行、removed 减 行、deleted 清安装),GET /github-app/status(须持本站 key) 可查配置与安装列表,同样不含 secret/pem。
验签与去重:webhook 验签同时接受 App secret(库里)与 env 里
GITHUB_WEBHOOK_SECRET(手动 webhook,过渡期共存);App 装好
后应删掉手动 webhook,避免同一事件送两遍;即使共存,
/fix-review 也有内容去重(repo + PR + 评论 id,
github_fix_review_dedup 表),只建一次任务。
诚实边界(已缩小):pem 现在用于签 JWT、换 installation
token:cloud/github_app.py 的 mint_app_jwt 是纯标准库
RS256(解析 PKCS#1 / PKCS#8 私钥,不依赖 openssl、不落临时
文件),exchange_installation_token 换 1 小时有效的 token
(现换现用、不缓存、不存库),resolve_app_token 按仓库名
查 github_app_installations 找 installation id,任一步缺失
或失败返回 None(调用方回落 PAT,不报错)。推分支、建 PR
走 App 还是 PAT 见“云端派发与自动 PR”;host.py 本地操作
(pr / checks / merge)仍是操作员自己的 gh + PAT,不走
App-token。App 安装事件的现场记录路径仍待下一次真实
安装/增减仓库动作实弹验证(此前一次是数据修复,不是
webhook 实测)。
推送凭证清单
- webhook / slack:已有内置真实发送端(标准库 urllib POST,
单次 10 秒超时,失败记入分发结果的 errors):
webhook 投 JSON(event_type、project_id、task_id、payload),
slack 投 Slack incoming webhook 格式的一行文本(任务 id +
事件 + 项目,失败附 error 摘要)。任务终态(completed /
failed / goal-unmet)由 worker 在 run_one 的终态写回后自动
通知订阅者;PR 端点在建 PR 成功时发 review-requested、
落盘失败时发 merge-failed;手动分发走
POST /admin/push/dispatch,同样经内置发送端。 显式注入的 sender 优先于内置端。 - 摘要(digest,G11):非失败事件在 digest 偏好下不再只在
返回值里列入 skipped_for_digest 就完事——事件持久化进
push_digest_events表,worker 循环每小时按(项目、订阅) 分组汇总发一条(payload 为{"digest": True, "events": [...], "count": n}),成功后整组标记已发送,失败整组保留待下 一次重发。失败类事件(failed/goal-unmet/merge-failed)不 进摘要,仍即时发。摘要节奏是“worker 循环每小时一批”, 不是“每条事件满 1 小时才发”——一批刚发完、紧接着到的 事件要等下一批(最长约 1 小时)。 - 推送失败重试(outbox,G12):webhook/slack 发送端抛异常
时,事件写进
push_outbox表,worker 循环后台重试(最多 3 次、默认 60 秒退避);3 次仍失败则删行并记 worker_push_outbox_dropped 日志,不丢任务状态、不炸循环。 缺凭证(webpush/apns/fcm)与无 sender 不是瞬时故障,不 进 outbox,仍明确报错。at-least-once:webhook/slack 的重 试可能重复送达同一事件,接收方必须按 event/task 去重, 本模块不保证 exactly-once。 - webpush:需要 VAPID 密钥对(私钥绝不进仓库),且需要 浏览器端订阅(endpoint + p256dh + auth,现有订阅模型只有 一个 target 文本字段,装不下)与标准库没有的 P-256 ECDH + AES-GCM——只有接口 + 缺凭证报错,没有真实发送端。
- apns:需要 APNs 证书与标准库没有的 HTTP/2——只有接口 + 缺凭证报错。
- fcm:需要 FCM key 与用户另建的 Firebase 服务账号——只有 接口 + 缺凭证报错。 以上三者目前只有接口 + 缺凭证报错,没有真实发送端,也 不编造推送。
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:真实 Postgres 实现。调用方仍写 sqlite3 风格 SQL(?占位符、row["col"]、conn.execute/fetchall/commit/rollback/close),由PgConnection/PgCursor/PgRow适配层翻译成 Postgres 方言(?->%s,字符串字面量里的?不动;INSERT OR REPLACE->ON CONFLICT DO UPDATE;PRAGMA table_info-> information_schema;AUTOINCREMENT->GENERATED BY DEFAULT AS IDENTITY; FTS5 虚拟表在 PG 上明确报错、KBStore 走 LIKE 分支)。 驱动异常映射成PostgresIntegrityError/PostgresOperationalError/PostgresConnectionError, 绝不把驱动异常原样漏成 sqlite3 异常。模块级connect()按CLOUD_DB_BACKEND分发,因此 server / AuthStore / MemoryStore / KBStore / CostStore 等沿用connect()的 调用点无需重构即可走 Postgres;但backup.py的 SQLite 在线备份 API 仍只适用于 SQLite,Postgres 备份请用 pg_dump。- 工厂
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 部署步骤(不要只改一个环境变量)
改 CLOUD_DB_BACKEND=postgres 本身不会迁移任何数据,也不会
建库——只改它、数据还在旧 SQLite 文件里,新库是空的。完整
步骤是:
- 装驱动(psycopg v3 优先):
pip install "psycopg[binary]" # 其次才用 psycopg2:pip install psycopg2-binary - 在 Postgres 里建库与用户(示例库名与用户名请按实际部署改,
密码绝不写进仓库文件):
注意:表由应用启动时
CREATE DATABASE cloud_control_plane; CREATE USER cloud_app WITH PASSWORD '<仅在此处输入>'; GRANT ALL PRIVILEGES ON DATABASE cloud_control_plane TO cloud_app;init_postgres_schema建(cloud/db.py的POSTGRES_SCHEMA,与 SQLite 表名/列名 一一对应,含 tasks 的 6 个结果列;KB 在 PG 上无 FTS 表, 走 LIKE 搜索)。若数据库用户不是库 owner,另需对 public schema 授权(GRANT ALL ON SCHEMA public TO cloud_app), 否则建表会报权限错——这是部署侧问题,不是代码 bug。 - 设环境变量并启动(
--db参数在 PG 模式下被忽略,连接 参数只走环境变量;--db-backend postgres与环境变量等价):export CLOUD_DB_BACKEND=postgres export CLOUD_DB_HOST=127.0.0.1 CLOUD_DB_PORT=5432 export CLOUD_DB_NAME=cloud_control_plane export CLOUD_DB_USER=cloud_app export CLOUD_DB_PASSWORD='<仅在此处输入>' python3 -m cloud.server --host 127.0.0.1 --port 8765 - 迁旧数据(可选,须在启动建表之后、切流量之前跑;跑两次
不重复插,第二次各表 inserted 应为 0):
迁移脚本逻辑在
python3 -m cloud.migrate_sqlite_to_postgres \ --sqlite /path/to/cloud-control-plane.db # 输出示例:projects: 3 / tasks: 12 / ... / total inserted: N # (只打印表名与行数,不打印任何密码或密钥)cloud/migrations.py(migrate_sqlite_to_postgres),身份列的序列会在迁完后 拨到当前最大 id,避免后续自动生成撞主键。 - 回退到 SQLite:把
CLOUD_DB_BACKEND改回sqlite(或 删掉它,默认即 sqlite),并把--db指回原来的 SQLite 文件即可。但注意:切到 Postgres 之后新写的数据只在 Postgres 里,SQLite 文件里没有——回退不会自动把 PG 数据 导回 SQLite;如需回迁,须另写 PG -> SQLite 脚本(本轮 未提供,不要假装存在)。 - 备份:Postgres 侧用
pg_dump,不要用cloud/backup.py(那是 SQLite 在线备份 API,只适用于 SQLite 文件)。
诚实边界(仍需在真实服务器上验证,见下文“未验证清单”): 本机 VM 既无 psycopg 驱动、也无 Postgres 服务器,因此真实 PG 路径在本机只能 skip(见测试一节),不能谎称已在真实 PG 上跑通过。已验证的是:适配层翻译逻辑(fake 驱动)、 schema 语句覆盖、SQLite 全量回归不坏。
| 方法与路径 | 权限 | 说明 |
|---|---|---|
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?, executor?, goal?, verify_command?, goal_max_rounds?, model?, effort?, approval?};无权限 403;同 project_id + idempotency_key 已存在则返回已有任务(200,不建重复);否则建 queued 任务并记审计 create_task,返回 201;verify_command 非空即 goal 模式(见下文“goal 模式”),goal_max_rounds 缺省 3、须 ≥1;model 不做白名单(中转目录会变,console 下拉只是建议值),effort 只能是 low/medium/high/xhigh/max,approval 只能是 ask/auto/full,三者缺省均为 NULL(= 跟随 worker 配置默认值,行为与过去一致) |
GET /tasks?project_id=... |
该项目 read |
返回该项目任务列表:{"tasks": [...]};无权限 403 |
GET /relay/models |
任意已认证用户 | 中转站可用模型列表:服务端凭环境变量 XCM_API_KEY 现取 XCM_RELAY_BASE_URL(缺省 https://xcmapi.org/v1)的 /v1/models,只返回模型 id(1 小时缓存),key 绝不进响应;中转挂了或未配 key 时 502,不编造列表;console 的模型下拉用它,取不到时回退手动输入 |
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 |
任一项目 maintainer+(与备份管理同口径),否则 403;扫完记审计 admin_sweep |
调 sweep_stale_tasks(db_path, threshold_seconds=300)(body 可传 threshold_seconds 覆盖默认值,下限钳到 60 秒);把 running 且 last_heartbeat(无心跳则用 updated_at)早于 now - threshold 的任务改为 interrupted 并记审计 sweep_interrupted;返回 {"swept": [ids]} |
POST /admin/backup |
maintainer 以上 | Postgres 时调 backup_postgres、SQLite 时调 backup_db(body 可传 out_dir,否则用 CLOUD_BACKUP_DIR 或默认 cloud-backups);返回 {backend, path, size_bytes, task_count},绝不返回数据库密码或备份文件内容 |
GET /tasks/{id}/cost |
该任务所属项目 read |
成本聚合:{task_id, project_id, total_tokens, usd_micros, turns, fallback_count, actual_tokens, estimated_tokens, cached_input_tokens, unpriced_actual_tokens};真数与估算分开,无价 actual 行进 unpriced_actual_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 分钟过期 |
GET /oauth/{provider}/callback?code=&state= |
无需 X-API-Key(pre-auth) | 真实浏览器回调:验 state(从 state 记录取回 client_id / redirect_uri)→ 换 token → 真实 userinfo 取身份 → 映射本站用户并签发 API key;成功返回 HTML 成功页(login、user_id、是否新用户、新 API key 一次,含保存与轮换提示,不含 access_token / client_secret / 旧 key);state 无效/过期/不匹配、换 token 失败、userinfo 失败、无 secret 一律 400 错误页且不建用户、不发 key |
POST /oauth/{provider}/callback |
无需 X-API-Key(pre-auth) | JSON 版回调:body {code, state, client_id?, redirect_uri?}(后两者可省、从 state 取回);验 state → 换 token → 真实 userinfo 取身份 → 映射到本站用户(复用 users 表,用户名 oauth:<provider>:<login>)并签发 API key、记审计 oauth_login(轮换时另记 api_key_rotated);state 无效/过期、换 token 失败、userinfo 失败、无 secret 一律 400 且不建用户、不发 key;成功响应里返回一次新 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);sender=None 时用内置真实发送端(webhook/slack 真 POST,webpush/apns/fcm 报缺凭证),都不静默成功;自动触发:worker 在任务终态时调分发(completed/failed/goal-unmet,见“goal 模式”),PR 端点在建 PR 成功时发 review-requested、落盘失败时发 merge-failed,不依赖手动调本端点 |
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 端点(GitHub 已从“只解析”升级为“可执行且幂等”;Slack 仍只解析)
POST /webhooks/github:用webhooks.verify_github_signature验签(请求头X-Hub-Signature-256),secret 从环境变量GITHUB_WEBHOOK_SECRET读。未设 secret 返回503;验签失败 返回401(两者都在去重之前,避免未验签的重复送达污染去重表)。 顺序:验签 -> delivery 去重 -> 解析 -> 执行建任务。- delivery 去重:请求头
X-GitHub-Delivery做幂等,去重记录持久化 到数据库新表webhook_deliveries(delivery_id 主键、event、repo、 created_at),不是只放内存;同一 delivery 第二次送达直接返回200 {"duplicate": true, ...},不重复建任务、不重复记审计。 并发/去重兜底:任务另有idempotency_key = webhook:<delivery_id>, 冲突时回读已建任务而不重复建。 - PR 评论
/fix-review执行:解析成功后在控制面数据库里建一个queued任务,prompt 为[webhook /fix-review] repo=<repo> pr=<PR号> author=<作者>\n<评论原文>,并记审计webhook_fix_review,返回task_id与project_id;webhook 处理里不直接跑模型,模型执行 仍是 worker 认领后做的事。任务归属按项目名 == repo full_name 匹配; 没有匹配项目时不凭空建项目、不建任务,返回task_id: null并说明 原因(worker 的 repo 仍是配置里的 repo,不是任意 GitHub 仓库的 自动克隆)。 check_run/check_suite解析:parse_github_check_run只在 conclusion 已定(success / failure)时返回结构化结果(repo、 head_sha、conclusion、check_name),handler 直接返回该结果而不建 任务、不改云端任务状态;结论进行中(None / queued / in_progress / neutral / cancelled 等)时返回{"result": null}。CI 状态回填仍由pr-checks主动拉取,webhook 只做通知解析,不要假装 webhook 已 自动更新了云端任务状态。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 表。两者配合
才算一个完整项目,不要只调一处。
命令台(v1)
GET /console 出一个纯静态单页(cloud/console.html,无构建步骤),
与控制面同源挂载。页面本身不走鉴权(只是个壳,没有 key 时调不到
任何数据);数据一律凭用户在浏览器里填的 X-API-Key 调接口,key 只
存在本浏览器 localStorage。v1 功能:项目列表/新建、任务列表(10 秒
自动刷新)/新建(含 goal 模式)/详情(result/error、成本)/重试/
取消/合并/提 PR、workers、健康状态。访问方式:公网入口已开
(2026-10-27):https://api.diante.me/console,nginx 在 443 块加了
5 个 location(/console、/tasks、/projects、/workers、
/health,全部指向双机上游;页面用绝对路径调 API,所以 API 路径
必须同批代理,否则页面能打开但数据全失败),改前备份为
api.diante.me.conf.pre-console。注意宝塔重生成 vhost 会吃掉这些
location,被吃时从备份贴回再 reload。数据仍凭 X-API-Key,
/health 本来就是无鉴权设计且不含敏感信息。
任务附件(放进任务工作区)
命令台新建任务时可附文件、文件夹、图片(三路输入),提交时先
POST /uploads(multipart:paths JSON 与 file0… 一一对应)
拿 upload_id,再 POST /tasks 带 upload_id 绑定;图片与普通
文件不区分,一律是 worktree 里的文件(模型能否“看懂”图片取决于
模型多模态能力,本链路不保证)。派发时 worker 经 host.py
--seed-dir 把暂存目录拷进 worktree(dispatch/goal 首轮才 seed,
run 续跑不重复拷,文件已在 worktree 里);Docker 任务把暂存目录
只读挂载进容器(/tmp/codex-seed:ro),cloud 执行器拒绝带附件的
任务。限制:单包最多 200 文件、单文件 20MB、合计 100MB、整个
multipart body 200MB(超了 413);路径拒绝对路径、..、空段、
反斜杠、NUL、盘符;包 24 小时过期、建任务绑定后置 used(一包
只能用一次,他人包 403、cloud 任务 400);任务终局后 worker 删
暂存目录,取消时 server 侧也删(删不到不报错)。暂存根默认
/opt/codex-app-server/work/uploads,CODEX_UPLOAD_DIR 可覆盖,
server 与 worker 两边必须一致(否则 worker 找不到包,派发静默无
附件)。公网 nginx 须代理 /uploads(与 /tasks 同批),否则页
面上传 404。注意:popen 路径的 goal 命令此前强制要求
verify_command,goal 新语义(无验收)任务到本地 worker 会直接
报错,本批顺手修成与同步 goal_task 一致(无验收不传 --verify,
轮次 0 原样传)。
旧任务继续会话(终端任务接回来再跑一轮)
详情页对终端任务(completed/failed/interrupted/merge-failed)
提供“继续会话”:填本轮消息 + 模型/强度/权限/计划/goal 全可选
(不传的沿用任务原值),POST /tasks/{id}/continue 把任务重
新排队,worker 认领后在同一会话线程上再跑一轮——不是新任务
(原 prompt 不动,只换本轮输入),也不是分支(不建新线程)。
新消息存在 tasks.resume_message 列,worker 跑完清掉;进程中途
被杀时清不到,下次认领重跑同一条消息,不静默丢消息。
goal 模式的继续:server 把任务转成新 goal 语义(goal=新消息、
验收清空、轮次 0),worker 走 host.py goal-resume --goal-text
在旧线程上开新目标循环(不限轮次,模型宣告完成才结束);
普通任务的继续走 dispatch 撞已存在 → run 续跑(与重试同款)。
不适用:进行中任务请用后续消息(queued/running 各 409),已
合并任务 worktree 已删、请分支(merged 409);冲突与新建一致
(plan + approval=full、plan + goal 各 400)。
主线 /goal 命令(发给主线一句话就自动建 goal)
不用先知道主线是进行中还是已终态,也不用勾选项:给主线发
一条含触发词的消息即可,例如“请在这条主线开启一个
持久 /goal,目标是收xxx”,或直接写“/goal 收xxx”。斜杠
不用:单发 goal 也同款触发,例如“goal 收xxx”、
“goal,目标是收xxx”。解析在
cloud/goal_command.py(server 与 worker 共用同一份,防两端
判断不一致);只是在讨论里提到“/goal 的用法”“goal 是什么”
这种不算命令。
新建目标也同款:POST /tasks 的 prompt 里直接写 /goal 收xxx(或上面那句整话)即新建 goal 任务,prompt 与 goal
都存解析出的目标,不用勾 goal 选项;空目标、plan 冲突、
带验收命令各 400。入口三处同款:POST /tasks/{id}/followups、
POST /tasks/{id}/thread/message、POST /tasks/{id}/continue
(message 字段);响应里 delivery 说明实际走了哪条路:
queued 主线原地转 goal(goal_converted)、已终态复活并转
goal(goal_revived)、running 主线先排队(goal_queued,
本轮不打断,worker 在本轮完成、排空到这条时把主线转成 goal
并回 queued,下次认领走 goal-resume 在同一 thread 上开新
目标循环;本轮若失败,这条留在 pending,等下次成功轮次再转)。
拒绝口径:已是 goal 任务 409(不重复创建)、plan 任务 400、
merged 409、空目标 400。goal 语义与新建 goal 模式一致:
不限轮次、模型宣告“目标已达成”才结束,宣告后即终态,不
是永不结束的常驻采集——需要继续收时再发一次 /goal 续开
新循环。
不靠关键词的另一条路(LLM 自己判断):会话里的 agent
也可以在回合输出里写 goal-convert 块把本会话转成 goal,
或写 automation-create 块新建自动化定义(首次运行时刻
first_run_at、只跑一次 once、绑回本会话 bind: "heartbeat"),worker 在轮次边界执行,格式与口径见
docs/marker-tools.md 新增的两节。本批刻意不向 LLM 自动
注入这两个工具的指南(避免 prompt 臃肿),需要时由用户在
会话里手动告诉 agent;服务端不按关键词再猜意图。
任务间互发消息(peer messages,Muse 式通用互发)
任意两个同项目任务可以互发消息、对方做完可以回消息,不 用经过用户中转。两个发送方:
- API:
POST /tasks/<目标>/messages,body{from_task_id, body, mode}(mode 缺省 queued);调用方 须对目标项目有 create_task、对发送任务项目有 read(防冒 名)。GET /tasks/<任务>/messages列出该任务发出的消息 (收到的在/followups里,带 sender_task_id)。 - Agent:任务里跑的 agent 在回合输出里写标记块,worker 在
回合结束后解析并投递——agent 不用联网、不用凭证,本地
与 Docker 执行器都通。块格式见
examples/peer-message-prompt.txt;每轮最多 5 块。
投递语义(核心约束:互发消息不中断任务):
- 目标排队中 / 运行中:消息排成一条后续消息(带
sender_task_id),worker 排空循环在轮次边界执行——direct
优先、队内 FIFO,与用户后续消息同款,当前轮绝不被打断。
接收方 agent 看到的 prompt 带发送方前缀
[来自任务 <id> 的消息],凭此知道回给哪个任务。 - 目标已终态(completed / failed / interrupted):消息复活 它——把带前缀的正文写进 resume_message 并把状态置回 queued,下次认领按续跑跑这一轮。没有复活,B 回给已结束 的 A 的消息会直接丢失,A→B→A 的往返没法成立。
- 一律拒绝(409 或投递层跳过,只记审计):发给自己、跨 项目、目标是 goal 任务、目标已合并、目标已有待续跑消息 (复活位被占)、目标不存在。
- 只有 completed 轮次的标记块被投递(失败 / 超时 / 取消 / 配额中断的轮次不投递);goal 任务的回合不解析标记块,且 目标是 goal 时直接拒绝——所以 goal 任务既不收也不发。
- 投递不查发送方配额(发送本身不花钱),但目标任务执行时 的配额门照常生效:排空前查配额,超限时该条回 pending、 任务走配额中断;复活任务认领时认领门同样生效。互发消息 不构成配额绕过。
- 边界:标记块正文不能含围栏(非贪婪匹配会把块截断成非法 JSON 而被跳过);worker 投递的 direct 消息只有排空优先 权,没有 API 直接发送那种“立即进链路”的标记(worker 侧 链路目录不一定在本机);复活投递的消息没有 followup 行, 发出列表里查不到,查审计 peer_message_sent 与目标的续跑 轮次;控制台“给其他任务发消息”框走 /tasks 下的 /messages,不用新增 nginx location。
测试
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 真实浏览器 登录已接通(GET 回调 + 真实 userinfo + 回调签发 API key),但 真实应用注册、回调公网 https 入口、已有账号绑定仍缺(见上文 “OAuth 真实浏览器登录”一节的部署前置);推送已接成订阅 + 分发端点,但 Web Push / APNs / FCM 的真实凭证与发送端仍缺; 单机 SQLite 在高并发写下仍有上限,生产量上来后需换 Postgres (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,不只改数据库状态。 - 多租户隔离(
--tenant-isolation,仅 Docker 执行器):开启 后每个项目用独立的宿主目录tenants_root/<项目>/work、codex-home、test-repo 副本与 repo 检出,容器内路径与 非租户模式完全一致(/app/work、codex-home、repos/<项目>、 test-repo),只是宿主挂载源按项目分开——容器内看不到其它 项目的任何文件;同时每个项目进独立的 Docker 网络codex-<项目>(容器跨项目网络隔离),容器统一加--cap-drop=ALL --security-opt=no-new-privileges。codex-home 只播种模板的 config.toml(relay 端点与 provider 名,不含 密钥值),绝不复制 thread_history。注意:内层 codex 沙箱仍 是 danger-full-access(bwrap 在 Docker 容器内不可用:嵌套 pidns 下 fresh proc mount 内核 EPERM,宿主级则可用,见下), 隔离边界是容器 + 挂载 + 网络三层,不是内层沙箱。 - 外层加固(内层保持 danger-full-access 时的补偿层):
--docker-security-opt(可重复,如apparmor=docker-codex, 见deploy/apparmor/docker-codex:在 Docker 默认 profile 基础上禁 raw 网络、ptrace、mount 与危险 capability;注意 它不含userns,即不启用 bwrap 内层沙箱路线);--docker-pids-limit(默认 512,防 fork 炸弹);--docker-runtime(不传 = docker 默认 runc;传runsc= 任务容器走 gVisor syscall 沙箱,需 daemon 已注册该 runtime, 否则起容器失败;生产 autoscaler 已配 runsc:2026-10-26 一次性 worker 模型任务验证通过,60 秒 completed, AppArmor docker-codex + 租户隔离 + egress 代理组合可用);--egress-proxy-port(0 = 不启用;生产配 8888):启用后 Docker 任务容器的 HTTP(S) 出口全部走宿主上的 Tinyproxy (见deploy/egress/:FilterDefaultDeny 白名单,只放行 relay、github、pypi、npm 域名,其余 403;网关查不到时 任务直接 failed,绝不无代理运行)。seccomp 现状诚实说明: Docker 默认 seccomp profile 已在所有任务容器上生效(这是 不弱的基线),本轮没有新写一个更严的 seccomp allowlist—— 从记忆里拼一个 allowlist 极易漏掉 codex 工具链要的系统调用 而静默破坏任务,正确的更严 profile 需要先对 codex 做系统 调用画像,那是下一项工作,不是本轮假装做到的。 - 宿主级沙箱任务(与 Docker 任务二选一,绝不叠加):
codex 的 workspace-write(bwrap)在 Docker 容器内不可行
(嵌套 pidns 下 fresh proc mount 内核 EPERM,宿主级则可用),
所以需要内层沙箱的任务改走宿主级:worker 用 LocalExecutor +
host-config-host.json(sandbox workspace-write,state 独立 为 work/host-state-host.json),以aa-exec -p codex-host运行(见deploy/apparmor/codex-host:docker-codex 基础 + userns/mount/pivot_root,仍禁 raw 网络与 ptrace;capability 必须放行——codexsvc 本身无宿主 capability,放行的只是 bwrap 的 userns 内假 root capability,实测 deny sys_admin 会让 bwrap 在 userns 创建处失败), 并带HTTP(S)_PROXY=http://127.0.0.1:8888让 relay/git 出口 同样走 Tinyproxy 白名单。路由靠任务的 executor 字段 (POST /tasks 已支持 local/docker/cloud):宿主 worker 必须 开--claim-executor-only,只认领 executor='local' 的任务, 绝不抢 executor 为 NULL 的任务(否则会把本该进 Docker 的 任务拉到宿主上跑,丢了 Docker 外层)。宿主任务放弃 Docker 外层(无容器、无租户挂载/网络隔离),换来的是 bwrap 内层。 实测过的沙箱边界(诚实版):agent 只能写自己 worktree, 写宿主其它目录与其它任务 worktree 均被挡(报文件系统只读); 但 /tmp 不隔离——agent 写 /tmp 会落到宿主 /tmp 上(codex 的 workspace-write 不把 /tmp 做成隔离 tmpfs),所以 /tmp 是任务间的共享区,不能当作隔离边界。 - 网络白名单的诚实边界:docker run 默认 bridge 网络,本执行 器不做 egress 白名单——真正的“只放行模型 API”需要宿主防火 墙或代理规则,那是部署项,不是本轮代码假装做到的,不要宣称 已实现网络白名单。另有一项待实测:codex 自带的 bubblewrap 沙箱在容器内是否可用,取决于宿主的容器权限配置,本镜像不 假装解决;若 bwrap 不可用,host.py 的 sandbox 参数仍可退到 read-only / workspace-write 的控制面约束。
- 仍需在外部主机上实测(本 VM 没有 Docker,本轮未连外部主机): 真实 docker build、真实 docker run、资源限额在真实负载下的 表现、bwrap 在容器内的可用性、以及取消时容器确实消失。
生产级补齐(本轮新增:webhook 执行、备份、限流、超时/重试、清理兜底)
- Postgres 备份:
cloud/backup.py的backup_postgres(out_dir, keep=3, runner=None, pg_dump_cmd="pg_dump"),数据库名/用户/密码只从CLOUD_DB_NAME/CLOUD_DB_USER/CLOUD_DB_PASSWORD环境变量取, 密码只经PGPASSWORD环境变量传给 pg_dump 子进程,绝不出现在 命令参数、返回值或日志里;pg_dump 缺失或非零退出时明确抛错; 成功返回备份文件路径(轮转只留最近keep份.sql),server 的POST /admin/backup(需 maintainer 以上权限,body 可传out_dir, 否则用CLOUD_BACKUP_DIR或默认cloud-backups)返回 backend、 path、size_bytes、task_count,绝不返回数据库密码或备份文件内容; SQLite 分支(backup_db)原样不动。Postgres 备份仍需在真实环境 验证一次真实pg_dump(本轮只用 fake runner 单测)。 - 限流:server 的
RateLimiter(内存滑动窗口,短锁只围住 deque 剪枝/计数,不做全局锁、不串行正常请求),默认每分钟 120 次 (环境变量CLOUD_RATE_LIMIT_PER_MINUTE/CLOUD_RATE_LIMIT_WINDOW_SECONDS可调,默认 120 / 60 秒); 限流在健康/OAuth/webhook 之后、认证之前,超限返回429 {"error": "rate_limited"},不记为认证失败;key 按 API key 的 sha256 计(原始 key 不存),无 key 时按客户端 IP 计;测试可用server.rate_limit_per_minute注入小阈值验证 429。 - 健康与指标补齐:
GET /health(不认证也通)除ok外返回backend(sqlite / postgres,取CLOUD_DB_BACKEND,绝不返回 连接串或密码)与db_ok(一次轻量SELECT 1成功与否);GET /metrics的by_status恒含七个状态键(queued / running / completed / failed / interrupted / merged / merge-failed),缺失 状态为 0 而不是缺键。 - 任务级超时:worker 的
--task-timeout(默认 1800 秒,0 表示 不限时、与 host--turn-timeout 0同约定,worker 级不设截止, 但 Docker 路径仍受--docker-timeout约束),与另外 两个参数不要混淆:--docker-timeout是执行器级的单次 docker run 超时,--max-seconds是run_loop的整体墙钟上限;三者互不覆盖, Docker 路径下 worker 取min(task_timeout, docker_timeout)(docker_timeout 未设时取 task_timeout)作为本次 docker run 超时, Local popen 路径下 worker 在轮询循环里到截止时间就杀进程组; 超时后真杀容器/进程组、任务置failed(超时是失败,不是取消, 绝不标 interrupted),并记worker_timeout审计与错误文本task timeout after <秒> seconds;Local 同步路径(假 runner / 无 超时的 subprocess.run)无法中途抢占一个已阻塞的调用,超时只能靠 执行器自身的 timeout,这是诚实边界,不要假装同步路径也能中途杀掉。 - 失败重试与退避:worker 的
--max-retries(默认 1 次重试,0 表示 不重试)与--retry-backoff-seconds(默认 5 秒,测试可注入 0); 取消不重试,其它失败(含 worker 超时)重试前按固定退避等待; 重试次数记在审计(worker_retry)与最终error_text的[retries=N]前缀里,不另起新表;重试耗尽后任务才最终 failed, 成本记录与 usage_turns 只按最终结果记一次,不按尝试次数重复记。 - 模型 fallback(显式配置时真换模型重试一次):worker 的
--fallback-models(逗号分隔的模型列表,默认空 = 关闭)。 未配时保持老行为:可 fallback 的失败只记model_fallback事件 与worker_fallback审计、fallback_count=1,然后直接 failed, 不换模型。显式配了时,一次尝试失败且错误可 fallback(404/429/ 5xx/timeout;401/400 绝不 fallback,取消绝不 fallback),就先记 事件与审计,再用列表里的第一个模型在同一任务、同一 worktree/分支 上再跑一次(dispatch 报“任务已存在”时走现有的 run 续跑路径, 模型经 host.py 的顶层--model参数传过去,不改 host.py 签名); fallback 总次数最多 1 次,配两个模型也只试第一个,不会无限换下去 烧额度,且与--max-retries无关(--max-retries 0时 fallback 照样跑)。第二次的结果就是最终结果:成功则任务 completed,actual_model写成 fallback 后的模型,成本记录fallback_count=1; 第二次也失败则任务 failed,error_text同时写清主模型失败原因与 fallback 模型失败原因。成本来源与 fallback 无关(见下文“真实成本”)。 - 真实成本(逐任务 actual):host.py 在每回合结束时从 app-server
的
thread/tokenUsage/updated事件里汇总上游真实用量(每个上游 补全发一次,last是该次补全的用量,同一 turn 内累加;中继实测 五次last之和等于最后一次的total,input/output/cached/ reasoning 四路都对得上)。host.py dispatch/run 打印的 JSON 带usage,goal 循环在结论行后另打一行usage input=… cached=… output=… total=… completions=…;执行器解析后 worker 每任务记 一条source="actual"(重试/fallback 各次尝试的用量都累加进 同一条,不按尝试次数重复记行)。没带回用量时(老版本 server、 回合未产生补全)回落字符估算记estimated,两者在报表里分开。 金额 = 实测 token × 价目表:默认是 OpenAI 挂牌价(gpt-6.1-sol $2.00/$0.10/$10.00、gpt-6-sol $2.00/$0.20/$10.00,单位美元/百万 token,依次为输入/缓存输入/输出;缓存是输入的子集,不另加; 推理 token 已含在输出里,不重复算),中继的实际账单可能不同, 可用CLOUD_MODEL_PRICES_JSON或CLOUD_MODEL_PRICES_FILE按模型覆盖;无价模型记usd_micros=None,报表里进unpriced_actual_tokens而不是 0 美元。GET /tasks/{id}/cost与GET /projects/{id}/cost的聚合新增cached_input_tokens与unpriced_actual_tokens;按天行同样新增这两列。注意死胡同: app-server 的account/usage/read在中继上不可用(要求 ChatGPT 账号登录,中继 API key 调它报codex account authentication required),账户级用量不是本功能的来源,不要重走这条路。 - 容器清理兜底:
DockerExecutor.list_containers(prefix="codex-")(docker ps -a --format {{.Names}},失败返回[])与cleanup_stale_containers()(列出并逐个 kill+rm,单个失败只跳过, 绝不抛异常),Worker.cleanup_stale_containers()在 run_loop 启动 与 CLI 启动时各调一次;取消/超时/失败路径上已有的容器清理保留 (docker run--rm、超时 kill+rm、取消按名 kill)。
项目仓库(任意仓库任务)
- 项目级
repo_url:POST /projects接受可选repo_url,GET /projects列表与POST /projects的 201 返回里都含它, 不传时为None(老行为不变)。校验与 worker 共用同一套cloud.repos.validate_repo_url:只接受https://开头(且不含 userinfo,即不含用户名/密码)或git@开头(git@host:path形);file://、ssh://、本地路径、带密码的 URL 一律 400 且不建项目。 - 存储与迁移:
projects表新增repo_url TEXT可空列;SQLite 的init_db建表时就含该列,老库用ALTER TABLE幂等补列(仿照migrate_tasks_table的写法);Postgres 的POSTGRES_SCHEMA同样加该列,init_postgres_schema也对老库补列。 - worker 派发:
run_one派发前查任务所属项目的repo_url,非空时 经cloud.repos.resolve_repo在--repos-dir(默认./repos)下 解析:目录不存在时git clone;已存在时校验它是 git 仓库且 remote origin 与repo_url一致(不一致时报错,绝不强制改 remote),一致时git fetch origin(失败明确报错,不静默用旧代码; fetch 成功后不 checkout/pull/reset/clean,工作树由 host.py 的 worktree 逻辑处理)。解析出的路径设为task["repo"]再交给执行器, Local 与 Docker 的dispatch_task都已读task["repo"],同一条路径; 查不到项目或repo_url为空时走原有默认 repo,不报错;解析失败时 任务置failed,错误文本写清是仓库解析失败,绝不标 completed。 - Docker 下的路径前提:容器内
task["repo"]路径必须与宿主机一致, 所以repos_dir必须放在--docker-extra-mounts已挂载的目录下 (例如把/var/lib/codex-app-server/repos以同路径挂载进容器), 否则容器内 repo 路径不存在——不要假装任意目录都行。生产部署示例:python3 -m cloud.worker --db <库> --executor docker --repos-dir /var/lib/codex-app-server/repos --docker-extra-mounts /var/lib/codex-app-server:/var/lib/codex-app-server,…。 - 仍未做(不要宣称已做):推送到远端明确不在本轮范围——任务在本地
检出上执行、本地 worktree 合并仍按 host.py 现有流程,
git push到远端需要仓库写凭证,控制面不存、不推,所以任意仓库的自动 PR 仍未实现;私有仓库 clone 会失败,错误文本写明“需要仓库访问凭证, 控制面不存仓库凭证”,绝不尝试用任何 key 去 clone;webhook 建的 任务仍需 worker 认领执行;CloudExecutor仍是占位符;公网 https 入口仍需部署侧反向代理 + TLS。
云端环境(选、建、管)
环境是项目仓库的一个命名基准检出,不是官方 Codex 的云端环境 (那是官方产品自己的后端,本控制面没有它的公开调用口,不宣称 能建它)。本控制面的环境管的是主机/Docker 这边的基准:项目 + 仓库快照 + 分支 + 执行器,任务选了环境后仍是一任务一 worktree (host.py 从环境检出 HEAD 为任务建分支),任务隔离、合并、提 PR 全都不变。
- API:
POST /environments(project_id、name 必填,branch 可选 空=默认分支,executor 可选 local/docker;建完 status=creating)、GET /environments?project_id=(默认排除已归档,include_archived=1才含归档)、GET /environments/{id}、POST /environments/{id}/archive。写操作(建/归档)要求项目 create_task 权限,读要求 read 权限。 - 创建规则:项目没有 repo_url 时 400(没有仓库就没有环境);
同项目未归档环境名唯一(409,归档过的名字可重用,部分唯一
索引保证);分支名只允许字母数字与
_ . - /,不以-开头、 不含..、不以.lock结尾;executor 只接受 local/docker。 - 准备:worker 在
run_all每轮开头准备所有 creating 的环境: 目录不存在时git clone,存在时校验 git 仓库且 origin 与环境 repo_url 一致再fetch,分支非空时checkout该分支(不存在 则失败,绝不自动建分支),成功变 ready、失败变 failed 并写 error_text。准备本身幂等,没有 preparing 中间态——worker 中途 死了、环境仍是 creating 时下一轮会重新准备;准备期间用户归档 了环境时 CAS 写回改不到行,状态保持 archived。 - 任务绑定:
POST /tasks接受可选environment_id,必须是本项目 已 ready 的环境(creating/failed/archived 一律 400 并写明状态); 环境 executor 非空且任务 executor 已指定时必须一致,任务 executor 未指定时继承环境 executor(认领过滤靠它);cloud 执行器没有环境检出路径,一律拒绝。worker 派发时环境任务不走 项目仓库路径,直接把task["repo"]指到环境检出(租户 Docker 为容器内路径/var/lib/codex-app-server/envs/<id>,租户挂载里 整目录挂载该项目 envs 目录;非租户为repos_dir/envs/<id>)。 - 管理:归档幂等(已归档再归档返回 200);有 queued/running 任务正在用环境时归档 409;归档只改状态,不删检出目录(已完成 任务的 worktree 独立,检出留着可查,磁盘清理另行手动做)。 重试(retry)只改任务状态,environment_id 原样保留;会话分支 (fork)不继承 environment_id——分支是 thread/worktree 分叉, 不是在环境上重开任务。
- 命令台:项目卡下方新增“环境”卡(列表/新建/归档,creating→
ready 的过程靠 10 秒自动刷新可见,失败原因直接显示在列表里);
新建任务栏新增环境下拉(未 ready 的置灰、不可选),任务列表里
带环境任务会标
[环境 <短 id>]。公网 nginx 若要让页面调通环境 接口,须把/environments同批代理到双机上游(与/console、/tasks、/projects、/workers、/health同款,否则页面能打开 但环境区永远加载失败)。
GitHub 仓库选择(建项目 / 给已有项目补仓库)
控制台以前没有仓库选择菜单:建项目时 repo_url 只是个可选的 手填文本框,不填也能建出项目,之后建环境必被 400 挡住 (“project has no repo_url”),且没有修改接口,死结只能重建 项目。本节补上选择菜单与补仓库接口。
- 列表来源:
GET /github/repos(需认证)列出本服务 GitHub App 安装记录里的仓库(installation_repositories 事件同步进github_app_installations表,字段 installation_id / account_login / repo_full_name,clone_url 由 full_name 拼出https://github.com/<full_name>.git)。只含仓库名,不含密钥。 - 建项目:仓库下拉(从上述接口加载)+ 手填文本框并存:选下拉 即把 clone_url 填进文本框,仍按原有 body 提交;没进 App 安装 的公开仓库继续手动填 URL,不受影响。
- 补仓库:
PATCH /projects/{id}(项目 owner 才能调, maintainer 也不行——改项目归属配置按 manage_project 计),body 必须含 repo_url:字符串走与创建时同一套 validate_repo_url 校验(带凭 证的 URL 拒绝),null 或空串表示清空。改完记 update_project 审计。环境区在当前项目无仓库时直接亮出同一下拉 + “保存仓库” 按钮,保存后立即可以建环境(截图里的死结就卡在这里)。 - 快照语义不变:补仓库只改 projects 行,已建环境的 repo_url 快照不动(仍用建环境时的仓库),新环境才用新值。
- 信任模型说明(诚实边界):下拉没有放宽任何权限——手填 URL
本来就能填任意地址,选下拉与手填是同一能力;按用户隔离仓库
可见范围(例如只列该用户 GitHub 身份名下的安装仓库)是后续
工作,本轮不做。公网部署时 nginx 须同批加
location ^~ /github/repos(/projects已有 location,PATCH 走它,不用重复加)。
租户配额与计费
- 口径:配额是按用户、按 UTC 自然月的 USD 上限(
user_quotas表,quota_usd_micros为 NULL 表示不限额,0 表示一分钱也不 能花;金额单位与 cost_records 一致,都是微美元,避免浮点)。 用量只算usd_micros非空的实际成本行(estimated 行 usd 为 NULL,SUM 自动跳过),按任务创建者created_by归属——一个 用户在多个项目里花的钱都算进他自己的月度账单;项目维度的 拆分由项目成本接口另行提供。 - 准入:建任务、会话分支、重试、续跑、goal 断点续跑在权限
校验通过后、写库之前查配额,本月用量 >= 配额时回 402
quota_exceeded(附 month、quota_usd_micros、used_usd_micros), 不建任务、不改状态。老用户与未设配额的用户默认不限额, 行为与过去一致。但 OAuth 新用户不再是“建号即不限额”: 建号(created=True)时写一行默认配额 (CLOUD_DEFAULT_QUOTA_USD_MICROS,默认 10_000_000, 即 $10/月;INSERT OR IGNORE幂等,不覆盖管理员后来改过 的额度),并记quota_default审计。goal 断点续跑端点 (G10):POST /tasks/{id}/goal-resume(权限 approve_merge, 同重试),仅 interrupted 的 goal 任务可续(goal-met/goal-unmet 已终局、failed 按旧规则新建、非 goal 任务一律 409),走 host.py goal-resume(无 --goal-text)的断点语义——从 goal_rounds_done 的下一轮接着跑同一循环,保留原线程与 worktree,状态回 queued 并置 goal_resume_pending(worker 认领后清掉),写task_goal_resumed审计;/continue开新循环的语义不动,不要把两者混用。重试与续跑按任务 创建者查配额(费用本来就记在创建者名下;维护者替他人 重试/续跑时,操作人超限不应拦、创建者超限才应拦——旧版 重试按操作人查是错位,本轮已改)。后续消息 (followup)创建不查配额:排队本身不花钱,执行时轮次边界 的配额门照常生效,不要把 followup 也改成准入校验。 - 费用归属与豁免(G13/G14,写死口径):系统任务豁免——
webhook /fix-review 等 created_by='system' 的任务在服务端
准入与 worker 认领两处都不查配额(
_check_quota与_quota_exceeded里 user_id == "system" 直接放行,单测 覆盖);这是写死的约定,不是可配置项。反过来,由用户动 作触发的 /pr 任务不再走 system 豁免:POST /tasks/{id}/pr在 merged 校验通过后、执行/排队之前先按请求用户查配额 (超限 402、查询异常见下 503,均不改状态、不排队), 队列路把请求用户写进 pr_jobs.created_by(老表缺列时 ensure 时 ALTER 补列,老行 created_by 为 NULL 时 worker 跑它记审计回落 "system"),worker 的 pr 任务循环把 created_by 当审计操作人传给 execute_task_pr(同步路本 就传调用方,不动)。注意这是一个有意收紧的口径,供合 并前推翻:PR 本身不跑模型回合,纯 git/gh 操作几乎不花 钱,若认为“已完成工作的 PR 不应被配额挡住”,可把 /pr 的配额门改回豁免而不动归属列(created_by 照常记录)。 - 配额查询异常的分层(G7,写死口径):新建/认领类门
(建任务、分支、重试、续跑、goal-resume、/pr)查配额抛
异常时 fail-closed——服务端回明确的 503
quota_check_unavailable(不是 500 断连),不建任务、 不改状态、不排队;worker 认领门同样标 interrupted,错 误含quota_check_unavailable,可重试/续跑,绝不在“不 知道超没超”时开跑。执行中轮询(Local 30 秒、Docker 轮 询见下)查配额抛异常时 fail-open——只记 warning,不杀 在跑任务,只有确认超限才掐断;轮次边界的复查同款 fail-open。实现上查询函数_quota_limit_and_used自己 绝不吞异常冒充“不限额”,分层由调用方 on_error 参数决定 ("closed" 门侧、"open" 轮询/边界,缺省 open)。 - 无价模型 token 上限:价目表目前只有两个 sol 模型有价,默认
模型 gpt-5.6-terra 等中转模型无价、成本行 usd 为 NULL,USD
配额对它们永远不生效(按 0 计)。因此有配额行的用户另受一
道体积护栏:当月无价模型 token(input + output,已记账 +
本轮,usd_micros IS NULL 的行)达到
CLOUD_QUOTA_UNPRICED_TOKEN_CAP(默认 20_000_000/月)即 停——服务端建任务回 402quota_exceeded(附 unpriced_token_cap、unpriced_tokens_used),worker 侧认领/ 重试/后续消息轮次同口径拦截。这道是防滥用的体积上限,不 是计价:成本报表里无价 token 仍进 unpriced_actual_tokens, 绝不折算成假美元;无配额行的用户不受限。 - 执行时加固(worker 侧,
Worker._quota_exceeded,口径与准入 一致:当月已记账 + 本轮已累计用量,按价目表换算):认领后 开跑前再查一次(排队期间配额被花完/调低时不跑,任务标 interrupted + 配额原因、不记成本、续跑消息不消费);重试与 fallback 换模型前各查一次;后续消息每轮开跑前查一次(超限 时该条回 pending、任务 interrupted,不再排空剩余;建消息时 不查,排队本身不花钱);Local popen 路径在轮询里每 30 秒查 一次,超限时杀进程组(goal 长循环也一样,杀的是整个子进程 组);Docker 路径同款粒度(G8):_docker_call_with_quota_poll把阻塞式 docker 调用放后台线程,主循环每quota_poll_interval_seconds(缺省 30 秒,单测可传小值, 生产不要改小)查一次,确认超限时按任务容器名真杀容器, 结果改写成内部标记quota_exceeded后走统一收尾——状态 一律 interrupted、已花成本照记 actual、续跑消息保留,配额 命中后不走 ALREADY_EXISTS 回落重跑。同步路径(非 popen、 非 Docker)仍只有边界检查,单轮内的超支要到轮次结束才停。 配额中断的任务一律 interrupted(可续跑:顶上来后重试 /续跑继续),错误文本带quota_exceeded,已花成本照记 actual,不发终态推送(与取消同款)。诚实边界:轮询查的是 已记账用量,单轮本身花的钱无法中途按 token 流式截断(中转 用量只在回合结束才回传),所以“掐断”粒度是轮次边界 + 长轮 询中的 30 秒检查,不是 token 级;真 Docker 掐断未实弹(只 由假执行器单测覆盖),部署后需实弹;并发下两个任务同时查 到未超限再同时开跑会有少量超支(与限流器同款 best-effort, 非精确配额)——G15 并发预留不在本批,单独立项。另两条 接受现状的边界:配额按 UTC 自然月切分,与 host 本地日期 不一致时以 UTC 为准(月末跨月任务的用量按记账日归属); 配额改动对在跑任务只在轮询/轮次边界生效,不会立刻掐断 已经开跑的回合(除非下一次轮询查到超限)。 - host 预算与云端配额的两套门(G9):worker 路径默认忽略
host 的
max_daily_turns预算——worker 调 host.py 时传顶层--ignore-host-budget(LocalExecutor 与 DockerExecutor 都 在子命令之前追加该标志;worker CLI 缺省开,可用--no-ignore-host-budget关掉),host.py 里只清max_daily_turns这一项(_effective_cfg,notify_command 等其它配置不动),dispatch 前置门、重试前检查、goal 循环 顶检查一并放行;云端 per-user USD 配额 + worker 认领/轮询 门才是 worker 路径的口径,host 单机 turn 数预算再拦一次只 会误拦云端已批的任务。standalone 直接跑 host.py 时不传 该标志,预算照常生效。两套门同时开(--no-ignore-host-budget) 时任务要同时通过两道门,超任一道都停。 - 接口:
GET /users/me/quota(自查,返回 month、quota、 used、remaining,以及当月无价实际 token 数unpriced_actual_tokens——价目表里没有该模型价格的实际 token 只上报、不计价、不拦任务,绝不编造一个价格把它折 算进 used;不限额时 quota 与 remaining 为 null);管理端点 的配额返回同样含unpriced_actual_tokens;GET /admin/quotas?user_id=与POST /admin/quotas(body:user_id、quota_usd_micros,null=不限额,负数 400, 用户不存在 404,改完记quota_set审计)。管理口径沿用/admin/backup的做法:在任一项目里是 maintainer/owner 即 算管理员,没有任何项目角色的用户无权改任何人的配额。 这还不是真正的收款:没有接支付网关、不开发票、不扣款, 只有记账 + 配额 + 超限拦截;要收钱需另接支付(价格表在cloud/cost.py,注意那是 OpenAI 牌价、不是中转站的账单)。 - 命令台:API Key 卡下方显示本月用量与配额(10 秒自动刷新; 超限建任务时接口的 402 会带“本月配额已用完”提示)。管理 员改配额目前只走 API,控制台没有改配额的表单。
- 公网 nginx:注意,443 块的默认
location /是代理到主机127.0.0.1:28080(不是控制面),而/users、/admin目前没有专属 location——配额接口部署上生产时须同批加location ^~ /users/me/quota与location ^~ /admin/quotas(只放这两个精确前缀,不要整段放行/users或/admin, 否则会把/admin/sweep、/admin/backup也暴露到公网), 否则公网页面调配额接口会落到 28080 而不是控制面。
sub2api 网关(每用户独立网关账号)
- 开关:
CLOUD_GATEWAY(默认xcmapi;设为sub2api即切流)。 切流前 xcmapi 保持现网;切流本身只是改环境变量 + 重启服务, 不动任务数据。SUB2API_BASE_URL(网关 origin,如http://172.17.0.1:28084,桥接 IP 供宿主与容器同达)、SUB2API_ADMIN_EMAIL/SUB2API_ADMIN_PASSWORD(系统管理员 账号)、SUB2API_GROUP_NAME(默认codex-system;或用SUB2API_GROUP_ID直接指定组 id)是开通必需的配置。 - 开通:OAuth 建号时 best-effort 调管理 API 开通(失败记
sub2api_provision_failed审计,建号本身不失败);任务开跑 前 worker 按created_by幂等开通(user_gateway_keys表: user_id → sub2api_user_id、api_key、refresh_token),失败则 任务直接 failed,绝不跑错网关。协调员子任务无 created_by 时 跟父任务属主走。key 明文存 PG,与 GitHub App 私钥同一信任 边界(库只在宿主 loopback 可达);at-rest 加密是后续项。 开通同时授初始余额:CLOUD_SUB2API_OPENING_BALANCE_USD(默认 10 美元,显式配 0 表示不授;非法/负值回落默认)—— 网关鉴权层在余额 ≤ 0 时直接 403,不授余额的 key 立刻不可 用;授余额失败时 key 与账号一并清理再抛错,不留孤儿。 - 注入:worker 在三条执行路径(Local popen、Local 同步、Docker
同步)都注入任务级 env:
SUB2API_API_KEY(属主的 key)、SUB2API_BASE_URL,并把网关 host 追加进 NO_PROXY(否则 codex 经 egress 代理访问内网网关会被挡)。host.py 以-c注入 provider(model_provider=sub2api+ provider 表,不改 任何租户的config.toml,多网关任务同项目并发互不干扰), 未配SUB2API_BASE_URL时 loud fail。#86 的 shell exclude 模式已覆盖*_KEY,agent shell 看不到该 key。 - Docker 密钥通道:
SUB2API_API_KEY在 docker 命令里只传名 (-e SUB2API_API_KEY),值由 docker CLI 子进程环境提供 (_docker_cli_env),绝不进 argv(ps/docker inspect 可见 argv);runner 不支持 env 时 loud fail。其它 extra_env 仍按-e NAME=VALUE(非密钥变量)。 - 模型目录:
/relay/models在 sub2api 下返回调用者自己的网关 模型(key 绑定的组决定);无 key 时返回空列表 +provisioning: pending,不在页面加载时悄悄开通。任务未指定 模型时:CLOUD_SUB2API_DEFAULT_MODEL优先,否则取该用户 网关模型首个;组里无模型时任务 failed( loud fail,不拿 xcmapi 的模型名去撞网关)。sub2api 模型目前无价目表,成本 走无价 token 上限那道护栏(见上节),报表记 unpriced。 - 诚实边界:本批只做到“网关可配置 + 任务注入 + 模型目录走 网关”;真模型调用实弹需上游账号挂到组里后才做(切流前置 条件),fallback 模型名在 sub2api 下须配网关侧的名字。
运维批(本轮新增:部署、恢复演练、监控、心跳、多用户验证)
细则在 deploy/README.md,这里只记代码侧的接口与默认值:
- 部署与回滚(
deploy/deploy.sh/deploy/rollback.sh,VM 侧跑, 不碰cloud/代码):部署前在主机上备份当前/opt/codex-app-server到/opt/codex-app-server-backups/(时间戳命名,只留最近 3 份,排除 work/),解包新代码、chown codexsvc、重启 server+worker、循环验localhost:8765/health最多 60 秒(要求 ok=true、backend=postgres、 db_ok=true),失败自动恢复备份并重启;回滚只回代码,不回数据库 (迁移只加列、幂等,老代码仍能读新列,见 deploy/README 的回滚边界)。 两个脚本都不接受密码参数、不读 env 文件、不打印密钥;需经代理时用 环境变量SSH_PROXY_CMD传入 ProxyCommand,未设则直连。 - 备份恢复演练(
cloud.backup.restore_postgres+deploy/restore-drill.sh, 主机侧跑):restore_postgres(backup_file, target_dbname, runner=None), 用 psql 把 .sql 备份恢复到目标库;目标库名等于CLOUD_DB_NAME时 直接拒绝(除非显式传allow_production=True,演练脚本永远不设它); 备份文件不存在或为空时报错;密码只经 PGPASSWORD 环境变量传,绝不进 命令行、返回值或日志。演练脚本先跑一次backup_postgres生成新鲜 备份,再恢复到codex_control_restore_test_<时间戳>测试库,比对 tasks/projects/users/audit_log 四张表行数(只比行数,不打印行内容), 比对完立即删测试库(trap 保证中途失败也删);绝不恢复到生产库, 绝不删生产库。 - 监控与心跳:worker 的
run_loop每次循环结束时写心跳文件,内容只有{"last_seen": epoch秒, "pid": 进程号},写失败只记日志不炸 worker; 路径由--heartbeat-file指定,未指定时看环境变量CLOUD_WORKER_HEARTBEAT_FILE,再未指定则为./worker-heartbeat.json(VM 本地默认,主机路径不在代码里硬编码;主机部署时两边指到同一个/var/lib/codex-app-server/worker-heartbeat.json,server 侧在 env 文件 里加CLOUD_WORKER_HEARTBEAT_FILE一行,worker 单元加--heartbeat-file参数,deploy.sh 不自动改 env 文件)。GET /health在原有 ok/service/backend/db_ok 之外加worker_last_seen_seconds_ago(无文件或读失败时为 None)与worker_ok(心跳 180 秒内为 true, 阈值可经CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS调,默认 180); health 本身仍返回 200,worker 不 ok 由调用方看字段判断,不要改成 503。deploy/monitor.sh(主机侧,codexsvc 可读,不读 env 文件)检查 server/worker 的 systemctl 状态、health 四项、pg_isready、docker ps 里 codex-postgres 在运行、磁盘 <90%、app 目录存在,输出一行 JSON,任一 项 fail 则退出非零;--self-test只验证脚本机制(health 解析 + JSON 合法),不验证主机健康。deploy/monitor.service/deploy/monitor.timer每 5 分钟跑一次,日志进 journal。 - 多用户真实验证(
deploy/verify-multiuser.py,主机侧以 codexsvc 身份 跑):内存里建 4 个测试用户与 2 个测试项目(P2 只有 owner 是成员), 用 HTTP API 验证建任务、读任务、merge 的角色权限与跨项目隔离; merge 用的 completed 测试任务是直接在 DB 里置 completed,不跑模型; 测试任务全部带永不存在的依赖multiuser-test-never-completes,生产 worker 只派依赖全部 completed 的任务,所以测试任务永远不会被认领去 跑模型,不花模型钱、不改仓库;测试行全部用multiuser-test-前缀, 清理时只删这些行,清理失败退出非零并列出残留 id(不含 key);审计 日志是 append-only 的,验证审计条目会留下,脚本不删审计表。 - 仍未做(不要宣称已做):推送告警通道(monitor 失败时通知到 Muse 聊天 的通道还没定);回滚不回数据库(改类型/删列的迁移需单独评估);恢复 演练只比行数,恢复到生产库的真实灾难恢复流程还没演练过;部署脚本、 恢复演练、monitor、多用户验证四项的主机实跑还没做(本轮只在 VM 上 跑了单测、deploy 干跑、monitor 自测与 VM 实跑)。
云端派发与自动 PR(本轮新增:多 worker 池、通用 runner、默认关闭的自动 PR)
- 多 worker 池(
cloud/workers.py+ worker 注册/心跳):worker 启动 时向控制面注册(workers 表:worker_id、executor、capacity、 last_seen),run_loop每轮结束写注册表心跳;GET /workers列出 全部 worker 及其 alive(last_seen 在 120 秒内,阈值可经环境变量CLOUD_WORKER_ALIVE_SECONDS调);POST /workers/heartbeat要求 body 含 worker_id(缺了 400),心跳本身就是注册——worker_id 不存在时自动注册,绝不 404。worker_id 可经 CLI--worker-id指定(生产部署应配稳定 id),未指定时默认“主机名-进程号” (重启后进程号会变,旧行自然变 stale,不要用随机 uuid 让注册表 每次重启都多一行无法对应到同一台机器);capacity 经--worker-capacity指定(默认 1,注册表展示用,本轮 run_loop 仍是串行一轮一任务)。两个端点都要 X-API-Key(与其它已认证 端点一致);worker 直接写库注册时不需要 key,HTTP 心跳是给有 key 的外部 worker 用的另一条路。 - 任务按执行器池派发:tasks 表新增
executor列(可空,老库幂等 补列);POST /tasks接受可选 executor(local/docker/cloud, 非法值 400),未传时为 None 表示任意 worker 可认领;worker 认领时只认领 executor 为空或等于自己执行器类型的任务—— pick_next 先过滤,_claim的 WHERE 里再加(executor IS NULL OR executor = ?)兜底,CAS 语义不变(仅当 queued 时标 running,两个同池 worker 抢同一任务时只有一个拿到 rowcount == 1);认领日志与审计里含 executor。注意_executor_name的修正:CloudExecutor 以前会被算成 local, 现在明确返回 cloud,否则 cloud worker 会认领 local 池的任务。 - goal 模式(
POST /tasks带verify_command即开启,本地 host.pygoal循环的云端版):worker 不走普通 dispatch, 改调执行器的 goal_task(local/Docker 均已接,CloudExecutor 没有 goal 概念、goal 任务派给它会如实失败,不静默降级); 循环内每轮 turn 后跑验收命令、失败输出喂回下一轮,最多goal_max_rounds轮(缺省 3,与 host.py 默认一致),goal 文本缺省用 prompt。达标时任务 completed;未达标时任务 failed(错误文本为最后一轮验收输出),且只发 goal-unmet 推送、不再重复发 failed。goal 模式下 worker 级重试与模型 fallback 关闭(循环内部已重试,再叠加只会污染错误文本); goal 任务失败后不要用/retry同任务重跑——host 任务名由 任务 id 派生,重跑会撞“任务已存在”而失败,应新建任务。 - CloudExecutor 通用 runner 接口(厂商无关,
cloud/sandbox.py): 本轮不假装接通某个云厂商(ECS、Cloud Run 等专属适配明确不在 范围)。CloudExecutor 接受一个 runner endpoint(显式参数或 环境变量CLOUD_RUNNER_ENDPOINT);未设时 execute 一律明确 失败“未配置云端 runner”,绝不静默降级到 Docker/Local;已设时 以 HTTP POST 把任务发给该 endpoint,请求体只有 id、prompt、 repo_url(task["repo"]),不含任何密钥;endpoint 返回的 status 为 completed 才算 completed,否则算 failed。认证只走 header 名:CLOUD_RUNNER_AUTH_HEADER给 header 名,token 值只从环境 变量取(CLOUD_RUNNER_AUTH_TOKEN_ENV指定的变量名,默认CLOUD_RUNNER_AUTH_TOKEN),只放进请求头,绝不写进日志、 错误文本或返回结果。worker CLI 的--executor新增 cloud 选项, 构造 CloudExecutor;worker 的 run_one 走 dispatch_task, CloudExecutor 的 dispatch_task/run_task 直接委托给 execute。 - 自动 PR(默认关闭,显式触发,
POST /tasks/{id}/pr,需项目 maintainer 以上权限 approve_merge):仅当任务状态为 merged 时 可调(其它状态 409;任务不存在 404;权限不足 403)。逻辑按顺序 检查(见cloud/pr.py):项目 auto_pr 不为 1 时返回 200 且 pr_skipped=true、reason=auto_pr_disabled,不失败、不推;本地 没有对应 host 任务 worktree(<仓库根>/work/host-worktrees/ <host 任务名>,host 任务名 = cloud 任务 id 经安全字符过滤, 分支名 = host/<host 任务名>,与 sandbox._host_name 同规则)时 reason=no_worktree;worktree 里没有 git remote origin 时 reason=no_remote;本机没有 gh 二进制时 reason=no_gh;gh 未登录 (gh auth status 非零)时 reason=gh_not_authenticated;以上全过 才先把 worktree 改动提交到分支上(worktree 必须正好在 host/<host 任务名> 上,否则直接失败;nothing to commit 时跳过; 缺 git 身份时回退 cloud/cloud@local 再试),再 push 分支 + 建 PR,成功后把 pr_url/pr_number 写回任务 记录并记审计 pr_created,返回 200 含 pr_url。App-token 路径:项目 repo_url 能解析出 github.com 仓库名且库里有该 仓库的 App 安装记录时,端点现换 installation token 走 App 路径(响应里 auth_via=app):push 用 git credential helper, token 只进子进程环境变量 GIT_TOKEN(不进命令行参数、不进 文件,helper 命令里只有变量名),建 PR 走 GitHub REST API (先读仓库 default_branch 当 base,不猜 main);没有安装 记录或换 token 失败时自动回落原来的 gh + PAT 路径 (auth_via=pat),不因此报错;App 没装的仓库与 gh 路径 的行为与原来完全一致。子进程超时:push、pr create、 commit、remote 查询、gh 检查全部走带超时的子进程调用 (默认 120 秒,CLOUD_PR_SUBPROCESS_TIMEOUT可改); 远端通路卡死时 handler 线程在时限内结束、返回 502, 任务保持 merged、可安全重调,不再永久挂住。push 或 pr create 失败时返回 502 且错误文本写清是哪一步(commit、push 还是 pr_create), 任务状态保持 merged 不变,绝不把任务标 failed。顺序:任务 completed -> POST /tasks/{id}/merge 改状态 -> POST /tasks/{id}/pr,本地 host.py merge 会删分支和 worktree、须在 PR 之后做,此后调 PR 会跳过 no_worktree。worker 不自动推: 自动 PR 只由显式调本端点触发,默认自动推会把测试任务也推到远端。 projects 表新增auto_pr列(默认 0,老库幂等补列);POST /projects接受可选 auto_pr(只接受 0/1,非法值 400); 本轮没有现成的 PATCH /projects/{id} 路由,auto_pr 只能在创建时 指定,建后改它需直接改库或重建项目,不要新造一个只为改 auto_pr 的路由。gh 的登录凭证由 gh 自己管,控制面不存、不打任何 token。 - 仍未做(不要宣称已做):厂商专属适配(ECS、Cloud Run 等,需 用户选厂商后另做);默认自动推(本轮明确不做);主机上 gh 的安装 与登录、远端写凭证(控制面不存;PAT 路径的自动 PR 真实成功 仍需它们,App-token 路径不需要 gh); 多 worker 的并发容量 enforcement(capacity 目前只是注册表展示, run_loop 仍串行);worker 经 HTTP 发心跳的凭证供给(worker 直接 写库时不需要,外部 worker 走 HTTP 时需自带 API key)。