# @hyzyn/dsh-docker 缺陷编号字典 > **这份文件是代码注释的编号字典,不是审计报告。** > > `src/` / `client-src/` / `test/` / `scripts/` 里有大量 `(Dxx)` 注释,含义是「这段代码为什么 > 长这样」,编号的出处就是本文。拿到任意一个 `(Dxx)`:先查 [§1 编号索引表](#1-编号索引表) > 知道**当年坏了什么**,再查 [§2 编号字典](#2-编号字典这段代码为什么长这样) 知道**所以代码为什么 > 写成这样**。 > > **编号是硬契约**:`D01–D170` 是 `packages/docker` 内部序列,与 `packages/tty/DEFECTS.md` 的 > `D01–D106` **不共享**;跨包引用请写「docker D03 / tty D12」。新缺陷接在 `D170` 之后, > **不得重号、不得回收空号**——源码里已有注释指向它们。 > **本文不含**:逐条 postmortem(症状 / 现场复现 / 根因 / 修法 / 回归 / 反向验证)。 > 那是 commit message / PR description 的内容;本文只留**结论与取舍**,正文在哪见 > [§4 冻结记录](#4-冻结记录被移出正文的内容在哪)。 ## 维护规则 > **通用条目已上收到 L0,本块只留指针**(同一条规矩原先逐字抄在三份 `DEFECTS.md` 里,改一处 > 漏两处)。条目编号保持不变,各处「维护规则 N」的引用继续有效;**包特有的东西不在上收范围** > ——本文件里的 ⚠️ 标注、§4 冻结记录说明都留在原地。 1. **新缺陷只做两件事** → [conventions.md § 编号规范 硬规矩 7](../../docs/conventions.md#编号规范)。 2. **索引表的归档判据** → [同上,硬规矩 8](../../docs/conventions.md#编号规范)。 3. **索引表的写入纪律** → [同上,硬规矩 4](../../docs/conventions.md#编号规范)。 4. **阈值 / 口径 / 基线数字只增不改** → [conventions.md § 文档分档 三条纪律第 3 条](../../docs/conventions.md#文档分档)。 > ⚠️ **标注(本次未擅改)——规则 2 的阈值已被现状越过。** > 当前索引表 **140 行**,远超 80 行;但 `D01–D140` 里有 **115 个编号**被(该数在 `D138` > 时点实测,未重算) > `src/` / `client-src/` / `test/` / `scripts/` 引用(共 320 处 `(Dxx)`), > 整批搬去 archive 会让源码里的注释失去解析处。**归档只能针对"已不再被源码引用"的编号**, > 当前一个这样的编号都没有。故此处只记录规则与现状,不执行搬迁。 ## 现状 **已修 170 / 待修 0**,编号至 `D170`。逐条症状见 §1,设计意图见 §2,**还没做的见 [ROADMAP.md](./ROADMAP.md)**。 > ⚠️ **标注(本次未擅改)——两处口径不一致,原文未改:** > 1. 本文原开头写「**125 条已全部修复**,随 docker 0.7.0 发布」;而编号已到 `D140` > ——「125」是 0.7.0 时点的数字,此后用户实测 / 复核 / 真机波又加了 `D126–D140`(15 条)。 > 3. **⚠️ 2026-09-26 补记(计数漂移已修)**:`D139` 是上一轮加进索引表的,但当时**没同步 > 更新「现状」行与文件头的编号范围**(仍写 `已修 138 / 编号至 D138`,而表里已有 139 行)。 > 本次把计数改成与表内一致(`已修 140 / 编号至 D140`,含新增的 D140),并按 > [conventions.md § 编号规范](../../docs/conventions.md#编号规范) 的硬规矩在此记下漂移是怎么发生的。 > 2. §3 小节标题写「**0.7.0 基线**」,而 `packages/docker/package.json` 现为 `0.7.3`。 > > 原始发布记录(照录,未改):docker **0.7.0** 为 minor —— `/attention` 返回体新增 > `total/truncated/degraded`(`items` 仍在)、变更端点与四条 SSE 新增同源证明、列表类截断 > 从「静默少列」改为报错、hostKeys 的删除改为优先于并集、空 `since` 视为未传,均属行为变化; > 同批 `@hyzyn/dsh-all` 0.1.37 / `@hyzyn/dsh-plugin-kit` 0.1.31;tag `v0.1.37`。 ## 1. 编号索引表 > 只回答「当年坏了什么」。**症状列的关键词就是检索锚点**(如 `inflight`、`keepTail`、 > `assertSince`、`dropConn`)——`git log -S'<关键词>'` 能直接落到修复提交。 > 严重度(P1/P2/P3)与「第一轮 / 第二轮 / 新引入」的考古信息**已按维护规则 3 移出**: > 前者是按行重数的时点分档,后者是修复波的批次,两者都不参与「这段代码为什么长这样」。 | D | 症状(一句话:当年坏了什么) | 涉及文件 | |---|---|---| | D01 | 空闲回收只看长流,`docker_image_pull` 这类在途的一次性长命令会被中途掐断 | src/ssh-exec.ts | | D02 | 同一目标的并发首个请求各建一条 SSH 连接,先建的那条立刻脱管 | src/ssh-exec.ts | | D03 | tty 的指纹种子恒为空(tty 已改用 `fingerprints[]`)→ 对 tty 钉扎过的主机静默重新 TOFU | src/index.ts | | D04 | SSE 无背压:`write()` 返回值被丢弃,慢客户端 + 话痨容器 → 宿主写缓冲无界增长 | src/index.ts | | D05 | `client.connect()` 的同步异常留下一条永假的池条目,改对配置也不恢复 | src/ssh-exec.ts | | D06 | `dropConn(key)` 无身份校验:旧连接的 close/error 会摘掉同键上的**新**连接 | src/ssh-exec.ts | | D07 | 传输错误重连丢连接却不 `end()`、配额类错误被判成传输错误 → 泄漏健康连接 + 可操作文案永不到达用户 | src/ssh-exec.ts | | D08 | 短命令路径逐 chunk `toString('utf8')`,跨分片的多字节字符变成 U+FFFD | src/ssh-exec.ts | | D09 | TOFU 记一条指纹会触发 `applySection` → `closeAllStreams()`:刚开的流被掐、在途 `docker pull` 被中止 | src/index.ts | | D10 | `POST /config` 整表覆盖 hostKeys(targets 有两重保护、hostKeys 没有)→ 面板一次无关保存即回退钉扎 | src/index.ts | | D11 | 「反复重启」判据缺失:`attention()` 取到 RestartCount 却从不参与判据,crash-loop 容器漏报 | src/docker.ts | | D12 | `attention()` 的 limit 先切后排且静默 → 最严重的容器可能被切掉,返回体无任何截断信号 | src/docker.ts | | D13 | 列表/详情类方法丢弃 `result.truncated`:静默少列容器/镜像/网络/卷、inspect 把「截断」误报成「不存在」 | src/docker.ts | | D14 | 截断保留头部、丢弃尾部:logs 丢最新行、pull 丢 digest、prune 丢总计 | src/ssh-exec.ts | | D15 | 改目标名会静默清掉已存的 password/passphrase(`mergeTargetSecrets` 按 name 匹配) | src/index.ts | | D16 | `docker_image_pull` 的超时被当成成功返回(`pull()` 丢 `timedOut`,`exec()` 会抛错) | src/docker.ts、1462 | | D17 | 日志 FOLLOW 的 effect 声明了 `active` 却漏进 deps → 折叠面板后 SSE 不断,白占 SSH 通道 | client-src/index.js | | D18 | 自动刷新 effect 不认 `active` → 折叠/隐藏后仍每 5s 轮询(总览页是 N 目标各两次 docker 调用) | client-src/index.js | | D19 | `chooseInitialTarget` 不校验 `current` → 目标被删/改名后面板每次打开都停在「未知目标」且不自愈 | client-src/index.js | | D20 | 两处「复制命令」直连 `navigator.clipboard`(非安全上下文同步抛错),已有的兜底函数是死代码 | client-src/index.js | | D21 | 面板 config 只在挂载时拉一次 → 设置里改「允许变更操作 / exec」对已打开面板不生效 | client-src/index.js | | D22 | 设置卡片保存无脏检查:请求飞行期间的编辑(含刚敲的密码)被响应整表回滚 | client-src/index.js | | D23 | `sessionScoped` 粘滞 → 从连接栏进入但未匹配到目标后,「切目标中」的锁与胶囊永久失效 | client-src/index.js | | D24 | `openContainerPanel` 的 tab 分支不 `closePanel()` → 两个 ContainerPanel 并存并共享模块级 `panelUi` | client-src/index.js、5703-5751 | | D25 | `route-smoke.mjs` 号称离线,实际向硬编码内网 IP 发起真实 SSH 连接并断言其「不可达」 | scripts/route-smoke.mjs | | D26 | `stream()` 在池里找不到条目时静默跳过配额判定与 busy 自增(fail-open) | src/ssh-exec.ts | | D27 | `acquire()` 的失败也落在「传输错误重试」范围内 → 不可达目标每次命令等两轮 20s | src/ssh-exec.ts | | D28 | `agentForward` 配了等于没配:从不给 `ConnectConfig.agentForward` 赋值 | src/ssh-exec.ts | | D29 | `auth=agent` 缺 `SSH_AUTH_SOCK` 时无预检(tty 已修,docker 未跟上) | src/ssh-exec.ts | | D30 | `expandHome` 只认 `~` 与 `~/`:`~user/...`、Windows 变量一律原样返回 | src/ssh-exec.ts | | D31 | loopback 围栏的 Host 白名单过窄:本机别名 / 非 127.0.0.1 环回地址让整个面板(含唯一的启用入口)403 | src/index.ts | | D32 | 无 `Origin` 的请求靠 `Sec-Fetch-Site` 兜底,而 `GET /images/pull/stream` 是「带副作用的 GET」 | src/index.ts | | D33 | 统计流去重只比较相邻上一条:多容器时同一轮重复采样不会被去掉 | src/index.ts | | D34 | `pickTarget` 对非字符串 `target` 静默回落到唯一目标(破坏性操作打错主机的最后一道防线) | src/index.ts | | D35 | `sanitizeTargets` 在读路径就去重/丢弃,下一次保存把丢弃结果固化 → 重名目标永久消失 | src/index.ts | | D36 | `applySection(patch)` 不在 try 内:`refreshTools` 抛错时用户拿到空 400,而配置已落盘 | src/index.ts | | D37 | 写路由对非法引用回 500(客户端错误报成服务端错误),4xx 校验只在少数几条入口做 | src/index.ts | | D38 | `parseInspectPorts` 的去重键只有 hostPort → 同端口多 IP 绑定的第二条被并掉,详情比列表少端口 | src/docker.ts、277 | | D39 | `parseStatsJson`:`PIDs` 字段缺失时得到 `0` 而不是 `null` | src/docker.ts | | D40 | `parsePorts` 静默丢弃端口区间(`8000-8005->8000-8005/tcp`)→ 端口整行消失 | src/docker.ts | | D41 | docker 的零值时间未归一(`0001-01-01T00:00:00Z`)→ 破坏「最近出事优先」排序,详情/hover 显示公元 1 年 | src/docker.ts | | D42 | `attention()` 静默吞掉 inspect 失败:OOM/退出码/重启次数/时间全降级且无任何标记 | src/docker.ts | | D43 | `assertBin` 只把关整串首字符:`docker --version` 能通过校验(不可注入,但报错退化为运行期 ENOENT) | src/docker.ts | | D44 | `logs()` 注释称「按到达顺序合并」,实现是 stdout 整段在前、stderr 在后 | src/docker.ts | | D45 | `since` 两套口径:events 有白名单(且窄于 docker 的 Go duration 语法),logs 完全不校验 | src/docker.ts、src/index.ts | | D46 | 共享 `targetParam` 文案向 12 个单目标工具暗示支持 `*` / 省略=全部,实际报错 | src/index.ts | | D47 | `docker_ps` 回完整 64 位 ID(README 写「短 ID」),且与 `docker_attention` 的短 ID 口径不一 | src/index.ts | | D48 | `docker_targets{probe:true}` 丢掉 `serverVersion`(README 承诺「探测 docker 版本」) | src/index.ts | | D49 | `docker_events` 描述写「八类」,白名单实为九类(README 写九类) | src/index.ts、src/docker.ts | | D50 | `docker_logs` 描述把默认行数写死 200,实际取配置 `logTailDefault` | src/index.ts | | D51 | 参数 schema 无 enum/边界:`action` 无 enum,`tail`/`timeoutSec` 越界被静默夹紧 | src/index.ts | | D52 | `docker_stats` 的 `ids` 传空串/纯空白静默变成「全部容器」 | src/index.ts | | D53 | `docker_attention` 在「零目标 + `target:'*'`」时渲染成「一切正常」(假阴性) | src/index.ts | | D54 | exec 输入框回车绕过 `execRunning` → 连敲回车会并发执行同一条命令 | client-src/index.js | | D55 | 统计流结束后 `statsNotice` 常驻并替换正文 → 快照数据到手也不显示 | client-src/index.js | | D56 | 「按时间」排序:尾部窗口首行若是无时间戳续行,会被排到窗口最前 | client-src/index.js | | D57 | 拉取进度「同层原地替换」只在相邻行成立、超限与暂停缓冲都静默丢行 | client-src/index.js | | D58 | 导出 .md 的代码围栏未转义:日志里出现 ``` 会截断代码块 | client-src/index.js | | D59 | `.dk_kvVal` 缺 `white-space: pre-line` → 多挂载/多网络等多行值被压成一行 | client-src/docker.css | | D60 | 日志过滤工具条无 `flex-wrap`:窄面板下输入框塌到 0 宽、右侧按钮被裁 | client-src/docker.css | | D61 | 聚合日志没有「用户上滚即暂停贴底」:读历史时每来一行都被拽回底部 | client-src/index.js | | D62 | 刷新竞态:快照轮询无请求序号(慢响应覆盖新响应)、重连补偿与防抖刷新共用同一代际闸 | client-src/index.js | | D63 | 日志无虚拟滚动:每个 chunk 全量重渲染最多 2000 行并重复重算过滤 | client-src/index.js | | D64 | 键盘可达性缺口(四处):抽屉拖拽条 / 日志区不可聚焦 / 总览表行 / 键盘触发的菜单定位 | client-src/index.js | | D65 | 右键「问 Agent」浮层与 5 个 document/window 监听器没有卸载清理点 | client-src/index.js | | D66 | 目标缓存的「30s 过期刷新」不存在:`cacheAt` 只写不读,README 与注释都承诺了它 | client-src/index.js | | D67 | `downloadText` 固定 1s 后 revoke blob URL,且 `` 从未插入 DOM | client-src/index.js | | D68 | 容器日志原文(不可信输入)整段进 agent prompt,只提示凭证风险、无「不构成指令」声明 | client-src/index.js | | D69 | 设置卡片的数字输入直接 `Number(...)`:`3.5` 被后端判非整数后静默退回默认值 | client-src/index.js | | D70 | 三套旗舰脚本(3586 行 / 158 断言)在 CI 与发布闸里零执行 | .github/workflows/ci.yml | | D71 | 三个 smoke 脚本没有超时/看门狗:任一挂起即整脚本永久挂住 | scripts/*.mjs(文件尾) | | D72 | `files` 不含 `scripts/`,但 package.json 仍 advertise `smoke`(tty 的 D42 在 docker 复现) | package.json | | D73 | 无测试的关键路径(tty D45 同款):TOFU 指纹与 SSH 连接构造零自动化覆盖 | src/ssh-exec.ts | | D74 | `client-lint` 的锚点只认入口文件:client-src 兄弟模块的诊断被静默丢弃 | scripts/client-lint.mjs | | D75 | README(中英)说 `enabled: false` 需重启才生效,实现是保存即热生效(同包测试断言的就是热路径) | README.md | | D76 | README 中英三处「离线回归项数」与实测不符,且中英互相不一致(27 vs 62 最悬殊) | README.md | | D77 | README dev 段把本包 vitest 说成「三套」,实际 6 套 103 例 | README.md | | D78 | README 手工验收清单写「agent 侧只有 7 个只读工具」,实际恒注册 11 个 | README.md | | D79 | README 中英各有排版残迹:整段重复粘贴的残句 + 失衡的代码围栏 | README.md、README.en.md | | D80 | 别名 Host 走异步分支时提前 `return`,`cross-site`/Origin 检查被整段跳过 → 围栏被绕过 | src/index.ts | | D81 | `agentForward: true` + 宿主无 `SSH_AUTH_SOCK` → ssh2 同步抛错,该目标每次都连不上 | src/ssh-exec.ts | | D82 | 保存飞行期间删除的主机指纹被静默丢弃:钉扎删不掉,UI 却显示已删 | client-src/index.js | | D83 | `finish()` 清空背压队列:`end`/队尾帧被丢,慢客户端重连并**重拉镜像** | src/index.ts | | D84 | 关掉「允许变更操作」不终止在途的镜像拉取流 | src/index.ts | | D85 | attention 候选 >300 时第 301 条起没有 inspect 详情,`degraded` 仍为 false | src/docker.ts | | D86 | 300 id 单批 inspect × 默认 512KB:一截断就**整批**降级,D11 判据整体失效 | src/docker.ts | | D87 | crash-loop 补捞预算被合法候选吃光 → D11 在最需要时不出手且零信号 | src/docker.ts | | D88 | `stream()` 的 `busy` 记在重连前的废条目上:配额失效 + 长流 120s 后被 sweeper 掐断 | src/ssh-exec.ts | | D89 | D35 新增的「无效条目已丢弃」warning 是死代码,永不触发 | src/index.ts | | D90 | D21 只推 `config` 不推目标列表:下拉里有已删目标、缺新目标 | client-src/index.js | | D91 | `content-visibility` 让 `scrollHeight` 变估算值 → FOLLOW 贴底失效、「回到底部」也回不到底 | client-src/docker.css | | D92 | 聚合日志重建流时不复位 `atBottom`(D61 只做了一半) | client-src/index.js | | D93 | 重连补偿改走共享尾沿防抖,事件密集时被无限取消 | client-src/index.js | | D94 | 占位条目在建连途中被摘掉后,`ready` 仍 resolve 出一条脱管连接 | src/ssh-exec.ts | | D95 | `closePanel()` 管不到 tab 实例:从连接栏进入仍可并存两个面板 | client-src/index.js | | D96 | 新安全闸门的拒绝分支零回归(删掉调用,三套脚本 + 119 例仍全绿) | scripts/route-smoke.mjs | | D97 | `/action`、`/stats`、`/exec`(空 command)仍回 500 而非 400 | src/index.ts | | D98 | `POST /logs` 的 `since` 仍未过 `assertSince`、空串在工具侧报「必填」、在 SSE 侧被忽略 | src/index.ts | | D99 | `assertSince` 与 docker 口径两向不吻合(`1.5h`/`0` 被拒,裸日期被放行) | src/docker.ts | | D100 | 单目标 `docker_attention` 的渲染丢 `total/truncated/degraded` | src/index.ts | | D101 | 面板与 `/attention` 路由都没接 `total/truncated/degraded`,计数静默 ≤100 | src/index.ts | | D102 | `parseInspectPorts` 仍整段丢弃区间端口(详情比列表少端口) | src/docker.ts | | D103 | 区间端口字段无任何消费方:显示成单端口(`8000→8000/tcp`) | src/index.ts | | D104 | `imageInspect` 的两段 `docker history` 从不检查 `truncated` | src/docker.ts | | D105 | `assertComplete` 把「静默部分结果」变成「整体失败」,文案对 agent 不可执行 | src/docker.ts | | D106 | `FRESH_UP_RE` 只认 ≤59 秒,与 `ATTENTION_FRESH_MS`(120s)不一致 | src/docker.ts | | D107 | `refreshTools` 半套注册 + D09 差异判定 → 重存同一配置不自愈 | src/index.ts | | D108 | `sameTargets` 按下标比较:仅顺序变化即收流 | src/index.ts | | D109 | `hostKeysRemove` 与并集顺序:同一请求的删除被撤销、非法形状静默忽略 | src/index.ts | | D110 | 请求路径内的 DNS 判定无超时、无缓存,且发生在写响应之前 | src/index.ts | | D111 | `poolKey` 未小写化:同一主机建两条连接,通道额度被悄悄翻倍 | src/ssh-exec.ts | | D112 | `run()` 的 `inflight` 只靠 channel 事件释放,超时定时器不兜底 → 连接永不回收 | src/ssh-exec.ts | | D113 | 数字输入框无法「清空再重打」(空串被整数正则拒绝) | client-src/index.js | | D114 | `sessionScoped` state 化后,`deps: []` 的挂载 effect 仍读首帧闭包 | client-src/index.js | | D115 | README 的 `hostKeys[]` 表仍是单数 `fingerprint` | README.md | | D116 | README 路由表 `/attention` 行仍是旧形状(只有 `items`) | README.md | | D117 | README 未记录 D32 收紧的行为(缺同源证明 → 403) | README.md | | D118 | README 的 vitest 清单仍写「六套」,漏掉本波新增的 `ssh-connect` | README.md | | D119 | CI 补了、**发布闸没补**:`release.yml` 仍零执行三套脚本 | .github/workflows/release.yml | | D120 | `files` 加了 `scripts` 仍不够:`client-smoke` 读未发布的 `client-src/` → `npm run smoke` 仍坏 | package.json | | D121 | 三套看门狗在主体结束即 `clearTimeout`,退出前的排空期失去保护 | scripts/client-smoke.mjs | | D122 | 建连超时路径脚本/单测双双归零,且 15s 用例上限 < 20s 建连超时 | scripts/route-smoke.mjs | | D123 | 本波新行为(`inflight`/`keepTail`/无 sock 的 `agentForward`/`assertSince`)零自动化覆盖 | test/ssh-connect.test.ts | | D124 | 新测试里 `auth=key` 用例名与断言不符(实际走 password 分支) | test/ssh-connect.test.ts | | D125 | README(中)两处删「(N 项)」时吃掉了后面的空格 | README.md | | D126 | 目标引用的连接簿条目失效时**界面上看不出来**:`book` 下拉的候选来自 ttyBooks,失效名字没有对应 option → 下拉渲染成**空白**;且错误文案让人「去 tty 终端面板的设置卡片里添加」,而那张卡片改不了 docker 目标的引用 | src/index.ts、client-src/index.js、client-src/session-target.js | | D127 | docker 设置卡片「目标名」输入框**打一个字就失焦**:`dk_targetRow` 的 React key 写成 `String(index) + item.name`,key 含被编辑的字段 → 每次输入 key 变化、React 卸载重建整行,输入框当场丢焦点(表现为"无法聚焦") | client-src/index.js | | D128 | 日志大流量**逐 chunk 全量重渲染**(打开 FOLLOW 的 tail 突发即数百次全量 reconcile,且每 chunk 对全缓冲 join/split 大字符串)叠加**缓冲只限行数不限字节、残行无界** → 话痨 / 大行容器把渲染进程吃到 OOM,网页直接崩溃 | client-src/index.js、client-src/log-buffer.js(新增) | | D129 | 修 D128 时顺手把显示层截断到 400 行(并另设 2000 行导出上限)——`LINES` 选 5000 实际只显示 400 行,与「选多少看多少」的设计不符;且「行数闸」与设置卡片的「输出上限(KB)字节闸」在 UI 上没区分 | client-src/index.js | | D130 | `docker_ps` 把双栈端口渲染成重复映射:去重键含 hostIp(`0.0.0.0` 与 `[::]` 不同),render 又丢掉 hostIp → `6379→6379/tcp,6379→6379/tcp`;顺带修掉裸 `84->84/tcp` 被切成 `hostIp:'8'` | src/docker.ts、scripts/smoke.mjs | | D131 | host 网络容器在 `docker_ps` 里 `ports` 为空,与「确实没暴露端口」无法区分(用户为此多 inspect 了 5 个容器) | src/docker.ts、src/index.ts、client-src/index.js | | D132 | `docker_inspect` 未命中只回 `No such object: `,不给近似候选(如 `607023340cbb_rmqnamesrv`) | src/docker.ts、src/index.ts | | D133 | 日志流断线重连**重放历史**:EventSource 自动重连复用带 `tail` 的 URL,服务端把最后 tail 行当新行重推,客户端缓冲只在 effect 重跑时重建 → 日志里凭空多出一段重复并挤掉真正的历史;宿主侧 8MB 背压队列溢出走静默 `res.end()`,客户端把它当「流正常结束」再自动重连,同样触发重放 | src/docker.ts、src/index.ts、client-src/log-stream.js(新增)、client-src/index.js | | D134 | 镜像拉取进度流**逐行落地**:每个 SSE line 都跑一次 `mergeProgress`(slice + 重建 key 索引,O(行数))再 `setLines` → 长拉取(多 GB / 数十层)时每秒数百次 2000 行重渲染,与 D128 同一类写法只是量级小 | client-src/index.js | | D135 | 日志级别过滤对 `%5p` **右填充**的级别(`[INFO ]` / `[WARN ]`)完全失效:选到 `WARN+` 乃至 `ERROR+` 仍显示一屏 INFO,且这些行**没有分级着色** | client-src/index.js | | D136 | `LINES` 选 N 行、右侧计数显示 **N+1**:快照切分用 `text.split('\n')`,而 docker logs 每行都以 `\n` 结尾(终止符),于是多切出一条空行——计数多 1、末尾多一条不可见空行、导出也多一行;同一份日志在快照视图与 FOLLOW 视图下行数不同 | client-src/index.js、client-src/log-buffer.js | | D137 | 日志导出的两个格式(`.log` / `.md`)各占一个 chip,窄面板下 `.dk_filterBar` 一换行 `.md` 就被甩到第二行、把行数计数也带下去,工具条长成两行 | client-src/index.js、client-src/docker.css | | D138 | 单目标数据路由的目标侧失败(SSH 不可达 / 私钥读不到 / docker 不在)被统一写成 **500**,而 `target:'*'` 对同一件事回 200 + `groups[].ok:false`——同一句错误两种形状,「目标不可达」被当成服务端故障 | src/index.ts | | D139 | 桌面版四条流全断:桌面壳把 `dsh-app://app/api/…` 的请求转给宿主时删掉 `Origin` / `Sec-Fetch-Site`、只重写 `Cookie`,而 D32 的同源证明要求两者之一 → 四条 SSE 与八条变更路由在桌面版**全部** 403;客户端看到的是无限「连接中断,正在自动重连…」+ 0 行日志,宿主侧当时**一条日志都没有**(同面板的只读路由照常可用,所以看着像"只有流坏了") | src/index.ts、test/logs-stream.test.ts、test/streams.test.ts、README.md、README.en.md | | D140 | 代理命令(ProxyCommand)失败时的错误文案**可能只剩一句「连接已关闭」**:ssh2 一看到流断了就报错,而「子进程退出 / 传输关闭」这两个事件比它晚 1~2ms,那一刻失败事实还是 null → 用户拿不到子进程自己打印的原因(`ECONNREFUSED` / `Cannot find module` 这类)。**只有真机验收能暴露**(真 ssh2 + 真子进程的时序) | src/ssh-exec.ts、test/ssh-proxy-command.test.ts | | D141 | 能力开关的有效值(**配置 && 已授权**)被折叠进 `live`,而 `applySection` 重算的输入也是 `live`——于是「配置早就是 true、之后才拿到授权」这一路永远回不到有效值:授权到了、破坏档工具却不注册(半个状态)。`onGrantChange` 里调 `applySection({})` 也救不回来,因为折叠后的 `false` 已经把配置里的 `true` 吃掉了 | src/index.ts(把**配置值**单独留一份 `configuredCapabilities`,重算时用它覆盖折叠值)、test/elevate-route.test.ts(配置预言 true + 授权到达 → 工具立即注册,且全程**没有** `/config` 写入) | | D142 | `snapshot()` 只回 `*Granted`,不回**配置值**与**授权来源**:「配置开着但没获授权」这个状态在界面上无法表达(只剩一句长黄字,用户多半当成插件坏了),「撤销」入口也无从区分带外授权(可撤销)与启动环境授权(撤不了,只能去改启动环境) | src/index.ts(新增 `*Configured` / `*GrantSource`)、client-src/index.js(未生效徽标 + 来源行)、test/elevate-route.test.ts | | D143 | 设置卡片有 6 处文案带字面 `**`(`hint.byteCap` / `hint.logTailDefault` / `hint.maxOutput` 的 zh 与 en)——客户端没有 markdown 渲染器,用户看到的是两个星号 | client-src/index.js(改成「」/ “”)(目录头注释里写明这条规矩) | | D144 | 同源证明的判据是「**精确子路径** + POST」:`/elevate` 族有三条子路由,只把 `'/elevate'` 写进 `MUTATION_SUBROUTES`(或把某一条做成 GET)会让另外两条**裸奔**——跨站页面能撤销授权、能反复对着确认挑战试错。**从测试里才发现**:一开始把整段分发写在证明检查**之前**,等于所有子路由都不设防 | 三条子路径**逐条**列入 `MUTATION_SUBROUTES`、全做成 POST、分发挪到证明检查之后;test/elevate-route.test.ts 逐条断言 403(并核对错误文案是「同源证明」那条) | | D145 | 建连失败时 ssh2 会 emit **不止一个** `error`(socket 层一次、client 层的「握手完成前关闭」再一次),而 `acquire()` 用的是 `client.once('error')`:第一个 error 消费掉监听之后,第二个成了「没有监听者的 `error` 事件」——Node 按约定**直接抛成 uncaught exception**。症状是「目标不可达」这条最平常的路径把进程带崩:本机 `pnpm test` 22 条断言全过、**退出码却是 1**(vitest 只把它记成 runner 级 Unhandled Error,任何断言都不会变红,所以既有用例在修之前也一直是绿的) | `client.on('error')` / `client.on('close')` 常驻(`settleError` 已按 `settled` 幂等,重复进来是 no-op);test/ssh-connect.test.ts 新增一条**自己接住 `uncaughtException` 并断言「一次都没发生」**的用例——反向验证过:把两处改回 `once` 就能让它红 | | D146 | 同一个设置卡片里两种「删除」外观:目标行的删除按钮是 `.dk_btnDanger`(红框、按内容宽、右对齐),TOFU 指纹行那一处却只写了 `.dk_btn`——于是它既不吃 `.dk_targetRow > .dk_btnDanger { justify-self: end }`(被拉满 150px 的操作列),又是中性色。两行用的是**同一套**列模板(`1fr 96px 1fr 150px`),同一个卡片里两种删除说不通 | TOFU 那处补上 `dk_btnDanger`;CSS 规则改成按**位置**生效(`.dk_targetRow > .dk_btn, .dk_targetRow > .dk_btnDanger`),后来人换类名也不会掉出去。实测 6 个按钮(3 目标 + 3 指纹)类名 / 宽度 50 / 右缘 1067 / 边框与文字色全同 | | D147 | 可点的开关行**没有手型**:`input[type=checkbox]` 的 `cursor` 由浏览器 UA 样式定死(`default`),**不随 label 继承**——`.dk_check` / `.dk_capRow` 只给 label 写 `cursor: pointer` 时,鼠标停在最该点的那 16px 复选框上却是箭头(用户反馈:「这个没做手型」) | 两处都补 `.dk_check input[type="checkbox"], .dk_capRow input[type="checkbox"] { cursor: pointer }`;真机实测(CDP)label 与 checkbox 的 computed cursor 都是 `pointer` | | D148 | 「撤销宿主授权」与**同一个卡片里的 `删除`** 风格不一:删除(目标 / 指纹)是危险色,撤销却是中性灰——两处都是「按下去少一条记录、要重新配回来」的动作,红/灰混用会让人以为撤销可随手点;而且 tty 卡片那份 `tt_capRevoke` 本来就是危险色,同一个按钮在两个卡片里两种样子(用户反馈:「为什么撤销的按钮风格不统一」) | `className` 加上 `dk_btnDanger`(与那 6 个删除按钮**同一个类**,实测同为 `rgb(236, 19, 19)` + 危险色边框);`dk_capRevoke` 只留「右对齐」那一条 | | D149 | 提权面板的动作按钮与说明文字**混排**:命令、复制、倒计时、等待说明、重新生成各占一条栅格行,「重新生成」被两行说明文字隔在下面——按钮离自己的命令越远,越像「不知道点了会发生什么」(2026-09-27 真机报告:Windows 缩放下的截图里这一整片读起来是散的)。同一份报告里的「这两个按钮没有手型」**未复现**:`.dk_btn` 从 docker 0.1.0 起就写着 `cursor: pointer`,CDP 实测 computed cursor 为 `pointer`、按钮中心命中自身;宿主 CSS 的 58 条 `cursor` 规则与全部 `html[data-platform=…]` 规则也压不掉它 | `client-src/index.js` 把「复制 / 重新生成 / 倒计时」并成 `.dk_elevActions` 一行、紧贴命令;`client-src/docker.css` 给面板与步骤容器加 `grid-template-columns: minmax(0, 1fr)` + `min-width: 0`(命令是等宽长串,auto 轨道按 max-content 会顶出卡片右边界,窄栏 + Windows 125%~150% 缩放最明显),动作行 `flex-wrap: wrap` 兜住极窄宽度。CDP 实测 360/420/520/700/900px 五种宽度:面板不溢出、命令在框内横向滚动、两个按钮均命中自身 | | D150 | 一个 SSH 目标只维持**一条**连接,而远端 `MaxSessions` 的 10 个 session 槽是**按连接**算的:长流上限 8 留出的两条余量只够**串行**的短命令用,可真实面板一次点击就会并发发出不止两条(详情页的概览 inspect + 日志快照 + 统计快照),agent 侧也会并发调 `docker_logs` / `docker_inspect`。多出来的那条通道被远端**直接拒绝**:用户看到「FOLLOW / 自动刷新都关着,日志却读不出来」,报 `(SSH) Channel open failure: open failed`——248 的 sshd 侧原文是 `error: no more sessions`(2026-09-29 14:24:55–14:32:19,同一条连接反复拒收)。更糟的是这条连接**不会自己恢复**:中止长流时发的 `signal('KILL')` 在部分 sshd 上被拒(`error: session_signal_req: session signalling requires privilege separation`),而 sshd 在子进程仍活着时**延迟释放** session 槽(`session.c`:`delay detach of session`),于是插件侧 `busy` 已归零、远端仍占着槽位 →「重试」永远打在一条满连接上(每次 `acquire()` 还刷新 `lastUsed`,连 120s 空闲回收都推后了),直到连接被换掉才恢复(实测 14:31:18 换连接后立刻正常,此前 13:59 / 14:06 两次配置保存也各换过一条) | `src/ssh-exec.ts`:① 新增 `ShortChannelGate`——**每个池键最多 2 条并发短命令**,其余 FIFO 排队(名额转交而非「先减后加」,不许新来的抢走刚释放的名额;`DSH_DOCKER_SHORT_CHANNELS` 可覆盖,仅测试 / 排障),`disposeAll()` 放行排队者;② `openChannel` 新增 `isChannelExhaustedError`(认 ssh2 的 `Channel open failure` / `open failed` 与 sshd 原话 `no more sessions`)分支:与传输错误一样**丢连接 + `end()` + 重建一次**,并留一行 warn(D07「不重连」的取舍在此让位于「满连接不会自愈」,但 `end()` 与可操作文案都保留);③ `describeExecError` 文案补上「插件已重建连接自动重试一次」;④ `stream()` 的中止路径注明「KILL 不保证生效、槽位延迟释放」这一事实。回归:`test/ssh-channel-gate.test.ts`(假 ssh2 真跑 `RemoteExec`:闸门把 3 条并发压成 2、额度满换连接且旧连接被 `end()`、重连后仍失败只重试一次、调大上限后并发升到 3 的反向验证)。**有意接受的代价**:重建会断开该连接上正在跟随的长流,由面板 SSE 自动重连(日志流按 D133 用 `tail=0` 续尾)——比「一条永远满的连接 + 重试永远失败」划算 | | D151 | 日志 / 拉取流是**一个 stdout chunk 一帧 SSE**:分片边界由上游决定(未缓冲 stdout 的容器逐行 flush,`docker logs -f` 推多快就多碎),而每个 chunk 的固定开销与「一行多少字节」**无关**——服务端一次 `JSON.stringify` + 一次 `res.write`,客户端一次 SSE 派发 + 一次 `JSON.parse` + 一次 `pushChunk`(字符串拼接 + 扫换行)。话痨容器每秒几千个 chunk 时,这些固定开销全砸在浏览器主线程上,与 D128 已治好的「逐 chunk 重渲染」是两笔独立的账。实测(`scripts/log-perf.mjs` 的事件洪泛剖面,**同一行速率** 6.2k 行/秒):每事件 1 行 → 事件循环最大延迟 49~53ms、1 个 >50ms 长帧;每事件 50 行 → 37~38ms、0 个 | `src/index.ts` 新增 `createSseCoalescer`:50ms 窗口把分片按通道合成一帧(**同一通道严格保序**、stdout 先于 stderr、攒满 256KB 立刻推、`end` 帧前由调用方 `flush()`),日志流与拉取流两条 `run` 用它包住 handlers 并在 `finally` 里 `flush()` + `dispose()`。窗口 50ms 比客户端渲染合帧的 150ms 小一个数量级,用户看不见。回归:`test/sse-coalesce.test.ts`(窗口合并 / 跨窗口分批 / 攒满即推 / flush 幂等 / dispose 停表 / 空分片忽略)+ `test/logs-stream.test.ts` 两条路由级用例(十个分片只出一个 line 帧;**未到窗口就收尾**时 `end` 仍排在这些行之后)。**本轮同时实测并否掉两条客户端「优化」**(记在这里,免得后人重做):① 行元素缓存(按 id 复用 element 让 React bailout)在 5k 与 20k 行/秒下都是 ±0——瓶颈不在造元素;② 合帧间隔退避(150ms→800ms)把最大延迟从 85ms **抬到** 144ms——退避只是把同样的 DOM 改动攒进更重的一帧。稳态剩下的是**挂载 5000 行的 DOM churn**(20k 行/秒下 6s 内 25~26 个 >50ms 长帧)——D152 用窗口化挂载把它解决(见下一行) | | D152 | 日志正文**挂着全部行**:5000 行 × 每行 4 个元素 ≈ 10000 个 DOM 节点,而持续洪泛时每一帧都要淘汰上千行、插入上千行——`scripts/log-perf.mjs` 的持续洪泛场景(20k 行/秒 × 6s)量到 25~26 个 >50ms 长帧 / 最大延迟 70~97ms,真机上就是「日志一快整页就卡」。同轮实测已排除两条更便宜的路(D151 记了:按 id 复用 element ±0、合帧间隔退避把最大延迟抬到 144ms);`content-visibility` 也不能用(D91:估算高度打穿贴底判定) | `client-src/log-window.js`(新增,纯逻辑:实测行高缓存 + 前缀和 + 窗口定位 + 锚点修正,15 例单测)+ `client-src/index.js` 的 `useLogRows`(两个日志视图共用:量高、锚点修正、贴底钉住在**绘制前**的 layout effect 里一次做完)+ `.dk_logPad` 上下垫高。DOM 从 ~10000 节点掉到 ~230(实测挂 53~57 行);**显示层不截断**落在数据上——全部行都能滚到、都能导出(`LINES` 选多少就是多少,D129 的语义未变)。实测改后:快照 5000 行 179ms / 229 节点;突发 20k 行最大延迟 2ms;持续洪泛 20k 行/秒 × 6s 最大延迟 6ms、长帧 >50ms **0 个**、213 节点。回归:`test/log-window.test.ts`(15 例)、`test/log-buffer.test.ts` 同层、`scripts/client-smoke.mjs` 的 `__logWindow` 缝与渲染期守卫(`ReactStub` 补 `useLayoutEffect`)、`scripts/log-perf.mjs` 新增**窗口化正确性**断言(滚到顶第一行必须是 `seq=0`、滚到底最后一行必须是 `seq=4999`、洪泛结束后最后一行必须是最新行)+ 挂载行数上限 200。**真机回修(同日)**:第一版把「贴底锁存」只交给 scroll 事件算,banner 一弹(面板高度变)就被误判成「用户上滚」→ 跟随悄悄停掉,而窗口还按状态里的旧滚动位置渲染 → **正文空白**(用户现场截图)。现在窗口以 DOM 真实滚动位置为准(每次提交先同步、状态只是镜像),锁存只在「位置真上移且内容没变短」时才清,`.dk_logBody` 另加 `overflow-anchor: none` 挡住浏览器滚动锚定;`scripts/log-perf.mjs` 增加「积压 → 重连」回归(可见行数 / 回到底部按钮 / 重连后是否贴回最新行)。**第二处真机回修**:上滚判定改成**只认手势**(wheel / 触摸 / 拖滚动条 / 键盘,1.5s 窗口)——洪泛里位置被夹紧或抖动不再把跟随停掉(真机第二次截图里右下角冒出「回到底部」就是这个误判;顺带发现第一版把手势监听挂在 ref 上,而日志页不是首屏 tab、挂载时 body 还不存在 → 监听根本没生效,D61 会整个失效)。门禁两个方向都断言:真手势上滚**必须**停跟随并可一键恢复,无手势的位移**必须不停**且下一帧贴回最新行 | | D153 | 背压处理有两个独立的老问题,叠在话痨容器上就是「日志页在重连里打转」:① **写过的帧被重排一遍**——`write()` 返回 false 只表示「越过高水位」,数据**已经收下**(Node 契约),老代码把它当失败又 push 进队列,drain 时会重写同一帧(客户端收到重复日志行,`pendingBytes` 也虚高);② **积压就掐流**:队列超过 8MB 就补 `end{output-limit}` 收尾,客户端只能重连,而重连期间面板是「连接中断 + 正文不动」——真机现场(2026-09-29,LINES=5000 + FOLLOW + 话痨容器)正是这条:两条警告 banner 之后正文空白、状态在重连 | `src/index.ts` 的 `openSseStream`:① 写入契约改成三态 `tryWrite → 'ok' \| 'pressure' \| 'gone'`,`waitingDrain` 标记高水位,**写一次就是写一次**,drain 只续写排队的帧;② 新增 `overflow: 'end' \| 'drop'` 选项——日志流与拉取流(**文本尾部流**)用 `'drop'`:丢最旧的整帧、留最新,并推一条 `skip{frames,bytes}` 通知(`skip` 帧自身永不参与丢弃),流**不断**;统计流 / 事件流保持 `'end'`(采样少一个就是错,宁可收尾重连)。客户端:`client-src/log-stream.js` 新增 `skip` 监听与 `onSkip`,单容器视图用它写进提示横幅、聚合视图并进状态行(i18n `hint.logSkipped` / `hint.logSkippedNoCount`,中英各一份)。回归:`test/logs-stream.test.ts` 两条(背压不重写:越过高水位的帧只出现一次;积压后丢最旧 + `skip{"frames":1}` 且 `ended === false`)+ `test/streams.test.ts` 一条(统计流仍走 `end{output-limit}`,假 res 补 `blocked` / `drain` 支持) | | D154 | D152 的锚点修正**只补得到「量高」,补不到「淘汰」**:`reanchor` 以「本次渲染自己的 anchorOffset」为基准,同一份布局内量高产生的位移能抵消;但洪泛稳态下环形缓冲每帧都在淘汰最旧的行,锚点**上方**的总高持续变短、整个内容上移——用户上滚读历史时视口内容持续上飘,读史成了自动「倒带」。门禁此前没有「洪泛中驻留历史」的场景,这条一直漏网 | `client-src/index.js` 的 `useLogRows`:跨提交记一份稳定锚(上一帧可见区顶行 + 它当时的**绝对**偏移),下一帧用 `reanchor` 相对这份记录补偿——淘汰、量高修正、聚合视图按时间插行三类位移被同一个差值吸收;贴底时置空(绝对钉尾无需补偿)。`client-src/log-window.js` 新增 `offsetOf`(某行在当前缓存下的绝对偏移)。回归:`test/log-window.test.ts`(`offsetOf` 与「淘汰头部后补偿量 = 被淘汰行总高」两条)、`scripts/log-perf.mjs` 新增**驻留历史**场景(慢速洪泛下驻留 1.2s,可见区顶行必须还是同一行、位移 ≤24px;**判别性已实测**:把 `useLogRows` 退回旧逻辑后该断言确实失败)。**它的安全前提是换代必作废锚**(D155 的 `resetKey`):快照行的 id 是位置寻址(`'s'+index`),快照刷新是「非空 → 非空」替换且同一 id 空间——不换代的话,跨帧锚会把**上一代的行**当成「该钉住的那一行」,几何也按上一代的高度算(旧代码用同一次渲染的 `anchorOffset`,反而没这条风险)。实测口径(门禁):换代不作废时,刷新回普通行后 `scrollHeight` 虚高 **35644px**(135557 → 99913)——不会一次「传送」,但滚动条、跳转、「滚到顶看到第几行」全部按错的一代算 | | D155 | `log-window` 的高度缓存**从未被清过**:模块契约写明「换流 / 重建缓冲时 `clear()`——行 id 会从 1 起复用」,但没有任何调用点。两个日志视图每次重开流都新建缓冲、id 从 1 重来,上一代容器行的实测高度被套到这一代同 id 的陌生行上。**快照刷新同样中招**:快照行 id 是**位置寻址**(`'s'+index`),刷新后同一 id 就是另一行内容,缓存不但错、还是「同名不同行」的错——它同时是 D154 跨帧锚在快照模式下的安全前提 | `client-src/index.js` 的 `useLogRows`:新增 `options.resetKey`(调用方说明「这一屏行属于哪一代缓冲」——单容器视图跟随传缓冲对象身份、快照传 `logs` 响应对象身份;聚合视图传缓冲身份),身份一变就 `clear()` + 作废锚点;「有行 → 空」保留为**兜底**信号(调用方漏传时仍能自愈,过滤把 `matched` 清空也会走到,清了重测无害)。回归:`scripts/client-smoke.mjs` 新增「换代作废」用例(源码级锁接线:hook 接 `resetKey`、换代分支同时清缓存与锚、两个视图都传、兜底信号不许丢、产物里能看到契约)+ `log-window` 的 `clear()` / `offsetOf` 用例。门禁补了端到端场景(`scripts/log-perf.mjs` 的**换代回归**):闸门自己喂的快照先刷成一代会折行的高行(每行 ~150px)并扫 6 个位置把它们量进缓存,再刷回普通行——判据是**几何**(这一代的垫高必须由这一代的实测高度算出来,`scrollHeight` ≤ 5000×20px + 余量),不是滚动位置。**判别性已实测**:把 `resetKey` 分支禁掉后该断言确实失败(滚动高 135557px vs 上限 115000px)。为什么不判滚动位置:跨帧锚本来就应该补偿视口上方行高的真实变化(D154 的正当职责),拿它当判据会把正确实现判成错的——第一版就是这么写错的,实测正确实现会位移 1881px 而被误报 | | D156 | `skip` 账目**少报**:skip 帧排队期间(高水位未退)再发生丢弃,账目虽然累加了,却在 skip 真正写出时被 `tryWrite` 的清零一起吞掉——客户端看到的第一段缺口永远偏小,第二段缺口完全不可见。D153 的测试只覆盖「丢一批 → 报一批」,两批叠着丢没测到 | `src/index.ts` 的 `dropOldestFrames`:队列里已有未落地的 skip 时**原地更新**它的 `{frames,bytes}`(改还没写到 socket 的帧正合适,时序不变);`tryWrite` 的清零仍发生在写出时。回归:`test/logs-stream.test.ts` 新增「skip 排队期间再丢弃」——46 块逐块对账,不变式「每一帧要么送达、要么被 skip 记账」(旧实现只报得出 15) | | D157 | `skip` 提示两处小洞:① 客户端**覆盖**不累计——skip 是增量通知(每丢一批报一批),只显示最后一批在长洪泛里严重少报;② 聚合视图重连成功后**不清除**(单容器视图清),提示跨过重连一直挂着 | `client-src/index.js` 两个日志视图:各加一个累计 ref,`onSkip` 累加后显示累计值;`onStatus 'open'`(重连成功)时计数与提示一并清零——语义是「本次连接共跳过多少」。i18n 无新增(复用 `hint.logSkipped`,数字变累计值) | | D158 | 设置面改挂 `plugins.bundle.config` 时**把共享的聚合包 key 当成了每包私有**:该槽的 key 是**共享命名空间**(同一 key 只能有一个注册者,重复注册**直接抛错**),而 `@hyzyn/dsh-all` 是所有插件共用的聚合 bundle —— 于是八个插件都去注册它,第二个注册者抛 `keyed slot "plugins.bundle.config" already has an entry for key "@hyzyn/dsh-all"`,客户端 `apply` 抛错 = 整个插件起不来,用户启动页直接变「Failed to load plugins」(现场两条:`@hyzyn/dsh-codegraph` / `@hyzyn/dsh-tty`,即抢 key 输掉的那些)。窗口期极短:从 `8ef7b6a9` 引入到 `237e8ac5` 修掉 | `client-src/index.js` 的注册块(八个包同一段):**只挂本包自己的 bundle 名**;聚合包不挂内联位、继续走 `plugins.row.config` 的 `@hyzyn/dsh-all#`(那份 row 入口即使内联可用也保留);内联可用时只撤掉**本包**那份 row 入口;旧宿主回退注册全部 row 槽。定位方式(可复用):把 profile + `$DSH_HOME` 复制到 `/tmp`,`DSH_HOME=… dsh --profile test --port <空闲端口>` 起隔离宿主,再用 `scripts/chrome-cdp.mjs` 抓浏览器侧 `Runtime.exceptionThrown`。回归:`scripts/test/plugin-settings-surface.test.ts` 新增 **bundle key 全仓两两不同、且不得是聚合包** 的跨包守卫(判别性已实测);`packages/docker/scripts/client-smoke.mjs` 两条用例改成新期望(内联只注册本包 key / 内联可用时仍保留聚合包 row key / 旧宿主两条 row key 都在) | | D159 | 「连接本机」的只读探测有两处把话说死/说空:① `probe()` **不认超时**——`docker version` 挂住时 `code` 是 `null`,兜底文案「退出码 null」对用户零信息量,而超时(daemon 卡死 / `docker context` 指向连不上的远端)恰恰是可修的一类,且它与「daemon 没起」的可修动作完全不同;② 探测只认 `dockerBin` 一个二进制、**完全没考虑 podman**——只有 podman 的机器上收到的是「请先安装 Docker」,用户得自己猜到去设置卡片把「docker CLI」改成 podman(README 也只写了这条手动路径)。另有一个实测出来的放大器:超时**只杀直接子进程**,`dockerBin` 指向包装脚本时孙进程仍握着 stdout/stderr,'close' 迟迟不来,15s 上限被拖成分钟级(真机实测 3s 上限拖到 60.3s) | `src/docker.ts` 的 `probe()`:新增 `ProbeResult.timedOut`,超时单独一档返回 `${bin} version 超时(15 秒未返回)`,不再落进退出码兜底;`src/index.ts` 新增 `isLocalCliMissing`(判据单一来源,文案分档与「要不要找候选」共用)、`findAlternativeLocalCli`(只读扫 `PATH`,按 **basename** 比对——按原字符串比会在 `dockerBin=/usr/local/bin/docker` 时给出「把 docker 改成 docker」的废话)、`localProbeReason`(接线,且**只在 CLI 缺失那一档**才去 accessSync),`describeLocalProbeFailure` 加 `LocalProbeHint` 入参(超时档 + 候选 CLI)。**只提示不静默改配置**:绝不替用户改写 `dockerBin`。`src/ssh-exec.ts` 的 `runLocal`:到点先 SIGKILL,再给 `KILL_GRACE_MS`(500ms) 收敛期,仍不 close 就自行 destroy 管道收尾——超时路径上「按时返回」优先于「拿到退出码」(本来就是 null)。回归:`test/connect-local.test.ts`(13 条新增,含用临时目录造 隔离 `PATH` 的可执行位用例——不依赖跑测试的机器装了什么)、`test/streams.test.ts` 的 `probe()` 六条出口、路由级用例(真 `accessSync` + 断言 `dockerBin` 未被改写)。**判别性已实测**:移除超时分支 / 断开候选接线后对应断言确实失败 | | D160 | 「活动」条(docker events 事件流)进了 `closed` 是**终止态**,但界面把它渲染得像会自愈:`onEnd`(`docker events` 退出 / 服务端发 `end`)与带 `data` 的 `onError` 都 `setEventsStatus('closed')` **并 `close()`**,之后没有任何东西会再开它;而文案「事件流已断开」加上 EventSource 平时确实会自动重连,让人以为等等就好。实测用户路径:daemon 没起时开面板 → 流立刻带错退出 → 用户看到「事件流已断开」;随后 daemon 起来、点工具条 ⟳,列表正常回来了,活动条却永远停在那句。**⟳ 也救不活**:`refresh()` 只做 `loadContainers()` + `setRefreshToken(v+1)`,而事件流 effect 的依赖是 `[active, view, target]`——`refreshToken` 根本没进那条 effect(它只被详情抽屉的三个 effect 消费)。当时只有刷新整个页面(重挂载)或切页 / 切目标能恢复 | `client-src/index.js`:新增 `eventsReconnect` 自增令牌并进事件流 effect 依赖;`refreshManually()` 抽出「刷列表 + 重连」(⟳ 与活动条重连按钮都走它),而 **AUTO REFRESH 轮询仍只调 `refresh()`**——塞进轮询会让终止态被不断重开,既掩盖「它断了」又白建 SSE;`canReconnectEvents(status)` 抽成纯函数(离线冒烟的 React 桩把 `useState` 冻在初值上,组件里那个 `eventsStatus` 恒为 `''`,判定必须能直接驱动),**只认 `closed`**(`unsupported` 不给——重连必然再失败);`ActivityBar` 加 `dk_activityHeadRow` 容器,重连按钮是折叠头的**兄弟**而非子元素(原来那层已经是 `