Files
codex-app-server/verification/resident-p0-fixes-summary.md
host b391fe9320 resident P0+P1: DNS-level SSRF pin, batch budget, daily cap/cooldown/retention, PG schema/migration, dual-connection deadlock isolation, retarget baseline reset, proxy and structured failures, metrics
- DNS 级 SSRF pin:解析后 pin IP 建连,防 DNS 重绑定
- 批量总预算与每日上限/抖动冷却/保留期
- PG schema 与迁移清单
- 双连接死锁隔离
- 改目标重置基线
- 显式代理配置与结构化失败原因
- /metrics resident 口径
2026-11-02 03:30:57 +00:00

15 KiB
Raw Permalink Blame History

常驻监听 P0+P1 修复批验证摘要(本地实现与测试,未 push、未开 PR、未合并、未部署)

  • 起点:feat-resident-p0-fixes,从 origin/main 60997b1(PR #275 合并头)起; 本批只改本 worktree,绝不动生产审核 worktree、闭环 worktree、主 checkout。
  • 审核依据:生产审核报告 ~/workspace/codex-app-server-resident-prod-audit/verification/resident-prod-audit-summary.md 第 4、5、7 节(动手前已重读);范围只限该报告的 P0 与 P1。
  • 不做事项:控制台 UI、选择器/区域提取、其它探针 (server_status/agent_status/relay_cost)、磁盘清理、真实外网抓取 (真实抓取只用本地测试 HTTP 服务验证)、生产 PG、生产双 worker、 真实外网只读演练(仍是需用户拍板的后续窗口,见 blocker)。
  • 红线:以下所有数值凡标“拟定默认值”者,均为本批实现工拟定的 默认值、供最终验收改,不是用户已拍板;摘要、代码注释与后续 PR 描述素材里一律按此口径写,不得写成用户已定。

1. 拟定默认值清单(逐项标拟定,环境变量可覆盖)

口径 拟定默认值 环境变量 说明
每监听每日变化任务上限 24 个 CODEX_RESIDENT_MAX_DAILY_CHANGE_TASKS 按 UTC 日切分;只数 status='changed' 且 task_id 非空的轮询行;超限记 changed_capped,游标不推进
变化抖动冷却 300 秒 CODEX_RESIDENT_CHANGE_COOLDOWN_SECONDS 上次建任务 300 秒内的新变化记 changed_cooldown,游标不推进,冷却过后复查仍可报
resident_polls 保留期 90 天 CODEX_RESIDENT_POLL_RETENTION_DAYS 批量驱动每批开头 prune 一次;过期删、未过期保留;当日上限计数不受影响(保留期远大于 1 天)
批量总预算 120 秒 CODEX_RESIDENT_BATCH_BUDGET_SECONDS 单批最坏 = 预算 + 单条超时上限 60 秒;超预算的剩余监听保持 due,下批再跑
代理模式 直连(默认) CODEX_RESIDENT_PROXY 未设/空/direct = 直连,不跟随进程代理;env = 显式跟随进程环境代理;其它值 = 显式代理 URL;两种代理模式都认进程 no_proxy/NO_PROXY
重定向上限 10 跳 (固定值) 超过抛 redirect 结构化失败

其它沿用旧值不变:间隔下限 60 秒、抓取超时 1–60 秒(默认 10 秒)、 1MB 截断、变化摘录 2000 字、poll detail 500 字、批量单批上限 50 条。

2. P0 修复与测试证据

S1. DNS 级 SSRF + 摘录口径(cloud/resident.py)

  • 抓取前解析全部 A/AAAA(resolve_public_addresses):任一地址命中 私有/回环/链路本地/保留/组播/未指定即整批拒绝;IPv6 的 IPv4 映射 地址(::ffff:127.0.0.1)按映射后 IPv4 再判;解析失败/空结果/ 非 IP 结果按 dns 失败,不放行。
  • 直连建连 pin 住解析结果(_PinnedHTTPConnection / _PinnedHTTPSConnection):TCP 对端就是解析时刻的 IP,不再二次 按主机名解析;https 的 SNI 与证书主机名仍是原始主机名(单测断言 wrap_socket(server_hostname='example.com') 且建连地址为 pin 的 IP), TLS 校验不被 pin 破坏。
  • 重定向每跳重复字面校验 + 解析门 + pin(相对跳转先 urljoin); 非 200 一律抛 HttpStatusFetchError,错误页正文直接丢弃。
  • 摘录口径(拟定):摘录只来自已通过校验的公开抓取结果;私网 抓取在抓取层直接失败(ssrf_blocked)、不产生摘录、不建任务、 轮询 detail 只含 reason=ssrf_blocked; … 异常文本,绝不含抓取正文。 创建监听时仍只做字面校验(DNS 会变,创建时放行不等于抓取时放行), 权威关口在抓取层——见代码注释。
  • 残留风险(诚实写明,不假装已封口):
    • 代理模式下客户端解析门只是前置检查,真正建连的是代理方、由 代理方二次解析;pin 在代理模式下不生效,须靠代理自身的 egress allowlist 兜底。生产部署若用代理模式,必须同时给代理配内网拦截。
    • 直连 pin 已把解析-建连 TOCTOU 关到“解析结果本身”这一层,但 “解析时刻的 IP 在之后被重新分配给内网用途”的极端情形不在 本次封口承诺内。
    • 旧 urllib 默认跟随进程代理的行为已改:默认直连。部署时若宿主 出口必须走代理,须显式配 CODEX_RESIDENT_PROXY=env 或代理 URL, 否则公网抓取会直连失败(记失败轮询,不建任务)。
  • 测试:test_resolve_public_rejects_private_and_mixed(私网/混合/ 映射地址/解析失败)、test_ssrf_blocked_poll_has_no_excerpt_and_no_task、 test_pinned_https_uses_hostname_for_sni_not_ip、 旧重定向测试改写(默认解析器 + getaddrinfo 补丁必拦;注入 resolver 返回公网测试 IP + socket 层重定向到本地服务时相对跳转走通)。

S2. 驱动与延迟:选批量总预算,不新增独立驱动进程

  • 论证:独立驱动进程的运维成本(新 unit、独立 DB 连接、启停监管) 是审核第 7 节留给用户拍板的项,本批不擅自新增;批量总预算是纯 代码改动、本地可测,且 claim 门已保证未来加驱动时多驱动不重复 轮询。预算把旧最坏 50×60=3000 秒 bound 到“预算 120 秒 + 单条 超时上限 60 秒”,剩余到期监听保持 due、下批再跑,不丢。
  • 间隔只是下限、不是 SLA:resident_metrics 的 due_count / max_overdue_seconds 直接度量“到期未跑”积压,进 /metrics; 任何对外口径仍须写“尽力而为、延迟无界”(模型任务一轮最长 1800 秒仍会拖后轮询,预算只 bound 单批,不承诺送达时限)。
  • 测试:test_batch_budget_stops_batch_and_keeps_rest_due (单调时钟注入:50 秒/条、预算 100 秒时跑 2 条即停,剩余 3 条仍 due)。

G2. 成本上限与保留期

  • 每日上限、抖动冷却、保留期见第 1 节清单;三者均持久化 (上限数当日轮询行、冷却用既有 last_change_at、保留期靠 prune_old_polls 的 DELETE),重启不丢计数。
  • 页面每轮都变时:冷却期内只记 changed_cooldown、上限满后只记 changed_capped,游标不推进(变化不丢,冷却过后/次日仍可报), 任务数有界。
  • 测试:test_daily_cap_and_cooldown_suppress_without_losing_change (上限 2、冷却 300 秒的加速版:建任务→冷却抑制→冷却后建任务→ 上限抑制,游标始终指向最后一次真实建任务的内容)、 test_prune_old_polls_keeps_recent(100 天前行删、当前行保留)。

3. P1 修复与测试证据

S3. resident 表进 PG schema 与迁移清单

  • cloud/db.py 的 POSTGRES_SCHEMA 新增 resident_watches / resident_polls(含 failure_reason 列)与三条索引(含部分唯一索引 WHERE status != 'ended');cloud/migrations.py 的 MIGRATION_TABLES 同步新增两表(TEXT 主键,无身份列,无需序列拨动)。
  • 测试:test_migration_coverage_includes_resident_tables (migration_coverage()["missing_from_migration"] == ()); 本地 PG 实测见第 5 节。

G1. ensure 移出热路径 + 批量逐条隔离 + 死锁重试一次

  • 热路径(due_watches / claim_due_watch / run_due / 批量循环)不再调 ensure_resident_tables;建表只在 server init_db(SQLite 分支新增 _res_ensure,PG 分支走 schema)、 worker 生命周期首次批量、API 函数(create/list/get/update/ transition)做。热路径只在表确实缺失时 ensure 后重试一次。
  • 批量入口逐条隔离:锁错误(PG locked=True、SQLite database is locked)回滚后重试一次,仍失败记入 errors 列表并继续下一条;非锁异常同样隔离;预算耗尽信号以 watch_id=None 的条目记入 errors,供 worker 记日志。
  • 测试:test_batch_isolates_item_errors_and_retries_deadlock (死锁始终失败时只影响本条、其余照常;第一次死锁第二次成功时 errors 为空)、test_hot_path_batch_executes_no_ddl (计数连接断言批量内零 CREATE 语句)。

G3. 改目标 URL 强制走一次 baseline

  • update_watch 改 target_url 时 last_content_hash 置 NULL, 下一轮只建基线、不建任务;改间隔/提示/名字不重置游标, 已排好的 next_poll_at 不变(语义保留,见代码注释)。
  • 测试:test_update_target_resets_baseline_but_interval_does_not。

G4. 显式代理配置 + 结构化失败原因

  • 代理口径见第 1 节清单与 resident_config() 注释;默认直连 与环境变量的关系写在代码与本摘要里,不只靠隐式进程环境。
  • 失败原因进 resident_polls.failure_reason 列(ensure 与 PG schema 同步补列),detail 前缀同样写 reason=<原因>; 原因集:dns / proxy / timeout / http_status / ssrf_blocked / tls / redirect / network / invalid_url / unknown。
  • 测试:test_proxy_config_defaults_direct_and_env_opt_in、 test_classify_fetch_errors、test_http_status_fetch_raises_structured_error (单跳层真实 404 + default_fetch 状态分支)。

G5. resident 指标进 /metrics

  • GET /metrics 新增 resident 段(成员项目过滤,与任务计数 同款):watches_by_status、due_count、max_overdue_seconds、 近 24h polls_last_24h / failed_last_24h / changed_last_24h / change_tasks_last_24h、当前拟定 config;表缺失时 {"available": False},不炸 /metrics。
  • 测试:test_resident_metrics_counts(含到期积压与非成员过滤)、 test_metrics_contains_resident_section(HTTP 层)。

P2 建议项(顺手修,不改语义)

  • resident 四个 handler 的 502 统一为固定错误码 resident_operation_failed,详情只记服务端日志(_server_log), 不再透传可能含表名/列名的异常文本;测试 test_resident_502_uses_fixed_error_code(响应不含注入的细节串)。
  • _resident_get 的 polls 异常记服务端日志(_server_log.warning), 响应仍保持 polls=[] + polls_note="polls unavailable" 的兼容形态; 测试 test_resident_get_polls_failure_keeps_note。
  • 未做(列为后续,不硬修):改间隔立即生效的语义(仍为“暂停后 恢复才立即复查”,已写进注释与本摘要);选择器/区域提取; 控制台 UI。

4. 测试结果

  • 定向(最后一次纯文案修订后重跑,见时间关系说明): python3 -m unittest cloud.tests.test_resident cloud.tests.test_resident_server cloud.tests.test_resident_website_loop cloud.tests.test_resident_p0_fixes —— 38 项全过(旧 20 项 + 新 18 项)。
  • ruff E,F(被碰文件):与改动前基线一致的 12 项,全是旧代码 (含 cloud/worker.py 的 F821、cloud/server.py 旧中文注释超长行等), 本批新增 0 项(新增的 3 项在自查中已修掉)。
  • CI 同款三条 discover 全量(均在本批代码定稿后跑出):
    • python3 -m unittest discover -s cloud/tests:1569 项,OK(跳过 9 项),393 秒;
    • python3 -m unittest discover -s deploy/tests:44 项,OK,12 秒 (尾部三行中文传输日志为某测试退出时打印,不影响结果);
    • python3 -m unittest discover -s tests:105 项,OK,12 秒。
  • 时间关系:定向 38 项先于全量跑出;全量三条跑完后,本摘要 填入全量数字(纯文案修订,不碰代码与测试),随后按任务要求 重跑定向——重跑结果仍为 38 项全过,与全量结论一致;摘要定稿 在定向重跑之后,不再有代码或测试改动。

5. 本地 PG 16 验证单独记录(一次性库,测完已删库删脚本)

  • 方式:initdb --auth=trust 一次性库(Unix socket,psycopg2 适配层),脚本放 /tmp(不进 worktree),测完停库、删库、 删脚本;结论只代表本地 PG,不代表生产 PG。
  • 结果(全部通过):
    • init_postgres_schema 后 resident_watches / resident_polls 齐全;
    • 索引在位:idx_resident_watches_project、 idx_resident_watches_name(部分唯一, WHERE (status <> 'ended'::text))、idx_resident_polls_watch;
    • 二次 init_postgres_schema + ensure_resident_tables 幂等;
    • migration_coverage 无缺失;SQLite→PG 迁移 resident 两表各 1 行;
    • 基线→变化→queued 任务、已结束名重用全部通过;
    • 双连接并发:胜者 1 条 baseline 轮询、败者 0 条且 errors 为空、 全程无死锁异常、无整批 abort、poll 总数 1(G1 修复后败者干净返回; 旧死锁形态未再出现,但“双连接”不等于“生产双 worker”,见偏差)。
  • 已清理:集群目录、socket 目录、日志与 /tmp 脚本均已删除, ls /tmp/resident_pg* 确认无残留。

6. 已知偏差

  • 本地 PG 结论不是生产 PG 结论;本地 HTTP 结论不是真实外网结论; 双连接结论不是生产双 worker 结论;真实外网只读抓取演练仍未做。
  • 代理模式下 pin 不生效(见 S1 残留风险),须靠代理 egress allowlist。
  • 旧闭环测试第 5 步轮询时刻由 t0+400 改为 t0+540:新冷却语义下 160 秒间隔的变化会被正确抑制,测试改按冷却期外复查(注释已写明), 不是为通过而删断言。
  • 旧重定向测试与闭环测试的本地服务接法改为“resolver 返回公网测试 IP + socket 层重定向”:门与 pin 全程真实跑,只有传输层终点是 测试替身;门本身由默认解析器用例独立覆盖。
  • resident_config() 每次调用读环境变量:改环境变量即生效, 但已启动进程的既有默认值不会跨进程同步(与其它环境变量配置同款)。
  • 无效环境变量数值(非整数/<=0)回落默认值,不炸驱动(配置错误 按默认跑,运维侧须自行核对配置是否生效,见 /metrics 的 config 段)。

7. blocker(需用户拍板,本批不擅自定)

  • 拟定默认值清单(第 1 节全部):上限 24、冷却 300 秒、保留期 90 天、 批量预算 120 秒、默认直连代理——供最终验收改。
  • 是否新增独立驱动进程(本批选批量总预算,见 S2 论证;独立驱动 仍是可选项,需用户定运维成本)。
  • SSRF 修复后的摘录口径最终版:本批拟定为“只来自已通过校验的 公开抓取结果”;是否进一步收紧为只留 hash/长度/脱敏摘录,仍由 用户定。
  • 生产 PG 双 worker 实测窗口、真实外网只读抓取演练窗口、 代理模式下的代理 egress allowlist 配置(若生产用代理模式)。
  • 控制台 UI 是否随修复批上(属产品范围,不计入本轮)。

8. 未做事项(本批明确不做)

  • 未 push、未开 PR、未合并、未部署;工作区未提交(由 parent 定)。
  • 未碰生产审核 worktree、闭环 worktree、主 checkout(feat-repo-picker)。
  • 未做控制台 UI、选择器/区域提取、其它探针、磁盘清理、真实外网抓取。
  • 未改生产默认值以外的任何行为口径;旧测试除上述两处时序/接法 调整外无删断言、无加 skip。