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