| 向下协商 | 「保留向下协商」 | createMcpHandler(factory) 默认 legacy: 'stateless',同一个 factory 同时服务 2026-07-28 与 2025 时代流量 | 用默认值,不设 legacy: 'reject' |
| 挂载形态 | api.raw 单 POST /api/mcp | v2 handler 是 web 标准 fetch(Request);toNodeHandler 适配 (req,res) | api.raw → toNodeHandler(handler) |
| 供图路由 | 「URL 保持 /notes/<系列>/<哈希>.webp 不变」 | Encore 路由里 /notes/:series/:file 与既有 /notes/series/:slug 冲突 | API 侧路径改为 /assets/notes/:series/:file;Caddy 按扩展名 rewrite * /assets{path},next dev 同款 rewrite。对外 URL 不变 |
| 中转 baseURL | 「pi-ai 配置面的表达」 | ModelRuntime.registerProvider(id, { baseUrl, models })(ProviderConfigInput)+ setRuntimeApiKey(id, key) | llm_config 存 base_url 与可选 models JSONB,建会话前按行注册 |
| # | 检查 | 命令 / 期望 |
|---|---|---|
| 1 | MCP 客户端实连打通 | 官方 v2 客户端 + Claude Code 各连一次;server/discover、tools/list、tools/call 全通 |
| 2 | 认证 | 无 token / 错 token 全拒(不回显细节),且 mcp_audit 有 denied 记录 |
| 3 | 发布全链路 | 经 MCP 新建系列+含附件文章 → 前端渲染 → RSS 出现该条 |
| 4 | 存量零回归 | 56 张图回填后 /notes/<系列>/<哈希>.webp 仍 200;既有文章页与 RSS 不变 |
| 5 | key 只见掩码 | llm_provider_upsert 后 llm_providers_list 与任何读路径只回 sk-…abcd |
| 6 | 模型热生效 | 切换默认 provider/模型后新会话用新模型,无需重启 |
| 7 | 向下协商 | 2025-11-25 客户端(initialize 握手)也能 tools/list;GET/DELETE 回 405 |
| 8 | 退役干净 | /admin/* 404;tools/notes-sync、dev.ps1 notes、sync-notes skill 不存在;.agents 已重同步 |
| 9 | 构建门禁 | dev.ps1 check + dev.ps1 test 全过;--services 含 mcp |
| 10 | 无引导密钥 | 仓库内无 DEEPSEEK_API_KEY/DeepSeekApiKey 残留;未配 provider 时 /agent/ask 回明确的 503 |
| # | 级别 | findings | 核实 | 处置 |
|---|---|---|---|---|
| 1 | P1 | .env.example 用 Get-Random 生成 token 与加密密钥 | 属实。PS 5.1 的 Get-Random 背后是 System.Random(非密码学 PRNG),而 docs/security.md §4 要求 token「高熵随机」 | 改用 RandomNumberGenerator::Create().GetBytes();两处生成命令都重写 |
| 2 | P1 | 清空 baseUrl/models 时旧 overlay 不会被撤掉 | 属实且有安全后果。pi 源码注释原文:"Re-registration merges defined values over the previous registration and preserves undefined ones";两字段都为 null 时旧写法干脆不调用 registerProvider。表现是所有者以为撤掉了中转,而 key 与 prompt 还在发往那个端点 | applyLlmConfig 改为先 unregisterProvider 再按新配置重建,与合并语义无关。桩中转实测:配上 4002 → 会话命中 1 次;清空 baseUrl → 新会话命中数不再增加 |
| 3 | P1 | 切 provider 时 removeRuntimeApiKey 会打断旧 provider 的既有会话 | 属实。ModelRuntime 是单例,pi 每次请求都经 prepareRequest → getAuth 重新解析凭据(源码核实),撤掉 A 的 key 会让 A 的既有会话下一轮失败 | 删掉那次 removeRuntimeApiKey(它本来只是「不留无用凭据」的洁癖)。同时校准文档口径:换模型只影响新会话,换 key / baseUrl 是进程级的、作用于所有会话的下一轮 |
| 4 | P2 | 可覆盖的附件 URL 不该标 immutable | 属实。notes_asset_put 明确支持同名覆盖,而 immutable 让浏览器一年内不复验 | Cache-Control 从 max-age=31536000, immutable 改为 max-age=86400,保留强 ETag。没有改成「强制文件名 = 内容哈希」:存量 56 张的名字是 R5 按源文件算的哈希,与上传字节的哈希对不上,强制等于废掉存量 URL |
| 5 | P2 | SHA256::HashData 在 PS 5.1 不存在,文档命令跑不通 | 属实,当场复现:Method invocation failed because [System.Security.Cryptography.SHA256] does not contain a method named 'HashData' | 换成 SHA256::Create().ComputeHash(...),并在本机 PS 5.1 实跑确认两条命令都出正确结果 |
| # | 级别 | findings | 核实 | 处置 |
|---|---|---|---|---|
| 1 | P1 | Caddy 的 path_regexp 用 RE2,不支持非捕获组 (?:…),生产会拒绝启动 | 误报。RE2 支持 (?:re)(它不支持的是反向引用与 lookaround)。用真 Caddy 验:caddy validate 回 Valid configuration;再起容器实跑六条路径,/notes/pi/abc123.webp → /assets/notes/pi/abc123.webp、/notes/pi/01 → WEB、/api/mcp → /mcp 全部正确 | 不采纳,并把这次实跑当作 Caddy 配置的验收证据留档 |
| 2 | P1 | /mcp 端点未标 sensitive,凭据会进 Encore trace | 属实,且实测比描述更严重。本地 trace 里直接读到 authorization: Bearer <明文管理 token> —— 服务端只存哈希这件事会被一份 trace 整个抵消。(body 那半条不成立:raw 端点的 request_payload 是 e30=,即 {},LLM key 没被记) | 加 sensitive: true。修后复查同一端点的 trace:request_headers 整段消失,payload 变 <redacted> |
| 3 | P1 | 热会话不重读 LLM 配置,凭据撤销不可靠 | 属实,但只对了一半(实测拆开的) | 热路径补上 applyLlmConfig。凭据这一半修好了:删 provider 后同一个热会话下一轮立即 503(实测)。端点/模型那一半修不掉:pi 的 Model 对象自带 baseUrl 且被 AgentSession 从创建起持有,重注册 provider 换不掉它(实测:热会话第 2 轮仍打到已清空的旧中转)。要不要让进行中的对话中途换端点/模型是产品取舍,按 CLAUDE.md「审查不负责长出方案」记 BACKLOG 待裁定 |
| 4 | P2 | Encore bodyLimit 默认 2 MiB,与工具声明的 10 MiB 上限矛盾 | 属实(类型注释写明默认 2 MiB)。存量最大附件 205 KB,所以回填没暴露它 | 反过来收:MAX_ASSET_BYTES 10 MiB → 4 MiB(R5 管线压到 1600px,4 MiB 已是最大存量的 20 倍),bodyLimit 显式设 8 MiB。顺带踩到:该字段只能写字面量整数,写常量或 8 * 1024 * 1024 都报 expected integer literal |
| 5 | P2 | 并发 makeDefault=true 会撞唯一索引回 500 | 属实。READ COMMITTED 下两个事务各自从自己的快照清旧默认再置位,后提交者撞 idx_llm_config_single_default;而 MCP 客户端可以并发发 tool call | 两个相关事务开头加一句 SELECT provider FROM llm_config FOR UPDATE,让第二个事务阻塞到第一个提交后重新求值。一条语句,不引入锁表/重试机制 |
| 6 | P2 | 大写扩展名的附件能传上去,但生产 Caddy matcher 只认小写 | 属实,同一次 Caddy 实跑复现:/notes/pi/abc.PNG 落到了 WEB 而不是 ASSET | ASSET_NAME_RE 去掉 i 标志,只收小写。收紧输入比让两处 matcher 变大小写不敏感更省 —— 文件名本来就约定是小写十六进制哈希 |
四条都是第 2 轮修复引出的连带问题,值得单独记一笔:第 2 轮为了让热会话跟上配置, 在热路径加了「读库 + 施加」,于是把三个新面暴露出来(并发、模型校验连累、撤销语义)。
| # | 级别 | findings | 核实 | 处置 |
|---|---|---|---|---|
| 1 | P1 | 删掉非默认 provider 后,它的既有会话仍拿着已删的 key 与端点跑 | 属实。热路径只读「当前默认」那一行,而 applyLlmConfig 又刻意保留旧 provider 的 key(那是第 1 轮的整改) | RuntimeSession 记下创建时的 providerId;热路径发现它与当前默认不一致就重建会话(释放 → dispose → 落到既有的冷启动路径,库内历史照常注入,访客这一轮正常继续)。空闲回收走的就是这条重建路径,不是新机制。实测:relay-a 会话打到桩中转 1 次 → 切默认到 deepseek 并删掉 relay-a → 同一会话第 2 轮命中数不再增加,日志出 rebuilding session … provider relay-a → deepseek |
| 2 | P1 | 「读配置 + 施加」没有串行,并发请求跨在一次配置变更上会把旧 key 写回去 | 属实。appliedFingerprint 与 ModelRuntime 都是进程级的,而热路径每轮都读一次 | 用本文件已有的 promise 链写法(serializeColdStart 同款)把读与施加绑成一段。链上只有一次单行查询与几个内存操作,不含 LLM 调用,不引入新的锁原语 |
| 3 | P2 | 默认模型填错会连已有会话一起打死,与「换模型只影响新会话」的契约矛盾 | 属实,是第 2 轮引入的回归:热路径调的 applyLlmConfig 末尾会解析模型句柄并抛错 | 把函数拆成两段:refreshLlmConfig(注册 provider + 装 key)与 resolveModel(只有冷启动调)。实测:模型改成不存在的之后,热会话第 2 轮 HTTP 200,新会话 503 —— 正是契约描述的样子 |
| 4 | P2 | SELECT … FOR UPDATE 在空表上锁不住东西,两个「第一次配 provider」的并发调用照样撞唯一索引 | 属实 | 换成事务级 advisory lock(pg_advisory_xact_lock)。与表里有没有行无关,一条语句覆盖两种情形,随事务结束自动释放 |
这一轮我改了一个自己定的契约,值得单独说明。 第 2/3/4 轮的多条 findings 其实是同一个 设计问题的不同入口:「进行中的会话与配置变更的关系」。我原先定的是「换模型只影响新会话」, 为此让热会话保留旧的 Model 句柄 —— 而那个句柄自带 provider 的端点,于是每一轮复审都能 从另一条路走到「已在内存的会话拿着新 key 打旧端点」。第 3 轮我把它记进 BACKLOG 当作产品取舍; 第 4 轮的 P1 说明它不是可以搁置的取舍,而是会持续产出撤销漏洞的结构。
于是收敛成一条统一规则:会话记下创建时的配置指纹,指纹变了就在下一轮被重建到新配置上 (走空闲回收已经在用的那条重建路径,库内历史照常注入,访客无感)。代价是换默认模型也会作用于 进行中的对话 —— 但那比「有漏洞但不换模型」好解释得多,BACKLOG 里那条设计项也随之关闭。
| # | 级别 | findings | 核实 | 处置 |
|---|---|---|---|---|
| 1 | P1 | 只比 provider 名字不够:「删掉 A → 用新端点/新 key 重建同名 A → 设默认」这条路上名字没变,于是不重建,而新 key 已装好 —— 会话下一轮拿新凭据打旧端点 | 属实 | 判据从 providerId 换成 configFingerprint(覆盖 provider / baseUrl / 模型 / key 全部字段)。并保住第 3 轮的 P2:重建前先 resolveModel 试一次,解析不出就原地留着旧会话、只拒新会话。实测:同名重建到新端点 4003 后,旧端点 4002 的命中数不再增加;同一批实测里坏模型仍是「热会话 200 / 新会话 503」,日志出 keeping session … on its previous config |
| 2 | P2 | llm_provider_delete 没进 advisory lock;它与 upsert 并发时能插在「读 existing」与「UPDATE」之间,让 UPDATE 影响 0 行而 MCP 照样回成功 | 属实。advisory lock 与 FOR UPDATE 不同,不会顺带保护既有行不被 DELETE | deleteProvider 也取同一把闸 |
| # | 级别 | findings | 核实 | 处置 |
|---|---|---|---|---|
| 1 | P2 | 会话重建与配置写入之间有竞态:确认新配置可用之后才 dispose 旧会话,但 createRuntimeSession 会在冷启动链里重读配置;若这两步之间又写入一个解析不出模型的配置,旧会话已没、新的建不起来 | 属实,结构上确实存在 | 不采纳,记 BACKLOG(缺陷门禁允许:P2、非阻塞、不丢数据)。理由三条:①代价有界 —— 消息早已落库、dispose 前先排干轨迹,访客下一轮照常继续;②触发条件是「一次无效的管理写入恰好落在毫秒级窗口」,而那个写入本身就会让所有新会话 503,多这一个会话不改变处境;③真正的修法是「先建新的再拆旧的」,会动到 disposing 映射与 seq 续接那套保护(它们是前几轮 P1 的整改产物)—— 属机制类改动,按 CLAUDE.md 不在非阻塞 findings 的整改范围 |
同一验收项针对性整改后连续 2 次仍不过 → 写 rounds/round-06/BLOCKED.md 停下呼人。
| # | 检查 | 结果 |
|---|---|---|
| 1 | MCP 客户端实连 | ✅ claude mcp list → ✔ Connected。抓包实测 Claude Code 原生说 2026-07-28:三个请求依次是 server/discover → subscriptions/listen → tools/list,均带 MCP-Protocol-Version: 2026-07-28 与 Mcp-Method 头,没有走 initialize 握手 |
| 2 | 认证 | ✅ 无 token / 错 token / 多带一个字符 → 全 401 unauthorized(文案一致,不回显细节);带 Origin → 403;四次尝试在 mcp_audit 各留一条 denied 记录 |
| 3 | 发布全链路 | ✅ 新建系列 → 上传 1×1 webp → 发文章引用它 → /notes/r6-smoke/01 渲染出图(naturalWidth=1,来自 Postgres)→ /rss.xml 与 /rss/engineering.xml 出现该条 |
| 4 | 存量零回归 | ✅ 56 张存量图经 notes_asset_put 回填(6.47 MB),apps/web/public/notes/ 已删除;/notes/pi/0b60f550dd19.webp 仍 200,带强 ETag,If-None-Match → 304。文章页(无扩展名)不受 rewrite 影响,仍 200 |
| 5 | key 只见掩码 | ✅ llm_providers_list 只回 sk-…3f9a;库内 api_key_enc 61 字节密文、不含明文;mcp_audit 全表搜不到 key 片段 |
| 6 | 模型热生效 | ✅ 切到不存在的模型 → 新会话 503、热会话不受影响(200);切回 → 新会话建起。最终口径(第 4 轮收敛):配置指纹变了,会话在下一轮被重建到新配置上;新配置解析不出模型时既有会话原地不动。四个场景均实测,见审查段 |
| 7 | 向下协商 | ✅ 2025-11-25 的 initialize 握手 200 且 tools/list 拿到 21 个工具;GET/DELETE → 405;不支持的版本 → 400 UnsupportedProtocolVersionError;头体不一致 → 400 -32020 HeaderMismatch |
| 8 | 退役干净 | ✅ /admin 404、next build 5 条路由无 admin;tools/notes-sync、dev.ps1 notes、sync-notes skill 已删,.agents/skills 重同步为 8 个 |
| 9 | 构建门禁 | ✅ dev.ps1 check 通过;dev.ps1 test 8 文件 111 用例全过(R6 新增 32 个);tsc --noEmit api 与 web 均干净;--services 已含 mcp |
| 10 | 无引导密钥 | ✅ 活代码与配置里 DEEPSEEK_API_KEY/DeepSeekApiKey 零残留(只剩历史轮次任务卡的记述);未配 provider 时 /agent/ask → 503 对话服务尚未配置模型 |
@modelcontextprotocol/sdk 是错的包。 它最新版(1.30.0,发布于 2026-07-27)的 LATEST_PROTOCOL_VERSION 仍是 2025-11-25,server/discover 根本不存在。 2026-07-28 由 SDK v2 提供,以全新包名发布(@modelcontextprotocol/server / @modelcontextprotocol/client,2.0.0),旧包停在 1.x。装错包的表现不是编译失败, 而是做出一个「看起来实现了但协议版本不对」的服务端。
subscriptions/listen 不是「不实现就没有」。 它由 createMcpHandler 自带 (默认上限 1024),而 Claude Code 一连上来就调。留着等于在管理端点上开一条 Encore 网关下断连探测不到的长连 SSE(本仓库 R3/R4 已两次实测确认这一点), 没有任何东西能收尾。用 maxSubscriptions: 0 在开流前拒掉;客户端拿到 -32603 照常工作。
供图路由与既有 notes 路由撞车。 /notes/:series/:file 和 /notes/series/:slug 在 Encore 路由里同层冲突,只能给 API 侧换前缀(/assets/notes/…)。 而 Next 的数组式 rewrites 属 afterFiles,在动态路由之前生效—— 若不按扩展名限定,/notes/pi/01 这种文章页会被一起劫走。
public/ 会盖住 rewrite。 图片回填进库之后,/notes/pi/*.webp 仍由 Next 从 public/ 直接返回(弱 ETag、max-age=0),看起来「一切正常」但根本没走新路径。 必须真的把文件删掉才能验到供图端点。
import { z } from "zod" 在 vitest 下是 undefined。 同一份代码 node / bun 直跑都正常, 只有 vitest 的 SSR transform 拿不到那个命名空间导出。换成 import * as z from "zod" (zod 4 把构造器摆在包顶层,语义完全一致)。
npm run build 会把正在跑的 next dev 搞坏。 两者共用 apps/web/.next/, 生产构建覆盖掉 dev 的 chunk 之后,页面报 Cannot find module './833.js', 而后端一切正常——很容易误判成本轮改动的锅。验收时要么先停 dev, 要么接受之后得 rm -rf apps/web/.next 再重起 dev。
llm_provider_upsert 原本会静默清零限额。 apiKey 是「省略即保留」,其余字段却带 zod 默认值 → 只想改个 baseUrl 的一次调用把日限额一起清成 0,而回执看起来一切正常。 已统一成「省略即保留,null 才是清空」,并补了针对性用例。
镜像里不烧任何 notes 内容,所以每个环境的图片都要各自入库一次。二进制在本轮之后 不再存在于工作区,从 git 历史取(删除前的提交是 081f56c):
预发/生产也可以直接 pg_dump/pg_restore 搬库(内容是数据,不是制品)。