| 路径 | 内容 |
|---|
| docs/security.md | 新增 §6 R-VISITOR 补记(cookie 口径 / 归属鉴权 / 保留期 / CSRF);修订 §4、§6 里「无 cookie」的措辞范围 |
| apps/api/agent/migrations/007_visitor_sessions.up.sql | visitors 表 + sessions.visitor_id + 索引 |
| apps/api/shared/visitor-cookie.ts | cookie 名/解析/Set-Cookie 构造/token 哈希(agent 与 trace 两个服务共用的纯函数) |
| apps/api/agent/visitor.ts | 访客解析(只读)/ 发放 / 滑动续期 |
| apps/api/agent/purge.ts | 保留期清理(进程内定时器,自托管镜像不跑 Encore CronJob) |
| apps/api/agent/store.ts | 会话读写全部带 visitor 归属;新增 deleteSession / sessionOwnedBy / purgeExpired |
| apps/api/agent/sessions.ts | 列表/单查按归属过滤;新增 DELETE /agent/sessions/:id |
| apps/api/agent/ask.ts | 发放/续期 cookie;续接会话前校验归属 |
| apps/api/trace/store.ts · stream.ts | 轨迹流按归属放行(trace 仍只读、不写 visitors) |
| apps/web/lib/agent-api.ts · components/workbench/Workbench.tsx | 删除入口(hover × + 原生 confirm)与 404 兜底 |
| apps/web/lib/api-client.ts | dev.ps1 gen 重新生成(生成物,不手改) |
| apps/api/agent/visitor.test.ts · store.test.ts | 隔离、删除、保留期、滑动续期的自动化验收 |
| apps/api/agent/README.md · apps/api/trace/README.md | 端点与鉴权口径同步 |
| ROUNDS.md | 进度表加本轮;功能边界段补删除功能的裁定 |
| # | 检查 | 命令 / 期望 | 结果 |
|---|---|---|---|
| 1 | 跨访客列表隔离 | dev.ps1 test:访客 A 的 listSessions 不含访客 B 的会话;无 cookie 时返回空列表 | ✅ 单测 +『curl 两个 cookie jar』双证:A 列表 1 条、B(无 cookie){"sessions":[]} |
| 2 | 跨访客单查隔离 | getSession(B 的 id) 以 A 身份 → not_found(不是 403,不泄漏「这个 id 存在」) | ✅ 单测 + curl 404 |
| 3 | 跨访客轨迹隔离 | GET /trace/stream?sessionId=<B 的 id> 带 A 的 cookie → 404;不带 cookie → 404 | ✅ curl 两种情形均 404;本人取同一条流 200 + event: ready |
| 4 | 跨访客续接隔离 | POST /agent/ask 带 B 的 sessionId + A 的 cookie → 404 | ✅ curl:他人 cookie 404、无 cookie 404、本人 503(本机未配 LLM,已过归属闸) |
| 5 | cookie 属性 | 响应头 Set-Cookie 含 HttpOnly、SameSite=Lax、Path=/、Max-Age=86400;X-Forwarded-Proto: https 时带 Secure,http 时不带 | ✅ curl -i 实测响应头齐全且响应体里没有 token;Secure 分支由单测覆盖;浏览器里 document.cookie === ""(HttpOnly 生效) |
| 6 | 滑动续期 | 连续两次请求间隔后 visitors.expires_at 被推到 now()+24h;过期行不再被认领,访客拿到新身份 | ✅ 单测(压到剩 1h → 认领后回到 ~24h;过期行认领得 null,ensureVisitor 发新身份且看不到旧会话) |
| 7 | 删除本人会话 | DELETE /agent/sessions/:id → 会话及其 messages / trace_events 级联清空 | ✅ 单测 + curl 200,重复删 404 |
| 8 | 删除他人会话失败 | 以 A 身份删 B 的会话 → not_found,B 的数据完好 | ✅ 单测 + curl 404 |
| 9 | 保留期清理 | purgeExpired():last_active_at 早于 3 天的会话(含存量无归属会话)被删,新会话不受影响 | ✅ 单测 5 例(含边界「差一天不删」、存量无归属会话、幂等、访客行) |
| 10 | 前端隔离与删除交互 | 会话列表只出本浏览器的会话;删除按钮 hover 才出现,confirm 取消则不删 | ✅ 浏览器实跑:列表只出本 cookie 的 2 条;hover 才出 ×,取消不删,确认后 2→1 且左栏即时更新 |
| 11 | 样式零回归 | git diff 里 Workbench 除删除按钮外无样式/布局改动(规则 7) | ✅ diff 仅新增 hover 态与绝对定位的 × 及其 position:relative 容器;不 hover 时与画板 1a 一致(截图留证) |
| 12 | 编译与生成物 | dev.ps1 check 通过;dev.ps1 gen 后 api-client.ts 含 deleteSession 且前端类型对得上 | ✅ dev.ps1 check 通过;dev.ps1 test 11 文件 / 195 用例全过;apps/web tsc --noEmit 干净 |
第 1 轮(3 条:1×P1 · 2×P2),全部采纳整改:
| # | 级别 | findings | 处置 |
|---|---|---|---|
| 1 | P1 | 访客 cookie 会进 Encore trace(Path=/ 让它跟着每一个同源请求走,而收它的端点没设 sensitive) | 采纳。实测证实比 findings 说的多一处:trace 里 request_headers.cookie、response_headers.set-cookie、response_payload(Encore 记的是处理函数返回值,visitorCookie 字段在那里还没被抽成响应头)三处都有明文 token。给 5 个 agent 端点 + /trace/stream 加 sensitive: true;另外给三个浏览器直达的 notes 端点(/rss.xml、/rss/:file、/assets/notes/…)也加上 —— 它们根本不看这个 cookie,但 Path=/ 会把它一并送过去。整改后重抓 trace:请求头/响应头整段消失,两个 payload 都是 <redacted> |
| 2 | P2 | 前端删除失败仍在 finally 里 startNew(),把没被删掉的会话从界面上抹掉 | 采纳。清空当前会话挪进 .then();并在 lib/agent-api.ts 里把 404 当成成功(「它已经不在了」= 删除的目的达成),其余错误(409 / 5xx / 断网)照常抛出、保留选中。原实现与它自己注释里那句「失败只刷新列表」自相矛盾 |
| 3 | P2 | 删除端点的 rec.busy 读检查与异步 disposeSession 不原子,并发 ask 能挤进来 | 采纳。改用 runtime.claim() —— 与 /agent/ask 同一把同步检查+置位的闸,认领失败回 409;认领后若 disposeSession 抛错则把 busy 还回去。同时改 ask.ts:用户消息落库失败时无论是否新建会话都释放运行时会话 —— 落库失败的一个真实原因就是「会话刚被自己在另一个标签页删了」,原来只在 isNew 时释放会让一个指向已删除数据的 pi 会话占着并发名额直到 15 分钟空闲回收 |
第 2 轮(全量 --scope branch;首次自动判据误选了 working-tree,重发时显式指定范围)。 3 条:2×P1 · 1×P2 —— 1 条采纳整改、1 条所有者裁定不修、1 条写明理由不采纳:
| # | 级别 | findings | 处置 |
|---|---|---|---|
| 4 | P1 | .mcp.json 的 xray-admin-130 让管理面 bearer token 走明文 HTTP | 所有者裁定不修(2026-09-01):「130 内网不用管安全问题」。已记 rounds/BACKLOG.md 并关闭,边界写明仅限 130 内网 —— R11 的生产管理面在公网上,同样形态不成立。注:这条不属于本轮改动(是工作区里所有者自己的 .mcp.json 修改),第 1 次复审因自动判据选中 working-tree 才被扫到 |
| 5 | P1 | 第 1 轮只标了「我能枚举出的浏览器直达端点」,/api/notes/series、/api/about、/health 仍用默认 tracing,cookie 照样进 trace | 采纳。这是第 1 轮整改不彻底:Path=/ 下任何同源路径都会收到 cookie,逐个枚举「谁会被浏览器直接访问」本身就是错的方法。改为一条不变量:每个 expose: true 端点都必须带 sensitive: true,当前 16 个已全部覆盖(判据:两个 grep 的条数相等)。写进 shared/visitor-cookie.ts 与 docs/security.md §6。未改 Path:codex 点到的路径本来就在 /api 下,收窄 Path 一条都挡不住,却会让直连 :4000 的本地调试悄悄失效并把 cookie 与反代前缀绑死 |
| 6 | P2 | 认证成功但业务失败的响应(404/409)没重发 cookie,滑动窗口在这些路径上断了 | 写明理由不采纳。Encore 的 APIError 没有响应头这一层(实测 api/error.ts),要修就得把 404 改成「200 + 错误字段」或为它单开 api.raw,前者是更糟的接口、后者是机制类改动(CLAUDE.md 审查边界:非阻塞 findings 不新增机制)。兜底:滑动靠成功响应维持,工作台每次挂载与每轮对话结束都调用会成功的 listSessions。残留风险只在「连续 24h 只收到错误响应」的 curl/爬虫形态上。已记 BACKLOG,并把 sessions.ts 文件头那句被证伪的「每条响应都重发」改成准确口径 |
第 3 轮(按 CLAUDE.md「审查范围」收窄到整改 diff:--base 5912fe5)——零 findings: 「改动的 Encore 端点一致地开启了 sensitive tracing,覆盖当前全部 expose 端点且未改变行为, 未发现新的可行动正确性问题。」
同一验收项针对性整改后连续 2 次验证仍不过 → 写 rounds/round-visitor/BLOCKED.md,停下呼人。
把响应字段抽成类型别名复用,Encore 的静态解析器认不出来,会静默把它当成普通响应体字段:
| 写法 | 实测结果 |
|---|---|
| type VisitorCookie = Cookie<string, "xr_visitor"> 再复用 | ❌ 无 Set-Cookie 头;响应体变成 {"session":{…},"visitorCookie":{"httpOnly":true,"maxAge":86400,…,"value":"<明文 token>"}} |
| type VisitorCookie = Header<string, "Set-Cookie"> 再复用 | ❌ 无 Set-Cookie 头;响应体里 "visitorCookie":"xr_visitor=<明文>; Path=/; …" |
| 字段处内联 Cookie<string, "xr_visitor"> | ✅ 发头 |
| 字段处内联 Header<string, "Set-Cookie"> | ✅ 发头(最终采用) |
两个后果都是致命的:①浏览器根本收不到 cookie,访客身份永远建立不起来;②身份 token 明文 进响应体,页面里任何 JS 都读得到,httpOnly:true 成了一句写在 JSON 里的空话。 而它编译通过、请求 200、字段也在 —— 只有 curl -i 抓响应头才看得出来。
最终选 Header<string, "Set-Cookie"> 而不是内联的 Cookie<>:两条 api.raw (/agent/ask、/trace/stream)只能自己拼字符串,用 Cookie<> 等于让同一个 cookie 的属性 在两处各写一遍,漂掉一个 httpOnly 在浏览器里是看不出来的(后一个 Set-Cookie 直接覆盖前一个)。 现在属性只有 shared/visitor-cookie.ts 的 buildSetCookie 一个来源。
官方文档明确:cron 由 Encore 平台按注册时刻调用端点,本地开发与 Preview 环境都不执行。 本项目是 encore build docker + compose 自托管(规则 10),写 new CronJob(...) 只会得到一个 看起来很正规、实际永不触发的假清理。保留期清理因此落在 apps/api/agent/purge.ts 的进程内 定时器上(每小时一次,unref() —— 不 unref 的话 encore test 跑完要等一个钟头才退出)。
/agent/ask 新建会话那条分支(ensureVisitor → writeHead(200, {...SSE_HEADERS, "Set-Cookie"})) 在本机跑不到:R6 之后 LLM 凭据的唯一来源是 llm_config 表,而本机没有配 provider, acquireSession 在到达那行之前就抛 LlmNotConfiguredError 回 503。
没有为此去改本机 secrets 或插一条假 provider —— 那要动所有者的 .secrets.local.cue。 判它成立的依据是:同一个 api.raw 端点的 fail() 路径用的是同一套机制(node writeHead + Set-Cookie 键),而它已被实测证明有效(带 cookie 的 503 响应确实带回了 Set-Cookie); 剩下的差异只是一次对象展开。仍然把「新会话首帧带 Set-Cookie」列进 130 预发冒烟, 那里配了真 provider(与 R4 把镜像实跑冒烟并入 R9 是同一种处理)。
7cc17fe(main 合并后)本机构建 → dev.ps1 ship 130 → 按「先停旧版、再迁移、后起新版」升级 (docs/deploy-environments.md)。5c98b3e → 7cc17fe,迁移 6 → 7(007 是本轮唯一新迁移)。
| # | 检查 | 结果 |
|---|---|---|
| 1 | 三 Tab | /、/notes、/about 全 200 |
| 2 | 服务端点 | /api/health、/api/agent/sessions、/api/about、/api/notes/series、/rss.xml、/rss/<分类>.xml 全 200;/api/mcp 无 token → 401 |
| 3 | 新建会话首帧带 Set-Cookie | ✅ 本轮唯一在本机验不了的一条,在这里验掉(130 配了真 provider cliproxy-dmit/gpt-5.6-terra):新访客首次提问 → 200 + Set-Cookie: xr_visitor=…; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax,且真实 LLM 跑完一轮(收到 event: done)。ROUNDS.md 里交接给 R11 的那条可以划掉 |
| 4 | 存量会话不可见 | 库里 12 条 R9/R10 冒烟遗留会话迁移后 visitor_id 全为 NULL,新访客列表返回 0 条 |
| 5 | 跨访客隔离 | B(无身份)对 A 的会话:列表 0 条 · 单查 404 · 轨迹流 404 · 续接提问 404 · 删除 404;A 自己:轨迹流 200 · 删除 200 · 重复删 404 |
| 6 | 读路径不发放身份 | B 只调 GET /api/agent/sessions,全程没拿到 cookie,visitors 表没有因此长行(=2,只有真正建过会话的那两个) |
| 7 | 库侧完整性 | token_hash 是 64 位 hex(明文不入库)· expires_at - now() ≈ 24h · 删除后孤儿消息 0 / 孤儿轨迹 0(级联生效) |
| 8 | 内容零回归 | notes 13 系列 / 205 章节仍在(抽查 typescript-deep-dive),RSS 分类源 200 |
留在 130 上的冒烟痕迹:1 条属于一次性 curl 身份的会话(2 轮真实 LLM 调用), 其 cookie 已丢弃,对任何访客不可见,3 天保留期到点自动清掉,不需要人工处理。