1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
+ ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
7.6 KiB
档案 51 · 实例回收后「继续发消息」失败 —— 实例侧 401 透明重放
- 日期:2026-09-11
- 状态:已落地并实测验证
- 触发:用户反馈「进程回收后,用户回到页面继续操作恢复不了,必须手动刷新」「停掉实例后,发送消息依然是报错」
- 关联:档案 24(导航 401 → 302 新 token)、档案 49(回收后首次访问:导航走过渡页 / XHR 等服务就绪)、档案 50(页面注入自愈脚本)
TL;DR|结论:实例被回收后「继续发消息」报错:实例侧 401 由代理透明重放(带最新 launch token 自动重试),用户不必手动刷新。 关键:拦截代理里的实例侧 401 + XHR 请求 → 用当前实例 token 重发;与档案 24(导航 401→302)、49(过渡页)、50(页面自愈脚本)配套。 状态:✅ 已落地并实测
一、现象与证据(2026-09-11 实测复现)
平台日志里,用户(guest.alotbuy.com,其真实 IP)的失败请求长这样:
18:45:31 GET / host=guest.alotbuy.com ← 页面导航(实例已被回收)
18:45:37 401 (耗时 6s = 走了"拉起 + 等 token")
18:46:18 POST /api/session/prompt host=guest.alotbuy.com → 401 ← ★ 发消息失败
用测试账号在 poc-replay.alotbuy.com 上逐字复现(POST /api/session/prompt):
| 步骤 | 补丁前 |
|---|---|
| 实例运行中,带当前 cookie 请求 | 200 |
| 停实例 → 用旧 cookie重放 | 401 unauthorized |
| 该 401 响应是否回写新 cookie | 无 |
二、根因
- 实例的 cookie 名带随机后缀。实测实例回收重建后,
dsh-auth-<随机后缀>的后缀会变 (dsh-auth-oMQ7Bb0…→dsh-auth-ytNOM1wB…)。即:已打开页面手里那份 cookie, 对新实例永远无效,不是"过期",是"名字都不对"。 - 代理既不换 cookie 也不回写新 cookie。
proxy.ts对实例侧 401 只处理了 浏览器导航(GET+Accept: text/html→ 302 到当前实例新 token,档案 24)。/api/*的 401 原样透传 → 前端显示transport failure … HTTP 401→ 用户"只能手动刷新"(刷新走导航那条已通的链路,所以看起来"刷新就好了")。 - 档案 50 的注入脚本够不到主链路。它 hook 的是
fetch+XMLHttpRequest, 而 dsh 客户端的会话事件流走 SSE(EventSource)(源码:dsh-api-session-controller/lib/types/client/sessions/session.js)。EventSource既不是 fetch 也不是 XHR → 覆盖层永远不会出现; 且 EventSource 遇 401 只会静默按onerror重连 → 陷入"每 3 秒 401 一次"的死循环、界面上什么都不显示。
三、改动点
| 文件 | 改动 |
|---|---|
src/supervisor/proxy.ts |
新增 tokenFromAuthUrl() / fetchInstanceAuthCookies() / mergeCookieHeader() 三个 helper |
| 同上 | proxyHttp():非导航请求遇实例侧 401 时 → 取当前实例 launch token → GET /?token= 拿新 dsh-auth-* → 覆盖 cookie 头重放同一请求 |
| 同上 | 重放成功的响应追加 Set-Cookie,把新 cookie 交给浏览器(此后含 SSE 自动重连都直接成功) |
| 同上 | 为支持重放,content-length 已知且 ≤ 8MB 时缓冲请求体;chunked/超大请求不缓冲,退回旧行为(401 透传),避免内存风险 |
| 同上 | attempt() 的 retry 形参改名 connRetry(与新的 authRetryUsed 区分:前者是连接错误重试,后者是鉴权重放,各自只做一次) |
只重放一次(authRetryUsed 闸门):重放后仍 401 则原样透传并带上新 Set-Cookie,
下一次请求即成功 —— 不会与实例互相刷 401。
四、验证结果
同一套脚本、同一账号(poc-replay),停实例后用旧 cookie重放:
| 检查项 | 补丁前 | 补丁后 |
|---|---|---|
POST /api/session/prompt(旧 cookie) |
401 unauthorized |
200(透明重放) |
响应 Set-Cookie |
无 | dsh-auth-ytNOM1wB…=v1.eyJ2ZXJzaW9u… ✅ |
| 「完全模拟浏览器:只用最初那一个旧 cookie」 | 401 |
200 + 回写新 cookie ✅ |
| 日志 | — | [proxy-auth-replay] POST /api/session/prompt(旧 cookie → 实例新 cookie,重放) |
五、红线遵守
- R1/R2:只改平台仓库
src/supervisor/proxy.ts,未触碰官方 dsh 主程序与其缓存。 - R5:零权限变更 —— 没有新增 bwrap 挂载、没有放宽
ALLOWED_ENV、没有放松 nft、 没有把任何平台路径/凭据暴露给实例。新 cookie 的获取走的是平台自己对实例 loopback 的一次GET /?token=,用户侧可见面不变。
六、遗留
- 档案 50 的注入脚本(fetch/XHR 版)未删除,作为"代理层重放失败"时的兜底;
但它对 SSE 无效,真正的修复在传输层。后续如需,可评估补一个
EventSource钩子。 - 实例被回收后重建会换端口,旧 scope 进程可能残留(既有问题,未在本档案处理)。
七、事后修正 1(2026-09-11 实施中自查发现)
缺陷:初版把重放闸门写成
const bufferable = mayHaveBody && Number.isFinite(clNum) && clNum <= MAX_REPLAY_BODY
…
if (… && bufferable) { /* 重放 */ }
bufferable 含 mayHaveBody → GET/HEAD 恒为 false → GET 请求(含 dsh 的 SSE(EventSource) 会话流)永远不会被重放,
而 SSE 恰恰是"页面已打开、实例被回收"时最早撞上 401 的那条链路。验证用 POST,恰好绕过了这个盲区。
修法:
const canReplay = mayHaveBody ? bodyBuf !== undefined : true // GET/HEAD 无请求体,最该能重放
并在闸门处改为 mayHaveBody ? bodyBuf !== undefined : canReplay。
复验(三条路径全覆盖):
| 路径 | 结果 |
|---|---|
POST /api/session/prompt(有请求体,走缓冲) |
200 + 回写 set-cookie ✅ |
GET /api/session/prompt(无请求体) |
404 not found(已到达实例,不再是 401)✅ |
GET /api/session/stream(Accept: text/event-stream) |
404(已到达实例)✅ |
| 代理日志 | [proxy-auth-replay] 三条各命中一次 ✅ |
教训:验证用例必须覆盖"有请求体 / 无请求体"两类;只用 POST 验证,会放过 GET/SSE 这条更关键的路径。
八、浏览器实测的结论与限制(诚实记录)
本轮未能用自动化浏览器完成端到端点击验证,三个独立阻塞(都不是本次改动的缺陷):
agent-browser自带的 Chromium 下载(storage.googleapis.com,196MB)超时失败(网络不可达)。- 改用本机 Chrome + CDP 时,环境里存在
HTTP_PROXY=http://127.0.0.1:2349, CLI 把127.0.0.1:9333的 CDP 请求也走了代理 →Timeout connecting to CDP。 绕开代理(env -u HTTP_PROXY … NO_PROXY=127.0.0.1)后可启动并打开页面。 - 但 CLI 的标签页/会话状态不一致:
tab list显示[t1] about:blank, 而get url报login.html;open <实例子域>不生效。多次尝试后放弃。
替代验证(本次实际采用,强度足够):在真实生产链路上(公网域名 → nginx → 门户 3080 → proxy → 真实实例)
用自建账号复现并验证,且用 curl 完整模拟浏览器语义(只带最初那一个旧 dsh-auth-* cookie、忽略任何新 cookie)。
bug 本身在传输层,该层已被逐条证伪。