Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/51-实例回收后401透明重放.md
T

139 lines
7.6 KiB
Markdown
Raw Normal View History

# 档案 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 本身在传输层,该层已被逐条证伪。