# 24 · 实例侧 401(launch token 过期)浏览器导航自动恢复 - 日期:2026-09-11 - 触发:用户刷新实例子域页面(`admin.alotbuy.com`)看到 **`dsh web authentication required; reopen the URL printed by dsh web.`**,**既不自动跳转也不自动重启** - 结论一句话:**代理只处理了"门户侧 401"(会话失效),没处理"实例侧 401"(launch token 过期)** → 浏览器停在 dsh 的死端页。已修复:实例侧 401 + 浏览器导航 → 用当前实例的**最新 token 302 回同一地址**(拿不到则回门户)。 - 状态:**已实施并上线**(服务已重启,`lib/` 已验证) --- ## 一、现象与成因 | 环节 | 事实 | |---|---| | 触发场景 | 实例**重启后 token 轮换**(本次由我 00:14 为验证 picker 重启实例引起;此前 23:32 域名迁移 drain 亦同) | | 浏览器侧 | 旧标签页 URL 形如 `https://admin.alotbuy.com/?token=<旧token>`,token 已失效 | | dsh 侧 | 返回 `401` + 文案 `dsh web authentication required; reopen the URL printed by dsh web.`(HTML) | | 平台侧 | `proxy.ts` 的 401 处理**只覆盖门户自身判定**(`resolveSubdomainAccess` 的 `unauthorized` → 302 `/login.html`);**上游(实例)返回的 401 被原样透传** → 用户看到死端页 | | 为何没重启 | "自动重启"逻辑只在 `not_running`(404)分支触发;本例实例**正在运行**,只是 token 过期 | ## 二、修复(`src/supervisor/proxy.ts`) 1. `proxyHttp` 新增参数 `freshAuthUrl?: () => Promise`; 2. 上游响应为 **401 且是浏览器导航**(`GET` + `accept: text/html`)时: - `upRes.resume()` 释放上游连接; - 调用 `freshAuthUrl()` 取恢复地址并 **302**; 3. 子域 hook 传入的 `freshAuthUrl`:`app.supervisor.status(userId).main?.launchToken` → 有 token 则 `https://<同一 Host>/?token=<新token>`(**原地恢复,无需重新登录**),否则回门户根; 4. 路径式路由 `/u/:slug/dsh/` 同样接入(复用 `prefix`); 5. `SubdomainAccess` 成功分支补充 `userId`(取 token 需要)。 ## 三、验证 | 检查 | 结果 | |---|---| | `bash scripts/ci.sh`(typecheck + build + 单测) | ✅ 38 pass / 0 fail | | 运行产物 | ✅ `lib/supervisor/proxy.js` 含 `freshAuthUrl` ×3;`lib/supervisor/orchestrator.js` 含 `symlink` ×3(档案 23 的沙箱修复同时生效) | | 服务重启(drain) | ✅ `active` / 残留实例 0 / 门户 200 | | 端到端 | ⏳ 待用户实测:实例重启后刷新旧标签页应自动恢复(302 带新 token) | ## 四、回滚 - 代码:`/opt/dsh/backups/proxy.ts.bak-`;或 `git revert d7ab5b4` → `npm run build` → `systemctl restart dshs`。 ## 五、顺带说明(本次一并上线) - **档案 23 的沙箱修复同时生效**:实例 bwrap 参数补齐 `/bin`、`/sbin`、`/lib` 符号链接 → 实例内 **bash 工具恢复可用**(此前被 dsh 沙箱后端整体拒绝)。 - 本次重启后所有实例已清空,用户下次进入会以新参数启动。 ## 六、红线遵守 - 只改编排器自身代码(`src/supervisor/proxy.ts`),未触碰官方 dsh 主程序与缓存;修复走既有的"302 透明恢复"模式(与档案 13 的 not_running 自动重启同源)。