# PROGRESS · 关掉 `JyotishAPIHandler.__new__` 后门 - 日期:2026-09-16 - 分支:`codex/api-server-backdoor-close-20260916`(rebase 到 `origin/staging` @ `5094fd26`) - 任务书:`docs/tasks/TASK-api-server-backdoor-close-20260916.md`(第二版,B / mixin 方案) - 结论:**spike 闸门红 → 按 §6.3 退回 A**。scripts 侧 `__new__` **4 → 2**,未归零。 --- ## 1. §5.1 闭包复核(在 `dc8cae31` 代码上重算) `ast` 解析,从四处伪造实际借用的入口出发,沿 `self._x()` **以及模块级函数的 `handler._x()`** 追传递闭包。 | | 实测 | | --- | ---: | | 闭包类方法 | **115** | | 合计行数 | **4,104** | | 碰 HTTP 上下文(`headers`/`wfile`/`rfile`/`path`/`client_address`) | **0** | | 闭包途经的模块级函数 | 65 个 / 2,028 行 | | 移动集还需要的模块级类 | 2(`BadRequest`、`JobQueueFull`) | | 移动集还需要的模块级赋值 | 26 | 与第二版任务书 §1 的数字一致,前提「0 个碰 HTTP」成立。 **循环 import 不是障碍**:移动集的自由名里**不含 `JyotishAPIHandler`**,方向是单向的 `jyotish_api_server → mixin`。任务书 §5.2 点名的这条风险,实测可解。 闭包按入口分裂: | 入口 | 方法 | 行数 | | --- | ---: | ---: | | `_compute_vedastro_gateway_archives` | 1 | 4 | | `_interpretation_source_runtime_coverage` | 1 | 41 | | `_high_rigor_vedastro_official_summary` | 1 | 220 | | `_compute_synastry` | 4 | 35 | | `_compute_consultation_workflow` | **114** | **4,086** | --- ## 2. §5.2 spike 闸门:**红** 机械切分(源码行切片,方法体逐字搬运):115 方法 → `ConsultationComputeMixin`, `class JyotishAPIHandler(BaseHTTPRequestHandler, ConsultationComputeMixin)`, 连同 65 个模块函数 / 2 个类 / 26 个赋值一并移入新模块。两文件均可解析、可 import, MRO 正常(`_compute_synastry` / `_compute_consultation_workflow` 都能解析到)。 `tests/test_api_server_security.py` **一个字不改**直接跑: ``` 1 failed, 128 passed in 43.17s FAILED tests/test_api_server_security.py::test_chart_async_job_executes_in_background ``` ### 2.1 红在哪、为什么 ```python monkeypatch.setattr(jyotish_api_server, '_write_async_job_record', fake_write) # 测试第 3768 行附近 ... assert writes[0][0] == 'api_chart_response' # IndexError: list index out of range ``` `_enqueue_chart_job` 随 mixin 搬到新模块后,它对 `_write_async_job_record` 的查找走 **新模块的 globals**;而测试把补丁打在 `jyotish_api_server` 的 globals 上。两个绑定不再是同一个, 补丁静默失效,`writes` 始终为空。 **这不是搬坏了代码**,已实证:把同一个 fake 改打到 mixin 模块上,行为与搬运前完全一致—— ``` endpoint: chart_async mode: async_submitted status: queued writes captured: 3 first scope: api_chart_response # 正是测试断言的值 ``` ### 2.2 为什么不能绕过 security 测试用 `jyotish_api_server.X` 打模块级补丁的名字共 6 个,其中 3 个落在移动集里 (`_attach_vedastro_main_entry_overview`、`_load_local_module`、`_write_async_job_record`)。 任何 B 形态的切分,只要「被打补丁的函数」与「调用它的方法」一起离开 `jyotish_api_server` 的 globals, 这条断言就会红。提到第三个共享模块也一样(`from third import X` 在两边各建一个绑定)。 唯一能保住绿的做法是给 `jyotish_api_server` 换一个自定义 module 类、劫持 `__setattr__` 往 mixin 模块转发—— 那是新增机制、不是纯搬运,属于任务书 §6.3 明令的「硬做 B」。 按 §6.3:**退回 A,不硬做 B。** spike 产物已丢弃,`scripts/` 回滚干净后重做。 --- ## 3. 实际交付(A 方案) 新建 `scripts/offline_compute_mixins.py`(412 行): | 内容 | 成员 | | --- | --- | | `BadRequest` | 从 api server 搬出(api server 改为 import 回来,205 处引用不变) | | 3 个模块函数 | `_build_vedastro_official_full_snapshot_payload_from_chart`、`_free_tier_queue_enabled_env`、`_preferred_strict_contract` | | `RequestParamMixin` | `_get_float`、`_check_range`、`_normalize_degree` | | `VedastroEvidenceMixin` | `_high_rigor_vedastro_official_summary`、`_compute_vedastro_gateway_archives`、`_interpretation_source_runtime_coverage` | | `SynastryMixin(RequestParamMixin)` | `_compute_synastry` | `class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryMixin)`, HTTP 侧通过继承拿回全部 7 个方法(`self._get_float` 等 74 处调用点一行未改)。 两个调用方改成直接实例化 mixin,不再伪造 handler: - `scripts/consultation_workflow_service.py:27` `build_runtime_evidence_helpers` → `VedastroEvidenceMixin()` - `scripts/local_accuracy_report.py:141` → `SynastryMixin()._compute_synastry(...)` **仍在用 `__new__` 的 2 处**(都挂在咨询工作流链上,属于 spike 红掉的那一半): `scripts/consultation_workflow_service.py:20`、`scripts/capture_report_blocked_repairs_golden.py:59`。 ### 3.1 改前 / 改后 | 指标 | 改前 | 改后 | | --- | ---: | ---: | | `JyotishAPIHandler` 类方法数(`^ (?:async )?def`) | 225 | **218** | | `scripts/jyotish_api_server.py` 行数 | 11,291 | **10,924** | | 新模块行数 | — | 412 | | `__new__` scripts 侧 | 4 | **2** | | `__new__` tests 侧 | 29 | 29(未动) | | `__new__` 合计 | 33 | **31** | ### 3.2 §4.2「方法体一字不改」的证明 `git show HEAD:scripts/jyotish_api_server.py` 与新模块各自 `ast` 取定义源码切片,逐个 SHA-256 比对, **11 个搬走的定义全部字节级相同**: ``` IDENTICAL M:_get_float cf428670791b cf428670791b (10 lines) IDENTICAL M:_check_range 5cb38a5550ba 5cb38a5550ba (5 lines) IDENTICAL M:_normalize_degree b2ac5ec5533a b2ac5ec5533a (2 lines) IDENTICAL M:_compute_synastry ad868c3b8e1f ad868c3b8e1f (18 lines) IDENTICAL M:_high_rigor_vedastro_official_summary 42219ca0bef6 42219ca0bef6 (220 lines) IDENTICAL M:_compute_vedastro_gateway_archives faa9643b7287 faa9643b7287 (4 lines) IDENTICAL M:_interpretation_source_runtime_coverage 333b5c13515c 333b5c13515c (41 lines) IDENTICAL T:_build_vedastro_official_full_snapshot_payload_from_chart d1cafe318865 d1cafe318865 (61 lines) IDENTICAL T:_free_tier_queue_enabled_env 63a3fd275267 63a3fd275267 (7 lines) IDENTICAL T:_preferred_strict_contract e251697dd292 e251697dd292 (6 lines) IDENTICAL T:BadRequest 99667903a999 99667903a999 (2 lines) ``` 搬运用按行区间删除实现,`jyotish_api_server.py` 的 diff 形态是 **11 行插入(import 块 + 类头一行)/ 378 行删除**,没有重排、没有空行规范化。 --- ## 4. §5.5 合同测试 `tests/test_api_server_growth_contract.py`: - 行数 baseline `11291` → `10924`(+300 粗护栏不变) - 类方法数 baseline `225` → `218` - `__new__` 合计 baseline `33` → `31` - **新增** `JYOTISH_API_HANDLER_NEW_SCRIPTS_BASELINE = 2` 与 `test_scripts_handler_forgeries_must_not_grow`, 单独锁 scripts 生产侧,注释写明目标仍是 0 及剩下 2 处为什么没关 - **新增** `test_offline_mixins_must_not_import_the_http_monolith`(§4.3 的方向断言,`ast` 查 import) - 原有三条断言(`AGENTS.md` 含 `must not grow` / `thinly registered`、该测试在 `CORE_PYTEST_TARGETS` 与快速门里)未改,仍绿 `7 passed`(原 5 条 + 新增 2 条)。 ### 4.1 两次反向验证 **① 在 `scripts/` 加回一处 `JyotishAPIHandler.__new__`** → 必须红: ``` FAILED tests/test_api_server_growth_contract.py::test_jyotish_api_handler_new_count_must_not_grow FAILED tests/test_api_server_growth_contract.py::test_scripts_handler_forgeries_must_not_grow 2 failed, 5 passed ``` **② 给 `JyotishAPIHandler` 加一个类方法**(218 → 219)→ 必须红: ``` FAILED tests/test_api_server_growth_contract.py::test_jyotish_api_handler_method_count_must_not_grow 1 failed, 6 passed ``` 两次探针均已还原,工作树无残留。 --- ## 5. 新增端到端守卫 `build_runtime_evidence_helpers` 原本**零测试覆盖**(只有 `mcp_server.py:4751` 在调)。 新增 `tests/test_offline_compute_mixins.py`(4 条)覆盖 §5.4 验收: 1. `SynastryMixin()._compute_synastry` 与 `ashtakoot.calculate_ashtakoot` 逐字段一致(`local_accuracy_report` 路径) 2. `build_runtime_evidence_helpers` 不伪造 handler 也能返回三个键(MCP 路径,端到端) 3. `ast` 断言该函数体内已无 `JyotishAPIHandler` 4. `JyotishAPIHandler` 仍持有全部 7 个方法、继承链正确(HTTP 侧零变化) 同时把该文件加进 `scripts/run_quality_gate.py` 的 `CORE_PYTEST_TARGETS`——该列表自带注释 「不在这个清单里的守卫在 CI 里永远不跑」,本单的后门守卫必须进门禁。 --- ## 6. §7 验收 ``` pytest tests/test_api_server_security.py tests/test_api_server_growth_contract.py \ tests/test_consultation_consumer_context.py tests/test_vedastro_gateway.py \ tests/test_vedastro_snapshot_cache.py → 190 passed ``` `tests/test_api_server_security.py` 单独跑:**129 passed,文件一字未改**(`git diff --stat tests/` 对该文件为空)。 快速门(退出码直接取,不隔管道): ``` .venv/bin/python scripts/run_quality_gate.py --profile quick → EXIT=127 pytest 段:798 passed, 1 skipped, 0 failed 失败步骤:step: npm test → sh: 1: tsx: not found ``` pytest 段 0 failed,且比 `dc8cae31` 基线(792 passed / 1 skipped)**多 6 条**, 正好是本轮新增的 4 条(`test_offline_compute_mixins.py`)+ 2 条(合同测试)。 `tsx: not found` 是**本工作树没装 `frontend/node_modules`**(主检出 `/workspace/Jyotisha/frontend/node_modules` 有), 不是仓库缺口,也与本单纯 Python 改动无关,故未新开 BLK 条目。 开工预检 `python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` → `exit=0`,六项全绿。 ### 6.1 门禁外的定向抽查(`BadRequest` 搬家的下游) `BadRequest` 从 `jyotish_api_server` 搬进新模块后由 api server import 回来, 仓内两个直接 `from jyotish_api_server import BadRequest` 的测试照常解析同一个类对象: - `tests/test_declared_window_chart.py` —— 在快速门的 798 条里,通过 - `tests/test_active_rectification_api.py` —— **不在快速门**,定向跑:`1 failed / 50 passed` 那一条失败 `test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow` **是基线即红,与本单无关**。在 `5094fd26` 的干净工作树上单独复跑同一条, 失败停在同一行(`tests/test_active_rectification_api.py:666`)、同一断言: ``` {'start_time': '04:16'} != {'start_time': '05:07'} ``` 属校正打分的期望漂移,不涉及本轮搬走的任何名字,未修(§4.6 不得顺手修不在本单里的问题)。 --- ## 7. 没做的 / 留给下一轮 - **scripts 侧 `__new__` 未归零**,剩 2 处:`consultation_workflow_service.py:20`、 `capture_report_blocked_repairs_golden.py:59`。两者都要整条咨询工作流闭包(114 方法 / 4,086 行)搬走, 而搬走就会触发 §2.1 的 monkeypatch 落点问题。**下一轮要先解决的是那 3 个被模块级打补丁的名字怎么安置,不是体量。** 一个可行方向:先把 `_write_async_job_record` / `_load_local_module` / `_attach_vedastro_main_entry_overview` 连同各自调用方一起留在 api server, 或把异步任务记录层单独抽成一个两边都 import 的模块并同步改测试落点(需产品批准动 security 测试)。 - 阶段 3(`do_POST` 路由表)按 §9 不做。 - 本单不产生 Bug 记录,不进 `CHANGELOG.md`。