Files
codex-app-server/deploy
host 5d09af855f Host-level sandbox tasks: LocalExecutor + bwrap workspace-write
Docker outer layer and bwrap inner layer are mutually exclusive
on this host (fresh proc mounts fail with kernel EPERM inside
Docker's nested pidns, but work on the host under a userns
profile). So tasks that need an inner sandbox run on the host:
- Worker --claim-executor-only: strict executor claim (only
  executor == own type, never NULL). The host worker MUST use
  it, or it would steal NULL-executor tasks meant for Docker
  workers (those tasks would lose the Docker outer layer).
  Default behavior unchanged (NULL claimable by any worker).
- host-config-host.json: sandbox workspace-write, state
  work/host-state-host.json (distinct from Docker tasks'
  host-state.json), same relay home and test repo as Docker.
- deploy/apparmor/codex-host: docker-codex base + userns,
  mount, pivot_root for bwrap; still denies raw network,
  ptrace, dangerous capabilities, /proc/sys writes.
- deploy/host-worker/codex-host-worker.service: aa-exec -p
  codex-host, --executor local --claim-executor-only,
  capacity 2, proxy env 127.0.0.1:8888 so host tasks' relay/git
  egress also goes through the Tinyproxy allowlist (host direct
  egress would bypass it).
POST /tasks already accepts executor (local/docker/cloud), so
no API change was needed for routing.
Tests: 4 host-sandbox-routing tests (strict claim incl. CAS,
default still takes NULL, config sandbox/state, profile
allows bwrap without deny mount). Full suites pass.
2026-10-26 08:23:37 +08:00
..

部署与运维(deploy/)

本目录是主机部署与运维的脚本集,与 cloud/ 代码分开:部署脚本只负责 传代码、备份、重启、验证,不改任务逻辑;运维脚本只负责检查与演练, 不跑模型、不推代码。

部署:deploy.sh

VM 侧运行:

deploy/deploy.sh <host> [user] [--dry-run]
# 例如:deploy/deploy.sh 124.222.165.170 root
# 干跑(只打印步骤,不传代码、不动主机):
#   deploy/deploy.sh <host> --dry-run
  • 密码:脚本绝不接受密码参数、不读 env 文件、不打印密钥。SSH 密码由 ssh/scp 交互提示输入,或由调用方自己的 SSH 通道(key)提供。
  • 经代理:设环境变量 SSH_PROXY_CMD 传入 ProxyCommand 内容,ssh 会自行 展开 %h %p,例如: SSH_PROXY_CMD="python3 /tmp/proxy_tunnel.py %h %p" deploy/deploy.sh <host>; 未设则直连 ssh。
  • 备份保留策略:每次部署前在主机上把当前 /opt/codex-app-server 打包到 /opt/codex-app-server-backups/codex-app-server-<时间戳>.tar.gz(排除 work/ 目录以免过大),只留最近 3 份,多余的自动删掉。
  • 健康门:重启后循环 curl localhost:8765/health 最多 60 秒,要求 ok=true 且 backend=postgres 且 db_ok=true;失败时自动恢复刚打的 备份包并重启,再验一次。回滚后 health 恢复也算部署失败(退出非零), 因为新代码没上去;回滚后 health 仍失败则退出非零并明说 “已回滚但 health 仍失败,需人工介入”。
  • 成功时只打印新代码的 git commit(从本机 git 取,不编造版本号)。

回滚:rollback.sh

VM 侧运行,恢复主机上最新的一份备份包:

deploy/rollback.sh <host> [user]

恢复后同样重启并验 health(60 秒),成功/失败都明说;主机上没有备份包 时直接报错退出,不编造。

回滚边界:只回代码,不回数据库

数据库迁移是只加列的幂等操作(例如 projects.repo_url、tasks 的 6 列), 回滚代码后老代码仍能读新列——新列对老代码只是多余的列,不影响已有 查询(老代码的 SELECT 只取它认识的列,或用 _row_get 容错)。所以只回 代码是安全的;数据库本身绝不会被 deploy.sh / rollback.sh 动到。这一点 逻辑上成立的前提是:迁移永远只加列、不改列类型、不删列;如果将来有一次 迁移要改类型或删列,必须先单独评估回滚方案,不能直接套用本脚本。

备份恢复演练:restore-drill.sh

主机侧运行(root,或能读 /etc/codex-app-server/env 的用户):

sh /opt/codex-app-server/deploy/restore-drill.sh
# 可选:BACKUP_DIR=/var/lib/codex-app-server/backups(默认就是这个)

步骤:跑一次 backup_postgres 生成新鲜备份 → 新建测试库 codex_control_restore_test_<时间戳> → 用 cloud.backup.restore_postgres 把备份恢复进去 → 比对生产库与测试库的 tasks / projects / users / audit_log 行数(行数相等即通过,只比行数,不比对具体内容,不打印任何 行内容)→ 比对完立即删掉测试库(trap 保证中途失败也删)。

安全红线:恢复目标永远是新生成的测试库名,绝不等于生产库名; restore_postgres 本身也会拒绝目标库名等于 CLOUD_DB_NAME (allow_production 绝不在本脚本里设为 True),双重保险。本脚本绝不 恢复到生产库,绝不删生产库;全程只打印 PASS/FAIL 与行数,不打印密码, 不打印备份内容。

监控:monitor.sh + monitor.service / monitor.timer

主机侧运行,codexsvc 可读权限即可,不读 env 文件:

sh /opt/codex-app-server/deploy/monitor.sh            # 输出一行 JSON,任一项 fail 则退出非零
sh /opt/codex-app-server/deploy/monitor.sh --self-test  # 只验证脚本机制(见下)

检查项:systemctl is-active server/worker、curl health(ok/backend/db_ok/ worker_ok)、pg_isready、docker ps 里 codex-postgres 在运行、磁盘使用率 <90%、/opt/codex-app-server 存在。health 与 pg_isready 都不需要密码; 需要密码的 SELECT 1 那一步直接跳过,不在脚本里读密码。

--self-test 只验证脚本本身的机制(health JSON 解析 + 输出是合法 JSON): 它起一个本地 canned health server,断言 health 那一项为 pass;其它项在 非主机机器上 fail 是正常的,self-test 不因此失败。不要把 --self-test 通过误读成“主机健康”。

定时(主机上安装,示例):

cp /opt/codex-app-server/deploy/monitor.service /etc/systemd/system/monitor.service
cp /opt/codex-app-server/deploy/monitor.timer /etc/systemd/system/monitor.timer
systemctl daemon-reload
systemctl enable --now monitor.timer
journalctl -u monitor.service -f   # 看每次检查的输出

timer 每 5 分钟跑一次,日志进 journal。

监控边界:本轮只有“主机侧检查 + journal 日志”

monitor.sh 本身不会发消息给用户。推送到 Muse 聊天的告警仍需用户另行 决定通道(例如 SSH key 常驻通道让外部 cron 拉 journal,或者一个 webhook URL 让 monitor 失败时 POST 过去),在通道定下来之前,不要假装 monitor.sh 会通知任何人。手工验证步骤:主机上跑一次 monitor.sh 看 JSON 与退出码,再跑一次 --self-test 看 SELF-TEST PASS,最后 systemctl list-timers | grep monitor 确认 timer 已启用。

worker 心跳与 health 的 worker 字段

worker 的 run_loop 每次循环结束时写心跳文件,内容只有 {"last_seen": epoch秒, "pid": 进程号},不含任何密钥;写失败只记日志, 不炸 worker。心跳文件路径:worker 用 --heartbeat-file 指定(主机部署 时指到 /var/lib/codex-app-server/worker-heartbeat.json),未指定时看 环境变量 CLOUD_WORKER_HEARTBEAT_FILE,再未指定则为 ./worker-heartbeat.json(VM 本地跑的默认,不在代码里硬编码主机路径)。

server 的 GET /health 在原有 ok/service/backend/db_ok 之外加两个字段: worker_last_seen_seconds_ago(心跳文件不存在或读失败时为 None)与 worker_ok(心跳在 180 秒内为 true,否则 false;阈值可经环境变量 CLOUD_WORKER_HEARTBEAT_MAX_AGE_SECONDS 调,默认 180)。health 本身仍 返回 200——worker 不 ok 不代表 server 挂了,调用方看 worker_ok 字段判断, 不要把 health 改成 503 破坏已有调用。

主机部署时两边路径必须一致:worker 单元加 --heartbeat-file /var/lib/codex-app-server/worker-heartbeat.json,同时 在 server 的 env 文件(/etc/codex-app-server/env,server 单元的 EnvironmentFile)里加一行 CLOUD_WORKER_HEARTBEAT_FILE=/var/lib/codex-app-server/worker-heartbeat.json。 deploy.sh 不会自动改 env 文件(那是 root-only 的密钥文件),这一行由 部署操作人在主机上手动加,加完重启 server 即可。

多用户真实验证:verify-multiuser.py

主机侧以 codexsvc 身份运行,直接对生产库 + 本地 HTTP API 验证:

python3 /opt/codex-app-server/deploy/verify-multiuser.py
# 可选:--base http://127.0.0.1:8765 --db /var/lib/codex-app-server/control.db

它在内存里创建 4 个测试用户(owner / maintainer / contributor / viewer) 与 2 个测试项目(P2 只有 owner 是成员,用于隔离验证),再用 HTTP API 验证:owner/maintainer/contributor/viewer 的建任务、读任务、merge 权限, 以及跨项目隔离(P1 成员读 P2 任务 403)、无 key 401。merge 验证用的 completed 测试任务是直接在 DB 里把测试任务置 completed,不跑真实模型。

防误伤要点(脚本逻辑里已实现):

  • 测试用户、项目、任务全部用 multiuser-test- 前缀,清理时只删这些 前缀的行,绝不碰其它生产数据;清理失败时退出非零并列出残留测试 id (不含 key),不静默假装干净。
  • 测试任务创建时都带一个永不存在的依赖 multiuser-test-never-completes: worker 只派依赖全部 completed 的 queued 任务,所以这些测试任务永远 不会被生产 worker 认领去跑模型,不花模型钱、不改任何仓库。
  • API key 只存在内存里,脚本全程只打印每项 PASS/FAIL 与总数,绝不打印 任何 API key;任务 prompt 与用户 id 也不打印,只打印检查名与结果。
  • 审计日志是 append-only 的:验证过程的审计条目会留在 audit_log 里, 脚本不删审计表——审计就该留痕,这一点不是清理遗漏。

公网入口与 webhook secret(已有文件,不属于本轮新增)

  • caddy/Caddyfile:只暴露 /webhooks/* 与 /oauth/*,任务/项目/管理 API 对外一律 403;域名与邮箱占位符需部署时替换。
  • setup-webhook-secret.sh:在主机上生成 webhook secret 并写入 env 文件, 绝不打印 secret;已设则不动,避免轮换后 GitHub 端旧 secret 失效。

仍未做(不要假装已做)

  • 推送告警通道:monitor 失败时通知到 Muse 聊天的通道还没定(见监控一节)。
  • 回滚不回数据库:见回滚边界一节;改类型/删列的迁移需要单独评估。
  • 恢复演练不碰生产库:演练只比行数,不比对具体内容;恢复到生产库的 真实灾难恢复流程(停服务、恢复、重启、验 health)还没演练过。
  • CloudExecutor、GUI、Slack 执行、推送实发:与本轮无关,仍是 0 或占位。