Files
dsh_shenxian/dsh-server-docs/04-调整方案/50-会话过期自愈注入脚本.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
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 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

7.0 KiB
Raw Blame History

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 一律原样透传

完整因果链:

  1. 实例空闲被回收 → 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)

  1. 新增模块级常量 SESSION_RECOVERY_JS(1480 字节,ES5 安全、全程 try/catch、幂等 window.__dshRecover):
    • hook window.fetch 与 XMLHttpRequest.prototype.open/send(loadend 回调)
    • 命中条件:状态 401 且 URL 含 /api/ → 弹覆盖层(转圈 + 「会话已过期(实例被回收重建),正在重新连接…」)→ 900ms 后 location.reload()
  2. injectRecovery(html):把 <script>…</script> 插到 </body> 之前(无 </body> 则追加)。
  3. proxyHttp 的响应写出改为:仅当 content-type: text/html 且无 content-encoding 且状态非 204/304 时—— 缓冲正文 → 注入 → 重算 content-length → 写出;其余情况原样 pipe(零影响)。 若因 content-encoding 跳过注入,会往 stderr 打 [inject-recovery] skip:(便于发现"没注入"的原因)。
  4. 不落盘、不改官方文件(符合 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,又自动解压)。 → 凡"注入/改写响应"类改动,验证必须覆盖"浏览器式请求头 + 解压后正文"两个条件。