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.0 KiB
7.0 KiB
50-会话过期自愈:实例 HTML 注入「重连」脚本(2026-09-11 落地)
背景
档案 49 修好了「实例被空闲回收后,请求失败在传输层」的问题(反代自动拉起 + 等待就绪), 但用户实测仍报:
client api: session/prompt failed: transport failure for /api/session/prompt: HTTP 401 (gateway/internal)
根因(代码级确认):proxy.ts 里那段 401 处理只对"浏览器导航"生效:
if (upRes.statusCode === 401 && request.raw.method === 'GET'
&& String(request.headers.accept ?? '').includes('text/html') && freshAuthUrl !== undefined) {
...302 到当前实例的新 token...
}
reply.raw.writeHead(upRes.statusCode ?? 502, headers); upRes.pipe(reply.raw) // ← XHR 一律原样透传
完整因果链:
- 实例空闲被回收 → 2. 用户回到已打开的页面继续对话,前端发 XHR,带着旧实例的
dsh-auth-*cookie → 3. 档案 49 的修复把实例拉起了(这一步生效)→ 4. 新实例发现 token 是旧 authority/端口的 → 401 → 5. 401 处理不管 XHR → 透传给前端 → dsh 显示transport failure→ 6. 用户手动刷新(=导航)才命中 401 处理、302 到新 token。
→ 档案 49 消灭了"传输失败",但没消灭"认证失败"。
方案取舍(用户选 C 先看效果)
| 方案 | 做法 | 评价 |
|---|---|---|
| B | 反代透明重放:XHR 401 时用 freshAuthUrl() 取新 token → 换新 cookie → 重放该请求 |
体验最好(前端无感),但要写 cookie 重放(中等复杂度)→ 留待后续 |
| C(本次采用) | 在反代返回的 text/html 里注入平台脚本 → 检测 /api/* 返回 401 → 显示覆盖层动画 + location.reload();reload 会走已经工作的"导航 401 → 302 新 token"链路自动恢复 |
简单、复用既有机制,且满足用户要的"当前页面给反馈" |
实现(src/supervisor/proxy.ts)
- 新增模块级常量
SESSION_RECOVERY_JS(1480 字节,ES5 安全、全程 try/catch、幂等window.__dshRecover):- hook
window.fetch与XMLHttpRequest.prototype.open/send(loadend回调) - 命中条件:状态 401 且 URL 含
/api/→ 弹覆盖层(转圈 + 「会话已过期(实例被回收重建),正在重新连接…」)→ 900ms 后location.reload()
- hook
injectRecovery(html):把<script>…</script>插到</body>之前(无</body>则追加)。proxyHttp的响应写出改为:仅当content-type: text/html且无content-encoding且状态非 204/304 时—— 缓冲正文 → 注入 → 重算content-length→ 写出;其余情况原样pipe(零影响)。 若因content-encoding跳过注入,会往 stderr 打[inject-recovery] skip:(便于发现"没注入"的原因)。- 不落盘、不改官方文件(符合 R2:只改写平台反代自身的响应内容)。
验证记录(真实反代链路,R4 模板测试)
| 检查 | 结果 |
|---|---|
enter 起实例 + curl -L(跟随 302)+ cookie jar 取 HTML |
24059 字节(正常) |
含 __dshRecover |
✅ 已注入 |
含 loadend 钩子 |
✅ |
</body> 出现次数 |
1 |
注入脚本是否在 </body> 之前 |
✅ |
提取注入脚本(以 __dshRecover 为中心向外取 <script> 边界)后 node --check |
✅ 语法正确 |
| 关键钩子齐备 | window.fetch ✅ / XMLHttpRequest ✅ / loadend ✅ / s!==401 ✅ / location.reload ✅ |
| 清理 | 测试实例已 stop、临时会话已删(user_agent=poc-inject3) |
首轮验证曾误报"脚本语法错误"——原因是我的提取正则跨了两个
<script>标签(dsh 把 boot 脚本放在<head>,内含</script>),换成"以__dshRecover为中心向外扩张"后通过。注入本身一直是对的。
仍未做
- B(透明重放):终态方案 —— XHR 401 时自动换新 token 重放,用户连 reload 都不需要。属后续增强。
- WS(
app.server.on upgrade):实例未运行时仍socket.destroy();dsh 前端会自行重连。若要"等就绪再建隧道"需在 upgrade 回调做同样等待(未做)。 - 真实浏览器端的行为验证:本次只验证了"注入正确 + 语法正确 + 结构正确";覆盖层与自动 reload 的交互效果需在浏览器里实测(起实例 → 停实例 → 在旧页面发消息 → 期望看到覆盖层并自动恢复)。
回滚
cp src/supervisor/proxy.ts.bak-<ts> src/supervisor/proxy.ts && npm run build && systemctl restart dshs
事后修正(2026-09-11 18:4x):注入对浏览器无效——上游 gzip 导致被跳过
用户实测反馈:「并没有效果,还是报刚才截图的错误」。排查后确认是我的 bug,且我此前的验证是假阳性。
根因
dsh 实例(上游)对带 Accept-Encoding 的请求会 gzip 压缩 HTML:
| 请求 | 上游响应头 | 注入 |
|---|---|---|
不带 Accept-Encoding(我最初的 curl 验证方式) |
content-encoding: (无) |
✅ 已注入 |
带浏览器式 Accept-Encoding: gzip, deflate, br |
content-encoding: gzip |
❌ 被跳过 ← 浏览器就是这种 |
我的代码在 content-encoding 存在时主动跳过注入(为避免破坏已编码内容)→ 浏览器拿到的页面没有自愈脚本
→ C 对真实用户完全无效。代理 stderr 里正好有 [inject-recovery] skip 记录,与推断一致。
修法
proxyHttp 里:只要客户端接受 HTML,就把转发给上游的 accept-encoding 覆盖为 identity
(预先算好 upHeaders 再传入 httpRequest)→ 上游回未压缩 HTML → 注入成功。
体积影响可忽略(HTML ~24KB);边缘 nginx 仍会对浏览器 gzip,用户侧无感知;静态资源/SSE 的压缩行为不受影响。
验证(浏览器等价:curl --compressed)
| 检查 | 结果 |
|---|---|
响应 content-encoding |
gzip(由边缘 nginx 压缩,非上游) |
| 解压后正文 | 24059 字节 |
含 __dshRecover / loadend |
✅ / ✅ |
代理 [inject-recovery] skip 记录(本次) |
0 条 ✅(修正生效,上游不再压缩) |
注入脚本 node --check |
✅ 通过 |
| 注入位置 | ✅ 在 </body> 之前 |
第二个教训:测试方法的假阳性 → 假阴性
- 假阳性(第一版验证):curl 不带
Accept-Encoding→ 拿到未压缩 HTML → 通过 → 误以为"浏览器也行"。 - 假阴性(复验时):
curl -H 'Accept-Encoding: gzip'时 curl 默认不解压 → 我读到的是压缩字节 →includes('__dshRecover')必然为 false → 误判"修复无效"。 - 正确姿势:模拟浏览器必须用
curl --compressed(既发Accept-Encoding,又自动解压)。 → 凡"注入/改写响应"类改动,验证必须覆盖"浏览器式请求头 + 解压后正文"两个条件。