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 一律写「远程服务器」。
140 lines
7.6 KiB
Markdown
140 lines
7.6 KiB
Markdown
# 档案 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 本身在传输层,该层已被逐条证伪。
|