diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22bb203..867d0fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,3 +70,47 @@ jobs: - name: Cargo test run: cargo test --locked working-directory: src-tauri + + # 为什么必须有 Linux 一档(2026-09-18 补): + # Windows 检出是 CRLF、且 CI 机器有网络/DNS,于是下面两类缺陷在 Windows 上「全绿」: + # ① LF 检出才暴露的测试缺陷(例如逐行注释剥离正则遇到 LF 会吃掉字符串里的 `//`); + # ② 依赖真实 DNS 的用例(离线/禁网运行器下才变红,且反例是「因解析失败而空过」的假绿)。 + # 只跑 pytest + 前端测试(不含 cargo:Linux 上 Tauri 需要额外系统库,成本不值当)。 + checks-linux: + name: Checks (Linux / LF + no-DNS) + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: 24 + + - name: Install frontend dependencies + run: npm ci + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install Python dependencies + run: pip install -r requirements.txt -r requirements-dev.txt + + - name: Run pytest + run: pytest -q + + # 仓库自带的强隔离门禁:清空 WORKBUDDY* 环境变量、把用户目录指向临时目录、 + # 禁真实 DNS/socket。此前它常年红(与用例互相矛盾),故无人接入 CI;现已修复。 + - name: "Run pytest (isolated: no network, disposable user data)" + run: python tests/run_isolated_tests.py + + - name: Three-source sync check (GPT_FALLBACK_MAP <-> GPT_PREMARKED) + run: python scripts/check_premarked_sync.py + + - name: Run frontend unit tests + run: npm test diff --git a/AGENTS.md b/AGENTS.md index e0a950a..d001b32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ Tauri v2 桌面应用 + Python 反代内核。 ```bash ./.venv/Scripts/python.exe -m pytest tests/ -q # Python:423 passed 为当前基线 (含长思考超时与防惊群契约) +./.venv/Scripts/python.exe tests/run_isolated_tests.py # 同上,但在「禁真实 DNS/socket + 用户目录指向临时目录 + 清空 WORKBUDDY* 环境变量」下再跑一遍 npm test # 前端:202 passed(node --test) cd src-tauri && cargo test --quiet # Rust:85 passed ``` @@ -26,6 +27,19 @@ cd src-tauri && cargo test --quiet # Rust:85 passed ⚠️ **裸 `python -m pytest` 会失败**(`No module named pytest`)——`python` 命中的是 Hermes 运行时 venv,不是本仓 venv。必须用 `./.venv/Scripts/python.exe`。 +⚠️ **改测试或写「看源码做结构/负向断言」的用例前必读(2026-09-18 修复)**: +① 剥离注释**不能用逐行正则** `line.replace(/\/\/.*$/,'')`——它既会在 LF 下吃掉字符串字面量里的 +`//`(`format!("https://…")` 整行尾部被剪掉,正向断言恒失败),又会在 CRLF 下因 `.` 不匹配 `\r` +而完全空转(负向断言把注释算进代码,写一句解释性注释即误报)。统一用 +`tests/helpers/strip-rust-comments.mjs`(会跳过字符串/字符字面量,LF/CRLF 结果一致)。 +② 用例**不得依赖真实 DNS / 网络**(如放行 `example.com`):离线或隔离运行器下,正例会红, +反例则「因解析失败提前返回」而**空过**——即断言看着绿、其实没验证到那条防线。 +需要走通公网分支时,打桩 `converter._resolve_host_ips`。 +③ 路径断言的期望值要用与实现相同的 `path.join` 推导,别写死 `z:/workbuddy/` 这类正则 +(Windows 下 `path.join('Z:\','workbuddy')` 是 `Z:\workbuddy`,POSIX 下是 `Z:\/workbuddy`)。 +④ 修完三类问题后 `pytest` / 隔离运行器 / `npm test` 在 **LF 与 CRLF 检出下都必须全绿**, +CI 的 `checks-linux`(LF + 禁网)一档就是为守这条而加。 + ### 构建:用户自己跑,助手不要绕 **构建由仓库主自己执行**:`npm run tauri build`(PowerShell,仓库根)。 diff --git a/converter.py b/converter.py index f5eda9e..c02f8b7 100644 --- a/converter.py +++ b/converter.py @@ -39,7 +39,7 @@ from pathlib import Path from contextlib import asynccontextmanager from contextvars import ContextVar -from typing import Optional +from typing import Any, Optional import httpx from fastapi import FastAPI, Header, HTTPException, Request @@ -1277,43 +1277,62 @@ def _resolve_host_ips(host: str) -> list: return ips -def _url_is_safe_for_fetch(url: str) -> tuple[bool, str]: - """SSRF 前置校验:仅允许 http(s)、禁止内网/回环/元数据地址。 - - 返回 (是否安全, 拒绝原因)。DNS 解析后逐个校验 IP,任一落在内网即拒绝 - (防 DNS rebinding 式的「一公网一内网」混合解析)。 - """ - from urllib.parse import urlsplit - try: - parts = urlsplit(url) - except Exception as e: - return False, f"URL 解析失败: {e}" +def _resolve_public_connect_ip(host: str) -> tuple[Optional[str], str]: + """把主机解析为「可直接连接的公网 IP」;拒绝时返回 (None, 原因)。 - if parts.scheme not in ("http", "https"): - return False, f"不支持的协议: {parts.scheme}" + 这是 SSRF 主机/IP 判定的**唯一实现**:URL 级校验 `_url_is_safe_for_fetch` + 与实际下载路径 `_url_to_data_uri`(逐跳)都经由它,杜绝两处逻辑漂移 + —— 历史教训是「一处被改、另一处才是真正生效的防线」,改动者据此产生虚假信心。 - host = (parts.hostname or "").strip().lower() + 规则: + - 命中本机/云元数据主机名单(含 `*.localhost` 后缀)直接拒绝; + - 纯 IP 字面量:无需 DNS,直接判定; + - 域名:要求**每一个**解析结果都是公网地址(防「一公网一内网」混合解析), + 并返回首个公网 IP 供调用方**绑定连接**,从而消除「校验一次、连接时再解析 + 一次」的 DNS rebinding 窗口。 + """ + host = (host or "").strip().lower() if not host: - return False, "缺少主机名" + return None, "缺少主机名" if host in _BLOCKED_IMAGE_HOSTS or host.endswith(".localhost"): - return False, f"禁止访问本机/元数据主机: {host}" + return None, f"禁止访问本机/元数据主机: {host}" # 纯 IP 直连:直接判定,无需 DNS try: - ip = ipaddress.ip_address(host) - if not _ip_is_public(ip): - return False, f"禁止访问内网/保留地址: {ip}" - return True, "" + literal = ipaddress.ip_address(host) except ValueError: - pass + literal = None + if literal is not None: + if not _ip_is_public(literal): + return None, f"禁止访问内网/保留地址: {literal}" + return str(literal), "" ips = _resolve_host_ips(host) if not ips: - return False, f"域名无法解析: {host}" + return None, f"域名无法解析: {host}" for ip in ips: if not _ip_is_public(ip): - return False, f"域名 {host} 解析到内网/保留地址: {ip}" - return True, "" + return None, f"域名 {host} 解析到内网/保留地址: {ip}" + return str(ips[0]), "" + + +def _url_is_safe_for_fetch(url: str) -> tuple[bool, str]: + """SSRF 前置校验(URL 级):仅允许 http(s),禁止内网/回环/元数据地址。 + + 返回 (是否安全, 拒绝原因)。主机/IP 判定全部委托给 `_resolve_public_connect_ip` + —— 与实际下载路径同源,不存在第二份实现。 + """ + from urllib.parse import urlsplit + try: + parts = urlsplit(url) + except Exception as e: + return False, f"URL 解析失败: {e}" + + if parts.scheme not in ("http", "https"): + return False, f"不支持的协议: {parts.scheme}" + + _, reason = _resolve_public_connect_ip(parts.hostname or "") + return (False, reason) if reason else (True, "") async def _url_to_data_uri(url: str, timeout: float = 15.0) -> str: @@ -1354,28 +1373,14 @@ async def _url_to_data_uri(url: str, timeout: float = 15.0) -> str: parts = urlsplit(current) orig_host = (parts.hostname or "").strip() port = parts.port or (443 if parts.scheme == "https" else 80) - - # DNS 解析并在公网 IP 中取已验证的 IP 建立直连,防 DNS rebinding - try: - ip_literal = ipaddress.ip_address(orig_host) - if not _ip_is_public(ip_literal): - _log(f"🚫 远程图片禁止访问内网/保留地址: {ip_literal}", level="warning") - return url - connect_ip = str(ip_literal) - except ValueError: - resolved_ips = _resolve_host_ips(orig_host) - if not resolved_ips: - _log(f"🚫 远程图片域名无法解析: {orig_host}", level="warning") - return url - for ip in resolved_ips: - if not _ip_is_public(ip): - _log(f"🚫 远程图片域名 {orig_host} 解析到内网/保留地址: {ip}", level="warning") - return url - public_ips = [ip for ip in resolved_ips if _ip_is_public(ip)] - if not public_ips: - _log(f"🚫 远程图片域名无有效公网 IP: {orig_host}", level="warning") - return url - connect_ip = str(public_ips[0]) + + # 与 _url_is_safe_for_fetch 共用同一判定(含本机/元数据主机名单), + # 并把连接绑定到已校验的 IP,杜绝「校验一次、连接时再解析一次」的 + # DNS rebinding 窗口。 + connect_ip, reason = _resolve_public_connect_ip(orig_host) + if not connect_ip: + _log(f"🚫 远程图片拒绝下载({reason}): {current[:60]}", level="warning") + return url # 构造直连 URL 与连接扩展 target_host = f"[{connect_ip}]" if ":" in connect_ip else connect_ip diff --git a/docs/audit-2026-09-18.md b/docs/audit-2026-09-18.md new file mode 100644 index 0000000..70a4468 --- /dev/null +++ b/docs/audit-2026-09-18.md @@ -0,0 +1,180 @@ +# workbuddy2api 复核报告(2026-09-18) + +范围:`main @ 5f8933b` 的**可复核实测**(本机 Linux/LF 检出;同时跑了 CRLF 检出对照)。 +第二轮(§3)在同一分支继续闭环复核中提出的三项工程/测试问题,并把上游看门基线推进(§4.2)。 +方法:跑仓库自带的三道门禁 + 独立静态检查(pyflakes)+ 定点变异验证。 +未做:未打真实 CodeBuddy 上游 API、未碰 8787 活服务、未在 Windows 上执行(本机无 Windows/Rust 工具链)。 +(注:第二轮为核对上游看门基线,实访了 GitHub API —— 见 §4.2,非业务上游。) + +## 0. 结论速览 + +| 类别 | 结论 | +|---|---| +| 业务逻辑 | 未发现新的功能性缺陷;`docs/audit-2026-09-17.md` 的高危项**已全部修复**(复核见 §1) | +| 测试门禁 | **3 处失败 + 3 处「假绿」空过**(本报告主要产出,已修) | +| 测试基建 | 隔离运行器常年红(故无人接入 CI)、盘根用例写死 Windows 路径、坏路径用例往仓库根写垃圾文件(已修) | +| 静态检查 | `converter.py` 12 处用了 `Any` 却未导入(已修);剩余为噪声(§4.1) | +| 覆盖缺口 | CI 只跑 Windows(CRLF + 有真实 DNS),恰好是上述缺陷的**共同成因**;已补 Linux 一档 | +| 复核遗留(第二轮) | SSRF 两份实现/死代码、`saw_output`、测试 CONFIG 泄漏 —— **均已闭环**(§3) | + +实测基线(本次实测,非文档值): + +| 项 | 修复前 | 修复后 | +|---|---|---| +| `pytest -q`(LF) | 423 passed | **424 passed** | +| `tests/run_isolated_tests.py` | **3 failed / exit 1** | 424 passed / exit 0 | +| `npm test`(LF) | **199 pass / 3 fail** | 202 passed | +| `npm test`(CRLF 检出) | 202 passed(**含 2 处空过**) | 202 passed | +| `check_premarked_sync.py` | exit 0 | exit 0 | +| `tools/check-upstream.py` | — | 0 项待评估 / 0 项查询失败(§4.2) | +| `cargo test` | 未跑(本机无 Rust 工具链) | 未跑 | + +> 修复前 LF 失败、Windows CI 全绿 —— 这个反差本身就是最重要的信号:**CI 的绿掩盖了 3 类缺陷**。 + +## 1. 复核:audit-2026-09-17 的高危项已修复 + +逐条回源码核对,以下**不再是问题**,不必再按旧报告追: + +- **P0 SSRF**:`_ip_is_public` 已含 RFC 6598 `100.64.0.0/10`(`_CGNAT_NETWORK`),且 `_url_to_data_uri` 已在每一跳 + 自行解析、校验后**把连接绑定到已校验 IP**(`connect_url` + `Host` + `sni_hostname` 扩展),重定向逐跳重校验。 +- **短 SHA vs 40 位 SHA**:`build.rs` 已烘焙完整 40 位(带长度与 hex 校验,取不到才回退 `unknown`)。 +- **设置保存**:`save_app_settings_with_writer` 已改为「持锁 → 先原子落盘 → 成功才提交内存」。 +- **交错工具分片**:`anthropic_stream.py` 已改为 `_tool_blocks` 字典化状态机,按上游 `tc.index` 分块。 +- **错误帧协议级传播**:`anthropic_stream.feed_chunk` 已识别 `error` chunk 并转 `error` SSE、置 `_finished`。 +- **降级误标 unavailable**:`_mark_model_unavailable` 在改写 `body["model"]` **之前**调用;成功后 `_mark_model_available` 标实际模型。 +- **接入引导写死 `local` 密钥**:`settings.js` 的 `applyApiKeyToUi` 已按 `apiKeyCache` 动态替换(含 CLI 示例)。 +- 仓库卫生:`.gitignore` 已覆盖 `accounts.json` / `settings.json` / `model_*.json` 等本地凭据文件。 + +## 2. 已修复:3 处失败 + 3 处「假绿」 + +### 2.1 注释剥离器两处失真(LF 失败 / CRLF 空转)— P1 + +`tests/test_app_update_contract.test.js` 的 `stripRustComments` 用 `line.replace(/\/\/.*$/, '')`: + +- **LF**:把字符串字面量里的 `//` 当注释 —— `format!("https://api.github.com/repos/{slug}/commits/{branch}")` + 整行尾部被剪掉,`repos/{slug}/commits/{branch}`、`compare/{base}...{head}` 两条断言**任何平台都取不到**; +- **CRLF**(Windows 检出 = 主平台 + CI):`.`` 不匹配 `\r`,而 `$`(无 `m`)锚定在 `\r` 之后,正则**恒不匹配** + → 剥离完全空转,193 行注释原样留在「代码」里。于是反向断言(不得出现 `releases/latest` 等) + 会把说明性注释算进去:**写一句解释性注释就会让 CI 变红**,而它本来是允许的。 + +修法:新增 `tests/helpers/strip-rust-comments.mjs`(按字符扫描,跳过字符串/字符/原始串,支持嵌套块注释, +行尾不吞 `\r`),LF 与 CRLF 结果一致。变异验证: + +| 场景 | 旧实现 | 新实现 | +|---|---|---| +| 基线 | LF 判红 / CRLF 判绿(不一致) | 绿 | +| 代码里重新引入 `releases/latest` | 抓到 | 抓到 | +| 注释里提到 `releases/latest` | LF 误报 | 正确放行 | + +### 2.2 安全用例可「真回归空过」— P1 + +`tests/test_secret_injection.test.js` 的剥离器 `src.replace(/\/\/[^\n]*/g, '')` 不认字符串字面量。 +`proxy.rs` 里既有 URL 字面量、也有路径校验 `ep.contains("//")` —— 这类行会被从 `//` 起整段吃掉, +而本文件的断言**全是负向**(「不得出现 `--api-key`」),于是真回归会被藏起来。 + +实测(往 `proxy.rs` 注入 `let _probe = format!("http://127.0.0.1:{port}/health"); cmd.arg("--api-key");`): + +| 剥离实现 | 结果 | +|---|---| +| 旧(HEAD) | `# pass 4 / # fail 0` —— **漏判** | +| 新(复用同一 helper) | 用例 A 正确判红 | + +### 2.3 视觉内联用例依赖真实 DNS(正例必红 + 反例空过)— P1 + +`tests/test_vision_inlining.py` 的桩只替换了 `converter._url_is_safe_for_fetch`,而**真实取数路径 +`_url_to_data_uri` 内部自己解析域名**(`_resolve_host_ips`),并不走那个函数。后果有二: + +- 正例(`test_remote_http_image_inlined` 等)在离线 / 隔离运行器(`deny_dns`)下必红; +- 反例(超限、非图片类型…)断言 `out == url`,在离线时**因解析失败提前返回**而同样成立 —— + **看着绿,其实那条防线一次都没被验证**。 + +实测:把 Content-Type 兜底整段关掉,在禁 DNS 环境下—— + +| 用例文件 | 结果 | +|---|---| +| 旧(HEAD) | `1 passed` —— 空过 | +| 新(打桩 `_resolve_host_ips`) | `1 failed` —— 抓到 | + +修法:`_install_stream_stub` 与重定向用例都改为打桩 `converter._resolve_host_ips`(返回公网 IP), +用例自此完全离线自足。 + +### 2.4 盘根扫描用例写死 Windows 路径形态 — P2 + +`test_turing_findsdk_chain.test.js` 用例 G 用 `^z:\/(workbuddy)\//` 匹配候选,但候选由 `path.join` 生成: +Windows 下是 `Z:\workbuddy\...`,POSIX 下 `path.join('Z:\','workbuddy')` 是 `Z:\/workbuddy` +(反斜杠被当普通字符)→ 归一化后是 `z://workbuddy`,正则**恒不匹配**,该用例在非 Windows 检出上必红。 + +修法:期望值改用与实现同源的 `path.join` 推导。变异验证:把 `WORKBUDDY_TURING_DRIVES` 的盘根扫描整段去掉, +本用例仍会变红(`实际:[]`)—— 说明它没有退化成恒绿。 + +### 2.5 隔离运行器常年红(harness 与用例互相矛盾)— P1 + +`tests/run_isolated_tests.py` 的 autouse 夹具为「不污染仓库根」把 `usage_log` 指向临时目录, +而 `test_record_usage_disabled_is_noop` 断言 `CONFIG["usage_log"] is None`(默认未启用) +—— 两者不可能同时成立,于是这个**比 pytest 更强的门禁恒失败**(exit 1),自然也就没人接入 CI。 + +修法:用例改为显式 `monkeypatch.setitem(..., None)`(保留「关闭时不写文件、不抛异常」的意图)。 +隔离运行器现 `423 passed / exit 0`,并已接入 CI(§2.7)。 + +### 2.6 坏路径用例在 POSIX 上往仓库根写垃圾文件 — P2 + +`test_record_usage_failsafe_on_bad_path` 用 `r"Z:\__no_such_dir__\usage.jsonl"` 充当「目录不存在」。 +在 Windows 上确实写盘失败;在 POSIX 上它是**合法相对文件名**,`open(..., "a")` 会成功: + +- 仓库根出现名为 `Z:\__no_such_dir__\usage.jsonl` 的文件(`.gitignore` 未覆盖,`git status` 变脏); +- 用例实际走的是**成功分支**,`_record_usage` 的失败兜底根本没被测到。 + +修法:改用 `tmp_path / "no_such_dir" / "usage.jsonl"`(两平台都必然 `open` 失败),并补 `assert not bad.exists()`。 + +### 2.7 CI 增加 Linux(LF + 禁网)一档 — P1 + +上述缺陷的共同成因是:**CI 只跑 Windows,而 Windows 检出是 CRLF、且 GitHub 机器有真实 DNS**。 +原 `ci` job(Windows,含 cargo)**保持不动**,新增 `checks-linux`:只跑 pytest、隔离运行器、 +前端测试、premarked 同步(不含 cargo —— Linux 上 Tauri 需要额外系统库,成本不值当)。 +如不需要 Linux 档,删掉该 job 即可,其余修复不依赖它。 + +### 2.8 `converter.py` 补 `Any` 导入 — P2 + +12 处注解(`Optional[Any]`、`obj: Any`、`_parse_expiry_timestamp(v: Any)` 等)用到 `Any`, +但只 `from typing import Optional`。目前**没炸**纯靠 `from __future__ import annotations` +把注解变成惰性字符串 —— 一旦谁去掉那行、或对相关函数调用 `typing.get_type_hints()`、或上 mypy/pyright,就会失败。 + +## 3. 复核后已闭环(2026-09-18 第二轮,同一 PR) + +1. **SSRF 校验两份实现 / 死代码 —— 已收敛**。抽出新函数 + `converter._resolve_public_connect_ip(host) -> (ip|None, 原因)`,作为「主机/IP 是否可连」的**唯一实现**: + - `_url_is_safe_for_fetch` 只保留 URL 级职责(协议白名单 + 取 host),判定全部委托; + - `_url_to_data_uri` 的逐跳校验改为调用同一函数,拒绝路径从 3 条简化为 1 条。 + 副作用之一:本机/元数据主机名单(`_BLOCKED_IMAGE_HOSTS`、`*.localhost`)现在**下载路径也生效** + (此前只在外层那份死代码里,实际未被执行)。 + 防漂移由新增用例 `test_url_level_and_fetch_path_share_one_validator` 锁定:把任一路径改回内联判定即变红(已变异验证)。 +2. **`responses_projection.py` 的 `saw_output` —— 已确认并闭环**:它是重构残留(`Output:` 是 Codex 执行器的 + 分段标题行,本就应该**无条件丢弃**),并非漏了一步判断。已删除该标志并补注释说明「若将来真要按它分支, + 需连同契约一起改」;同时把用例补强为 `assert "Output:" not in content` + + `content.count("Key output:") == 1`,防止有人把「无 Output 段就省略标题」这种猜测性分支加回来。 + 顺带修掉一个真空断言:`test_ssrf_guard_blocks_dangerous_urls` 的 `expect_keyword` 此前只收集不使用。 +3. **测试全局配置隔离 —— 已修复并加兜底护栏**:`test_snapshots_stream_resp.py`、`test_snapshots_wire.py` + 以及同子系统的 `test_snapshots.py`(`_enable()` 助手 + 4 个用例)全部改走 `monkeypatch.setitem`; + `tests/conftest.py` 新增 autouse 夹具 `_isolate_global_config`,每例结束把 `CONFIG` 恢复为快照, + 使漏写的还原不再污染其他用例/文件(已用探针用例验证:移除护栏即复现泄漏)。 + +## 4. 仍未处理(建议后续) + +1. **CI 无任何 Python 静态检查**(无 ruff/mypy)—— 这正是 §2.8 能潜伏至今的原因。加入后剩余的噪声: + - `converter.py:1111` `global _MODELS_CACHE` 多余(只做 `.get()`,无名字重绑定); + - `responses_compat.py:86` `nonlocal pending_tool_calls` 多余(只 `.clear()/.append()`); + - `converter.py:5000`、`desensitize.py:249` 的 f-string 无占位符。 + - 注意:`converter.py:3028/3271` 的 `cred = _cred()` 是**有意的 fail-fast 守卫**(无凭据时抛 503),别当噪声删掉。 + - `test_wsl_and_tool_repair.py` 仍有直接改 `CONFIG` 的写法(`log_payloads`/`log_path`/`usage_log`), + 目前由上述 autouse 护栏兜住,但值得按同一模式改成 `monkeypatch`。 +2. **上游看门(Issue #14)**:4 项基线与仓库更名已推进,实跑 `python tools/check-upstream.py` 得 + 「0 项待评估、0 项查询失败」,满足工作流自动收口条件(合并进 main 后由 CI 关闭)。 + +## 5. 覆盖局限(如实声明) + +- **未在 Windows 上执行**:全部结论来自 Linux;Windows 侧用「全仓转 CRLF 的副本」等价验证 + (npm test / pytest / 隔离运行器三项在 CRLF 副本上同样全绿)。 +- **`cargo test` / `cargo check` 未跑**:本机无 Rust 工具链,Rust 侧本轮只做源码阅读(未改动)。 +- **未打真实上游**:未验证真实 CodeBuddy 服务下的行为;`update.rs` 的 GitHub 联网路径未实测。 +- **未逐行审阅 `converter.py`(约 7.9k 行)**:本轮以「门禁 + 静态检查 + 定点变异」为主,不构成全量审计。 +- 变异验证只针对本轮改动的用例,未做全仓变异覆盖。 diff --git a/responses_projection.py b/responses_projection.py index 2b56b62..8c091fd 100644 --- a/responses_projection.py +++ b/responses_projection.py @@ -568,11 +568,14 @@ def _summarize_tool_output(text: str) -> str: lines = text.splitlines() exit_line = next((line.strip() for line in lines if "Process exited with code" in line), "") useful_lines = [] - saw_output = False + # 注:`Output:` 是 Codex CLI 执行器的分段标题行,这里**无条件丢弃**(纯归一化,不承载分支语义)。 + # 该行曾被赋给一个 `saw_output` 标志,但标志从未被读取——是重构残留而非有意留白; + # 已删除以免后人误以为「是否有 Output 段」会影响摘要形态。若将来真要按它分支 + # (例如无 Output 段时省略 "Key output:" 标题),请连同 + # tests/test_responses_projection.py::test_summarize_large_tool_output 的契约一起改。 for line in lines: stripped = line.rstrip() if stripped == "Output:": - saw_output = True continue if ( stripped.startswith("Chunk ID:") diff --git a/tests/conftest.py b/tests/conftest.py index 41dd63f..9bc62c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,3 +20,22 @@ def _isolate_global_cred(): converter.CONFIG["cred"] = None yield converter.CONFIG["cred"] = None + + +@pytest.fixture(autouse=True) +def _isolate_global_config(): + """每个用例结束后把 CONFIG 恢复成用例开始前的快照(兜底护栏)。 + + 为什么需要:`CONFIG` 是全局可变字典,直接 `converter.CONFIG["x"] = v` 而不还原 + 会把开关泄漏给后续用例/**其他测试文件**,症状是「单独跑绿、全量跑红」或反之 + (顺序一变就变脸),且很难定位。历史上快照/日志相关用例都有这个毛病 + (snapshots / snapshots_keep / snapshots_log / usage_log / log_payloads / log_path)。 + + 与 monkeypatch 的关系:用例内仍应优先用 `monkeypatch.setitem(converter.CONFIG, ...)` + (显式、局部、可读)。本夹具是**兜底**,不是替代——它保证「漏写的还原」不会污染别人。 + 拆除顺序上 monkeypatch 先还原自己的键,本夹具随后整体恢复快照,故两者可安全共存。 + """ + snapshot = dict(converter.CONFIG) + yield + converter.CONFIG.clear() + converter.CONFIG.update(snapshot) diff --git a/tests/helpers/strip-rust-comments.mjs b/tests/helpers/strip-rust-comments.mjs new file mode 100644 index 0000000..b0be51d --- /dev/null +++ b/tests/helpers/strip-rust-comments.mjs @@ -0,0 +1,83 @@ +/** + * 剥离 Rust 源码注释,供「看源码做结构/负向断言」的契约测试复用。 + * + * 为什么不逐行用正则替换: + * ① **字符串字面量里的 `//` 会被误当注释开头**。典型是 URL 与路径校验: + * `format!("https://api.github.com/repos/{slug}/commits/{branch}")`、 + * `ep.contains("//")`。逐行替换会把整行尾部一起吃掉,于是 + * · 正向断言(「必须存在某端点」)永远取不到 → 恒失败; + * · 负向断言(「不得出现某写法」)则变成空过 —— 真正的回归被藏起来,最危险。 + * ② **换行符敏感**。CRLF 检出下 `.` 不匹配 `\r`、而 `$`(无 m 标志)锚定在 `\r` 之后, + * `line.replace(/\/\/.*$/, '')` 恒不匹配 → 剥离完全空转,注释原样留在结果里, + * 负向断言会把说明性注释也算进去(写一句「不再使用 X」就误报)。 + * Windows 检出(本项目主平台 + CI)正是这种情形,于是同一用例在 Linux 上失败、在 + * Windows 上假绿。 + * + * 本实现按字符扫描:跳过字符串/字符字面量(含 `r"…"` / `r#"…"#` 原始串)后才识别注释, + * 行尾原样保留(不吞 `\r`),因此 LF 与 CRLF 结果一致。 + * + * @param {string} src Rust 源码全文 + * @returns {string} 去掉行注释与块注释(含嵌套块注释)后的文本,保留换行与字符串内容 + */ +export function stripRustComments(src) { + let out = ''; + let i = 0; + const n = src.length; + while (i < n) { + const ch = src[i]; + const prev = i > 0 ? src[i - 1] : ''; + // 原始字符串:r"…" / r#"…"# / br#"…"#(前一个字符不是标识符字符才算起始, + // 避免把 `for`、`parse_compare` 这类标识符里的 r 当成原始串前缀) + const rawMatch = /[A-Za-z0-9_]/.test(prev) + ? null + : /^(?:b|c)?r(#*)"/.exec(src.slice(i, i + 8)); + if (rawMatch) { + const terminator = '"' + rawMatch[1]; + const start = i; + i += rawMatch[0].length; + const end = src.indexOf(terminator, i); + i = end === -1 ? n : end + terminator.length; + out += src.slice(start, i); + continue; + } + // 普通字符串字面量(含 \" 转义) + if (ch === '"') { + const start = i; + i += 1; + while (i < n) { + if (src[i] === '\\') { i += 2; continue; } + if (src[i] === '"') { i += 1; break; } + i += 1; + } + out += src.slice(start, i); + continue; + } + // 字符字面量 'a' / '\n';生命周期标注(如 &'static str)不匹配,按普通字符输出 + if (ch === "'") { + const m = /^'(?:\\.|[^\\'])'/.exec(src.slice(i, i + 6)); + if (m) { out += m[0]; i += m[0].length; continue; } + out += ch; + i += 1; + continue; + } + // 行注释:吞到行尾,保留换行符本身 + if (ch === '/' && src[i + 1] === '/') { + while (i < n && src[i] !== '\n' && src[i] !== '\r') i += 1; + continue; + } + // 块注释:Rust 支持嵌套 /* /* */ */ + if (ch === '/' && src[i + 1] === '*') { + let depth = 1; + i += 2; + while (i < n && depth > 0) { + if (src[i] === '/' && src[i + 1] === '*') { depth += 1; i += 2; } + else if (src[i] === '*' && src[i + 1] === '/') { depth -= 1; i += 2; } + else i += 1; + } + continue; + } + out += ch; + i += 1; + } + return out; +} diff --git a/tests/test_app_update_contract.test.js b/tests/test_app_update_contract.test.js index 6b97e4f..2a2c05b 100644 --- a/tests/test_app_update_contract.test.js +++ b/tests/test_app_update_contract.test.js @@ -16,6 +16,7 @@ import assert from 'node:assert'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { stripRustComments } from './helpers/strip-rust-comments.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); @@ -27,14 +28,7 @@ const html = readFileSync(join(root, 'index.html'), 'utf8'); const handoff = readFileSync(join(root, 'scripts', 'app-update', 'windows.ps1'), 'utf8'); // Rust 注释里提到 Release 是在解释「为何不用它」,断言需剥离注释后再看代码。 -function stripRustComments(src) { - return src - .replace(/\/\*[\s\S]*?\*\//g, '') - .split('\n') - .map(line => line.replace(/\/\/.*$/, '')) - .join('\n'); -} - +// 剥离实现见 helpers/strip-rust-comments.mjs(含「为什么不能用逐行正则」的说明)。 const updateRsCode = stripRustComments(updateRs); test('Rust 侧不得再使用 GitHub Release API 作为更新来源', () => { diff --git a/tests/test_converter.py b/tests/test_converter.py index 27dba1d..d16f9c9 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -84,10 +84,15 @@ def test_usage_int_normalization(): # 用量统计写盘(--usage-log JSONL) # --------------------------------------------------------------------------- -def test_record_usage_disabled_is_noop(): +def test_record_usage_disabled_is_noop(monkeypatch): # 未启用 --usage-log:不写文件、不抛异常 + # + # 必须显式置 None,不能断言「CONFIG 默认即 None」:tests/run_isolated_tests.py 的 + # autouse 夹具为了不污染仓库根,会把 usage_log 指向临时目录,此时该键不是 None + # ——原来的 `assert CONFIG.get("usage_log") is None` 会让隔离运行器恒红(harness 与 + # 用例互相矛盾,导致这个更强的门禁常年没人跑)。默认值本身由 CLI 解析层保证。 + monkeypatch.setitem(converter.CONFIG, "usage_log", None) converter._record_usage("glm-5.3", True, 0.0, input_tokens=1, output_tokens=2) - assert converter.CONFIG.get("usage_log") is None # 默认确实未启用 def test_record_usage_writes_jsonl_line(tmp_path, monkeypatch): @@ -121,13 +126,18 @@ def test_record_usage_failure_row(tmp_path, monkeypatch): assert rec["output_tokens"] is None -def test_record_usage_failsafe_on_bad_path(monkeypatch): +def test_record_usage_failsafe_on_bad_path(tmp_path, monkeypatch): # 目录不存在:写盘失败必须静默吞掉,绝不影响请求主流程 - monkeypatch.setitem( - converter.CONFIG, "usage_log", - r"Z:\__no_such_dir__\usage.jsonl", - ) + # + # 不能用 r"Z:\__no_such_dir__\usage.jsonl" 当「坏路径」:那是 Windows 专属假设。 + # 在 POSIX 上它是一个**合法的相对文件名**,open(..., "a") 会成功,于是在仓库根 + # 留下一个名为 `Z:\__no_such_dir__\usage.jsonl` 的垃圾文件(未被 .gitignore 覆盖), + # 而用例实际走的是成功分支——根本没验证到「写盘失败要吞掉」这条兜底。 + # 用 tmp_path 下不存在的子目录:两个平台都必然 open 失败。 + bad = tmp_path / "no_such_dir" / "usage.jsonl" + monkeypatch.setitem(converter.CONFIG, "usage_log", str(bad)) converter._record_usage("glm-5.3", True, 0.0) # 不应抛异常 + assert not bad.exists(), "落盘失败时不应创建任何文件(含父目录)" # --------------------------------------------------------------------------- diff --git a/tests/test_image_fetch_binding.py b/tests/test_image_fetch_binding.py index 810757e..2d55d20 100644 --- a/tests/test_image_fetch_binding.py +++ b/tests/test_image_fetch_binding.py @@ -21,6 +21,31 @@ def test_non_public_addresses_are_not_admitted(address): assert not converter._url_is_safe_for_fetch(f"http://{address}/image.png")[0] +@pytest.mark.anyio +async def test_url_level_and_fetch_path_share_one_validator(monkeypatch): + """URL 级校验与真实下载路径必须共用 `_resolve_public_connect_ip`。 + + 背景:这两处历史上各写了一份 SSRF 判定,URL 级那份成了零调用点的死代码, + 「改了一处、真正生效的是另一处」会让改动者产生虚假信心。本用例把两条路径 + 钉在同一个函数上:任一处重新内联自己的判定,`calls` 就不会有两条记录。 + """ + calls = [] + + def fake_helper(host): + calls.append(host) + return None, "统一拒绝(测试桩)" + + monkeypatch.setattr(converter, "_resolve_public_connect_ip", fake_helper) + + assert converter._url_is_safe_for_fetch("http://images.example/a.png")[0] is False + out = await converter._url_to_data_uri("http://images.example/a.png") + assert out == "http://images.example/a.png", "被拒绝的地址必须原样返回、不得内联" + assert calls == ["images.example", "images.example"], ( + "两条路径未共用同一判定函数 —— SSRF 校验又出现第二份实现:" + f"实际调用记录 {calls}" + ) + + @pytest.mark.anyio @pytest.mark.parametrize("scheme,address", [("http", "8.8.8.8"), ("https", "8.8.8.8"), ("https", "2606:4700:4700::1111")]) async def test_fetch_connects_to_validated_literal_with_original_authority(monkeypatch, scheme, address): diff --git a/tests/test_responses_projection.py b/tests/test_responses_projection.py index d97cb0d..63e74b1 100644 --- a/tests/test_responses_projection.py +++ b/tests/test_responses_projection.py @@ -347,6 +347,11 @@ def test_summarize_large_tool_output(): assert "result_data_line_49" in content assert "Chunk ID:" not in content assert "Wall time:" not in content + # `Output:` 分隔标题行必须被丢弃(它是 Codex 执行器的分段标记,不是内容); + # 同时正文标题仍为 "Key output:" —— 两者共同锁定「丢弃标题 ≠ 省略正文标题」, + # 防止把「无 Output 段时不发 Key output 标题」这类未定义的猜测性分支加回来。 + assert "Output:" not in content + assert content.count("Key output:") == 1 def test_schema_projection_removes_unnecessary_keys(): diff --git a/tests/test_secret_injection.test.js b/tests/test_secret_injection.test.js index 274b33b..cbb3f4d 100644 --- a/tests/test_secret_injection.test.js +++ b/tests/test_secret_injection.test.js @@ -22,6 +22,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { stripRustComments } from './helpers/strip-rust-comments.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const JS = (p) => readFileSync(join(ROOT, p), 'utf8'); @@ -29,9 +30,12 @@ const JS = (p) => readFileSync(join(ROOT, p), 'utf8'); const PROXY_RS = JS('src-tauri/src/commands/proxy.rs'); const CONVERTER = JS('converter.py'); -// 只看真实代码:去掉注释,避免「注释里写了 --api-key 说明」误判为仍在传参 -const stripComments = (src) => - src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); +// 只看真实代码:去掉注释,避免「注释里写了 --api-key 说明」误判为仍在传参。 +// +// 必须用会跳过字符串字面量的实现:proxy.rs 里既有 URL 字面量,也有路径校验 +// `ep.contains("//")`。粗放的 `/\/\/[^\n]*/` 会把这两类行从 `//` 起整段吃掉, +// 于是「不得出现 --api-key」这类**负向断言会在真回归面前空过**(本文件的断言价值全在负向)。 +const stripComments = stripRustComments; test('A 内核启动不得再把密钥放进 argv(防明文进命令行)', () => { const code = stripComments(PROXY_RS); diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py index 62df445..0742c08 100644 --- a/tests/test_snapshots.py +++ b/tests/test_snapshots.py @@ -8,33 +8,38 @@ import converter -def _enable(tmp_path, keep=5): +def _enable(monkeypatch, tmp_path, keep=5): + """一律经 monkeypatch 改写全局 CONFIG(用例结束自动还原)。 + + 直接赋值不还原会把 snapshots/keep/log 泄漏给后续用例与测试文件, + 造成顺序相关的假红/假绿,违反 AGENTS.md 的测试数据隔离铁律。 + """ p = tmp_path / "snapshots.jsonl" - converter.CONFIG["snapshots"] = True - converter.CONFIG["snapshots_keep"] = keep - converter.CONFIG["snapshots_log"] = str(p) + monkeypatch.setitem(converter.CONFIG, "snapshots", True) + monkeypatch.setitem(converter.CONFIG, "snapshots_keep", keep) + monkeypatch.setitem(converter.CONFIG, "snapshots_log", str(p)) return p -def test_default_on_writes(tmp_path): +def test_default_on_writes(tmp_path, monkeypatch): # 默认开启:不显式设开关即落盘 - converter.CONFIG["snapshots"] = True - converter.CONFIG["snapshots_log"] = str(tmp_path / "s.jsonl") + monkeypatch.setitem(converter.CONFIG, "snapshots", True) + monkeypatch.setitem(converter.CONFIG, "snapshots_log", str(tmp_path / "s.jsonl")) assert converter._record_snapshot("/v1/chat/completions", "m", True, 0.0, request_body={"a": 1}) is not None assert (tmp_path / "s.jsonl").exists() -def test_explicit_off_writes_nothing(tmp_path): - converter.CONFIG["snapshots"] = False - converter.CONFIG["snapshots_log"] = str(tmp_path / "s.jsonl") +def test_explicit_off_writes_nothing(tmp_path, monkeypatch): + monkeypatch.setitem(converter.CONFIG, "snapshots", False) + monkeypatch.setitem(converter.CONFIG, "snapshots_log", str(tmp_path / "s.jsonl")) assert converter._record_snapshot("/v1/chat/completions", "m", True, 0.0, request_body={"a": 1}) is None assert not (tmp_path / "s.jsonl").exists() -def test_record_shape_and_sanitized(tmp_path): - p = _enable(tmp_path) +def test_record_shape_and_sanitized(tmp_path, monkeypatch): + p = _enable(monkeypatch, tmp_path) sid = converter._record_snapshot( "/v1/messages", "deepseek-v4.1-flash", False, 0.0, request_body={"model": "m", "key": "Bearer sk-12345678"}, @@ -48,8 +53,8 @@ def test_record_shape_and_sanitized(tmp_path): assert rec["error"].startswith("HTTP 400") -def test_rotation_caps_lines(tmp_path): - p = _enable(tmp_path, keep=5) +def test_rotation_caps_lines(tmp_path, monkeypatch): + p = _enable(monkeypatch, tmp_path, keep=5) for i in range(14): converter._record_snapshot("/v1/chat/completions", "m", True, 0.0, request_body={"i": i}) diff --git a/tests/test_snapshots_stream_resp.py b/tests/test_snapshots_stream_resp.py index 98d39eb..d3a47ef 100644 --- a/tests/test_snapshots_stream_resp.py +++ b/tests/test_snapshots_stream_resp.py @@ -19,11 +19,13 @@ def _collected(): } -def test_pseudo_stream_snapshot_has_resp(tmp_path): - converter.CONFIG["usage_log"] = None # 用量关闭也不影响快照腿 - converter.CONFIG["snapshots"] = True - converter.CONFIG["snapshots_keep"] = 200 - converter.CONFIG["snapshots_log"] = str(tmp_path / "s.jsonl") +def test_pseudo_stream_snapshot_has_resp(tmp_path, monkeypatch): + # 一律经 monkeypatch 改写全局 CONFIG:直接赋值不还原会把开关泄漏给后续用例/ + # 测试文件(顺序一变就出现莫名其妙的红/绿),也违反 AGENTS.md 的测试数据隔离铁律。 + monkeypatch.setitem(converter.CONFIG, "usage_log", None) # 用量关闭也不影响快照腿 + monkeypatch.setitem(converter.CONFIG, "snapshots", True) + monkeypatch.setitem(converter.CONFIG, "snapshots_keep", 200) + monkeypatch.setitem(converter.CONFIG, "snapshots_log", str(tmp_path / "s.jsonl")) token = converter._SNAP_CTX.set(("/v1/chat/completions", {"model": "m"})) try: async def drain(): diff --git a/tests/test_snapshots_wire.py b/tests/test_snapshots_wire.py index a6aa363..182f700 100644 --- a/tests/test_snapshots_wire.py +++ b/tests/test_snapshots_wire.py @@ -24,12 +24,14 @@ def test_record_usage_forwards_to_snapshot(): assert "_SNAP_CTX.get(" in src -def test_snapshot_independent_of_usage_log(tmp_path): - # 用量未启用时快照仍落盘 - converter.CONFIG["usage_log"] = None - converter.CONFIG["snapshots"] = True - converter.CONFIG["snapshots_keep"] = 200 - converter.CONFIG["snapshots_log"] = str(tmp_path / "s.jsonl") +def test_snapshot_independent_of_usage_log(tmp_path, monkeypatch): + # 用量未启用时快照仍落盘。 + # 一律经 monkeypatch 改写全局 CONFIG:直接赋值不还原会泄漏给后续用例/测试文件 + # (顺序一变就出现莫名其妙的红/绿),也违反 AGENTS.md 的测试数据隔离铁律。 + monkeypatch.setitem(converter.CONFIG, "usage_log", None) + monkeypatch.setitem(converter.CONFIG, "snapshots", True) + monkeypatch.setitem(converter.CONFIG, "snapshots_keep", 200) + monkeypatch.setitem(converter.CONFIG, "snapshots_log", str(tmp_path / "s.jsonl")) token = converter._SNAP_CTX.set(("/v1/chat/completions", {"a": 1})) try: converter._record_usage("m", True, 0.0) diff --git a/tests/test_turing_findsdk_chain.test.js b/tests/test_turing_findsdk_chain.test.js index 69a476f..a7d75a1 100644 --- a/tests/test_turing_findsdk_chain.test.js +++ b/tests/test_turing_findsdk_chain.test.js @@ -165,14 +165,27 @@ test('turing_helper 生产链路(findSdkDir 选择与优先级)', async (t) await t.test('G. 显式开启盘根扫描后,盘根候选才出现(纯盘符与带冒号两种写法都生效)', () => { withEnv({ ...redirectBases(sandbox), WORKBUDDY_TURING_SDK_DIR: undefined, WORKBUDDY_TURING_DRIVES: 'Z,Z:' }, () => { - const dirs = collectCandidateDirs().map((d) => d.replace(/\\/g, '/').toLowerCase()); - const zHits = dirs.filter((d) => /^z:\/(workbuddy)\//.test(d)); - // 两种写法都归一为 Z:\ → 各自产生 4 条候选(WorkBuddy/workbuddy × 2 相对路径), - // 但 add() 去重后总计 4 条(两种写法产生同一批路径)。 - assert.ok(zHits.length >= 4, - '设了 WORKBUDDY_TURING_DRIVES 后应产生盘根候选;实际:' + JSON.stringify(zHits)); + const norm = (d) => d.replace(/\\/g, '/').toLowerCase(); + const dirs = collectCandidateDirs().map(norm); + // 期望值必须用与实现相同的 path.join 推导,不能写死 `z:/workbuddy/` 正则: + // path.join 是平台相关的——Windows 下 path.join('Z:\\','workbuddy') === 'Z:\\workbuddy', + // 而 POSIX 下会得到 'Z:\\/workbuddy'(反斜杠被当普通字符,再补一个分隔符), + // 写死正则会让本用例在 Linux/macOS 检出上恒失败(CI 只跑 Windows,故此前未暴露)。 + // 两种写法('Z' 与 'Z:')归一后是同一组路径,add() 去重后应恰好 4 条。 + const expected = []; + const relPaths = [ + 'resources/app.asar.unpacked/native/turing-sdk', + 'resources/native/turing-sdk', + ]; + for (const base of [path.join('Z:\\', 'workbuddy'), path.join('Z:\\', 'WorkBuddy')]) { + for (const rel of relPaths) expected.push(norm(path.join(base, rel))); + } + const zHits = expected.filter((e) => dirs.includes(e)); + assert.strictEqual(zHits.length, 4, + '设了 WORKBUDDY_TURING_DRIVES 后应产生 4 条盘根候选(两种写法去重后);实际:' + JSON.stringify(zHits)); // 反向确认:候选确实来自「显式开启」这一条件,而不是无条件存在 - assert.ok(zHits.length <= 8, '不应重复铺开(add 去重应生效);实际:' + JSON.stringify(zHits)); + const allDriveHits = dirs.filter((d) => /(^|\/)workbuddy\//.test(d) && /^z:/.test(d)); + assert.ok(allDriveHits.length <= 8, '不应重复铺开(add 去重应生效);实际:' + JSON.stringify(allDriveHits)); }); }); diff --git a/tests/test_vision_inlining.py b/tests/test_vision_inlining.py index e03a3fb..3f26979 100644 --- a/tests/test_vision_inlining.py +++ b/tests/test_vision_inlining.py @@ -13,6 +13,7 @@ """ import base64 +import ipaddress from contextlib import asynccontextmanager import pytest @@ -26,6 +27,10 @@ FAKE_PNG = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" +# 域名打桩用的公网 IP:真实取数路径(_url_to_data_uri)会在内部自行解析域名, +# 若放任真实 DNS,用例就会隐式依赖「能上网 + example.com 能解析」。 +PUBLIC_TEST_IP = "93.184.216.34" + class FakeStreamResponse: """模拟 httpx 流式响应。""" @@ -40,10 +45,18 @@ async def aiter_bytes(self): yield self._body -def _install_stream_stub(monkeypatch, response: FakeStreamResponse, safe_override=True): - """把 httpx.AsyncClient.stream 替换为返回固定响应的桩,并放行 SSRF 校验。""" - if safe_override: - monkeypatch.setattr(converter, "_url_is_safe_for_fetch", lambda url: (True, "")) +def _install_stream_stub(monkeypatch, response: FakeStreamResponse): + """把 httpx.AsyncClient.stream 替换为返回固定响应的桩,并让域名解析到公网 IP。 + + 只打桩**真实的接缝**:SSRF 判定与下载都发生在 `_url_to_data_uri` 内部, + 统一走 `_resolve_public_connect_ip` → `_resolve_host_ips`(DNS)。 + 因此这里打桩 DNS 即可放行;绝不能像以前那样打桩 `_url_is_safe_for_fetch` + ——那是个「以为在放行、实际没人调用」的假接缝,会让人误判用例覆盖到了什么。 + """ + monkeypatch.setattr( + converter, "_resolve_host_ips", + lambda host: [ipaddress.ip_address(PUBLIC_TEST_IP)], + ) class FakeStreamCtx: async def __aenter__(self): @@ -144,10 +157,18 @@ async def test_responses_endpoint_e2e_inlines_input_image(monkeypatch): ("http://0.0.0.0/x.png", "内网"), ]) def test_ssrf_guard_blocks_dangerous_urls(bad_url, expect_keyword): - """内网/回环/元数据/非法协议地址必须被 SSRF 守卫拒绝。""" + """内网/回环/元数据/非法协议地址必须被 SSRF 守卫拒绝。 + + 注意 `expect_keyword` 必须真断言:此前它只作为参数收集、从未使用, + 于是「拒绝原因说得出是哪一类」这条契约其实是空的(拒绝对了但理由串台的 + 改动不会被发现)。分类由 `_resolve_public_connect_ip` 统一给出。 + """ ok, reason = _url_is_safe_for_fetch(bad_url) assert ok is False assert reason, f"{bad_url} 应给出拒绝原因" + assert expect_keyword in reason, ( + f"{bad_url} 的拒绝原因应点明「{expect_keyword}」,实际:{reason}" + ) @pytest.mark.parametrize("good_url", [ @@ -233,9 +254,13 @@ def stream(self, method, url, **kwargs): return FakeStreamCtx() # 首跳放行(公网),第二跳由真实守卫判定内网 → 必须拒绝 + # + # 注意:真实防线在 _url_to_data_uri 内部逐跳执行,_url_is_safe_for_fetch 没有生产调用点, + # 因此这里必须打桩「DNS 解析」而不是 _url_is_safe_for_fetch——否则首跳会因真实 DNS 是否可用 + # 而走出不同分支(离线时首跳就提前返回,重定向防线实际没被测到)。 monkeypatch.setattr( - converter, "_url_is_safe_for_fetch", - lambda url: (True, "") if "example.com" in url else (False, "内网"), + converter, "_resolve_host_ips", + lambda host: [ipaddress.ip_address(PUBLIC_TEST_IP)], ) monkeypatch.setattr(converter.httpx, "AsyncClient", FakeAsyncClient) diff --git a/tools/upstream-sources.json b/tools/upstream-sources.json index fa70d68..8d33476 100644 --- a/tools/upstream-sources.json +++ b/tools/upstream-sources.json @@ -1,7 +1,7 @@ { "version": 1, "description": "WorkBuddy2API 社区反代生态借鉴仓库看门清单。由 upstream-watch 工作流定期比对上游新提交,出现新特性或风控变更时评估摘樱桃(Cherry-pick)。", - "updated_at": "2026-09-17", + "updated_at": "2026-09-18", "sources": { "wb2api-upstream-xiaofan": { "name": "workbuddy2api 原版(xiaofan6ya,借鉴雷达)", @@ -34,8 +34,8 @@ "name": "workbuddy-switch-gateway(momo0410,借鉴雷达)", "repo": "momo0410/workbuddy-switch-gateway", "branch": "main", - "last_synced_commit": "50e88ae0", - "last_synced_sha": "50e88ae046294713eaeb4ee6f06a011dca42b474", + "last_synced_commit": "91b9a292", + "last_synced_sha": "91b9a2925c3b73e31c891e7541e44921137df4c8", "absorbed": "借鉴雷达(workbuddy2api 已吸收其积分到期日分层调度与到期时间解析)", "note": "账号管理+OpenAI兼容网关;已借鉴其按积分到期日分层选号调度算法(先烧快过期额度),优先调度即将过期账号池;监控整仓新提交。" }, @@ -76,11 +76,11 @@ "note": "腾讯代码助手转 OpenAI/Anthropic/Responses 协议适配器;已借鉴其 OpenAI Responses 协议(POST /v1/responses)双向转换与流式事件状态机设计;监控整仓新提交。" }, "wb2api-upstream-ardeyou": { - "name": "workbuddy2api-intl 国际版(ardeyouxipianyi,借鉴雷达)", - "repo": "ardeyouxipianyi/workbuddy2api-intl", + "name": "workbuddy2api-hub 国际版(ardeyouxipianyi,借鉴雷达;原 workbuddy2api-intl 已更名)", + "repo": "ardeyouxipianyi/workbuddy2api-hub", "branch": "main", - "last_synced_commit": "7c93b46a", - "last_synced_sha": "7c93b46a9e3e3b3e6c0245a4918e7e17cb1db0c4", + "last_synced_commit": "95c3e41d", + "last_synced_sha": "95c3e41d0322a94ee842834314305d853d06e2b2", "absorbed": "借鉴雷达(workbuddy2api 已吸收其国际版客户端 UA 仿真规范)", "note": "WorkBuddy 国际版反代网关(www.workbuddy.ai);已借鉴其国际版官方桌面 User-Agent 规范与海外域适配规则;监控整仓新提交。" }, @@ -133,8 +133,8 @@ "name": "workbuddy2api Go 核心原版(Sliverkiss,借鉴雷达)", "repo": "Sliverkiss/workbuddy2api", "branch": "main", - "last_synced_commit": "9d1a21bd", - "last_synced_sha": "9d1a21bdcb143527a4d1a0937c4495c026dbd783", + "last_synced_commit": "4ac68b77", + "last_synced_sha": "4ac68b772248832cebaea980a54480cf763f760d", "absorbed": "借鉴雷达(横向对比与加权调度/多账号池核心架构参考)", "note": "Go 语言实现的高性能多账号 OpenAI 兼容网关核心,支持 OAuth 登录、三因子加权池调度与流式响应;监控整仓新提交。" }, @@ -160,8 +160,8 @@ "name": "codebuddy2api Next.js 网关(orangeboyChen,借鉴雷达)", "repo": "orangeboyChen/codebuddy2api", "branch": "main", - "last_synced_commit": "45227c54", - "last_synced_sha": "45227c5417ae41a995e9276d4993ad43ef8ca5f8", + "last_synced_commit": "7ccf62f9", + "last_synced_sha": "7ccf62f93f33fe86c7ca4ceb0ad359db2870abe0", "absorbed": "借鉴雷达(TypeScript/Next.js 架构协议路由参考)", "note": "Next.js / TypeScript 实现的轻量三协议网关与调试追踪层;监控整仓新提交。" },