Files
codex-app-server/verification/resident-p0-fixes-summary.md
T
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

237 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 常驻监听 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。