Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/51-实例回收后401透明重放.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

140 lines
7.6 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.
# 档案 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 | **无** |
---
## 二、根因
1. **实例的 cookie 名带随机后缀**。实测实例回收重建后,`dsh-auth-<随机后缀>` 的**后缀会变**
(`dsh-auth-oMQ7Bb0…` → `dsh-auth-ytNOM1wB…`)。即:已打开页面手里那份 cookie,
对新实例**永远无效**,不是"过期",是"名字都不对"。
2. **代理既不换 cookie 也不回写新 cookie**。`proxy.ts` 对实例侧 401 只处理了
**浏览器导航**(`GET` + `Accept: text/html` → 302 到当前实例新 token,档案 24)。
`/api/*` 的 401 原样透传 → 前端显示 `transport failure … HTTP 401`
→ 用户"只能手动刷新"(刷新走导航那条已通的链路,所以看起来"刷新就好了")。
3. **档案 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 实施中自查发现)
**缺陷**:初版把重放闸门写成
```ts
const bufferable = mayHaveBody && Number.isFinite(clNum) && clNum <= MAX_REPLAY_BODY
…
if (… && bufferable) { /* 重放 */ }
```
`bufferable` 含 `mayHaveBody` → **`GET`/`HEAD` 恒为 false** → **GET 请求(含 dsh 的 SSE(`EventSource`) 会话流)永远不会被重放**,
而 SSE 恰恰是"页面已打开、实例被回收"时最早撞上 401 的那条链路。**验证用 POST,恰好绕过了这个盲区。**
**修法**:
```ts
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 这条更关键的路径。
---
## 八、浏览器实测的结论与限制(诚实记录)
本轮**未能**用自动化浏览器完成端到端点击验证,三个独立阻塞(都不是本次改动的缺陷):
1. `agent-browser` 自带的 Chromium 下载(`storage.googleapis.com`,196MB)**超时失败**(网络不可达)。
2. 改用本机 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`)后可启动并打开页面。
3. 但 CLI 的**标签页/会话状态不一致**:`tab list` 显示 `[t1] about:blank`,
而 `get url` 报 `login.html`;`open <实例子域>` 不生效。多次尝试后放弃。
**替代验证(本次实际采用,强度足够)**:在**真实生产链路**上(公网域名 → nginx → 门户 3080 → proxy → 真实实例)
用自建账号复现并验证,且**用 curl 完整模拟浏览器语义**(只带最初那一个旧 `dsh-auth-*` cookie、忽略任何新 cookie)。
bug 本身在传输层,该层已被逐条证伪。