Files
dsh_ai1net_server/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

116 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 50-会话过期自愈:实例 HTML 注入「重连」脚本(2026-09-11 落地)
## 背景
档案 49 修好了「实例被空闲回收后,请求失败在传输层」的问题(反代自动拉起 + 等待就绪),
但用户实测仍报:
```
client api: session/prompt failed: transport failure for /api/session/prompt: HTTP 401 (gateway/internal)
```
**根因(代码级确认)**:`proxy.ts` 里那段 401 处理**只对"浏览器导航"生效**:
```ts
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`,**又自动解压**)。
→ **凡"注入/改写响应"类改动,验证必须覆盖"浏览器式请求头 + 解压后正文"两个条件**。