Files
codex-app-server/cloud
host 0ed301b010 OAuth real browser login: GET callback, userinfo, API key issuance
Add GET /oauth/{provider}/callback (code+state from query, client_id
and redirect_uri recovered from the state record) alongside the
existing POST callback. Callback now fetches real userinfo
(GitHub api.github.com/user, Google openidconnect userinfo) and
issues a usable local API key on success: new users get the
create_user key, existing users rotate (old key dead, audit
oauth_login + api_key_rotated). Key plaintext appears only once
on the success page/response; failures are 400 HTML/JSON without
secrets, and a missing client_secret fails before any network call.
State remains one-time, 10 minutes, provider/redirect_uri matched.
Tests: 10 new OAuth real-login tests pass; host 27, cloud 249
(4 skipped are the pre-existing real-Postgres tests).
Real end-to-end still needs a registered OAuth App and a public
HTTPS callback entry (server still binds localhost only).
2026-10-26 03:35:40 +08:00
..

云端控制面(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(只走环境变量),不是无认证后门。

OAuth 真实浏览器登录(已实现,部署前置仍需用户动手)

流程(标准 OAuth2 授权码流程):

  1. GET /oauth/{provider}/login?client_id=...&redirect_uri=... 返回 {provider, state, authorization_url};state 已入库 (10 分钟过期、一次性,provider / redirect_uri / client_id 都随 state 一起存下),把用户送到 provider 授权页。
  2. 用户授权后 provider 重定向到本站 GET /oauth/{provider}/callback?code=...&state=...(真实浏览器 流程只带 code + state;client_id / redirect_uri 由 server 从 state 记录取回,不需要 provider 额外带回)。
  3. 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)。
  4. 成功页(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、Google GOOGLE_OAUTH_CLIENT_SECRET),绝不写文件、绝不硬编码、 绝不出现在授权 URL 或成功页里;secret 缺失时回调明确报 client_secret_missing,不会以空 secret 去撞真实端点。
  • 本站控制面必须有公网可达的 https 回调地址:目前 server 默认 只绑 127.0.0.1:8765,没有公网入口、没有 TLS,这一步是部署 前置——需要在部署侧配反向代理 + TLS 证书并把公网域名指到 server,再把该域名下的回调地址填进 OAuth 应用的注册信息里。
  • 登录后 API key 的保存与轮换规则:新用户拿到创建时生成的 key; 已存在用户每次 OAuth 登录都会轮换 key(旧 key 立即失效), 因此不要把旧 key 存到多处长期复用;key 明文只在回调成功页/ 响应里出现一次,丢了只能重新走一次 OAuth 登录拿新 key。
  • 仍缺(非部署前置,但同样未完成):已有本站账号的绑定流程 (目前是确定性用户名新建/复用,不做账号绑定)。

推送凭证清单

  • 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:真实 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 文件里,新库是空的。完整 步骤是:

  1. 装驱动(psycopg v3 优先):
    pip install "psycopg[binary]"
    # 其次才用 psycopg2:pip install psycopg2-binary
    
  2. 在 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。
  3. 设环境变量并启动(--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
    
  4. 迁旧数据(可选,须在启动建表之后、切流量之前跑;跑两次 不重复插,第二次各表 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,避免后续自动生成撞主键。
  5. 回退到 SQLite:把 CLOUD_DB_BACKEND 改回 sqlite(或 删掉它,默认即 sqlite),并把 --db 指回原来的 SQLite 文件即可。但注意:切到 Postgres 之后新写的数据只在 Postgres 里,SQLite 文件里没有——回退不会自动把 PG 数据 导回 SQLite;如需回迁,须另写 PG -> SQLite 脚本(本轮 未提供,不要假装存在)。
  6. 备份: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?};无权限 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 分钟过期
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);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 表。两者配合 才算一个完整项目,不要只调一处。

测试

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,不只改数据库状态。
  • 网络白名单的诚实边界:docker run 默认 bridge 网络,本执行 器不做 egress 白名单——真正的“只放行模型 API”需要宿主防火 墙或代理规则,那是部署项,不是本轮代码假装做到的,不要宣称 已实现网络白名单。另有一项待实测:codex 自带的 bubblewrap 沙箱在容器内是否可用,取决于宿主的容器权限配置,本镜像不 假装解决;若 bwrap 不可用,host.py 的 sandbox 参数仍可退到 read-only / workspace-write 的控制面约束。
  • 仍需在外部主机上实测(本 VM 没有 Docker,本轮未连外部主机): 真实 docker build、真实 docker run、资源限额在真实负载下的 表现、bwrap 在容器内的可用性、以及取消时容器确实消失。