Files
dsh_ai1net_server/.workbuddy/memory/PLAYBOOK-实例与插件坑.md
T
admin ce8e6ceed9 chore(工作区): 纳入版本控制基线(回收 411 MB 过程产物)
回收 411 MB(470 M → 58.8 M),全部经回收站,可恢复:
- 待清理/(146.2 M,含 relay 分片 128 M 与 42 项过程目录)
- tmp/(32.4 M,按接续棒命名的过程临时区)
- .workbuddy/tmp/(39.5 M)
- 4 份 workbuddy.db 冗余副本(101 M,09-23 事故的坏副本 / 抢救产物)
- tmp/im16/gw/centrifugo 二进制(63.9 M,可重下)+ 缓存残留

入库范围:常驻规则(CODEBUDDY.md / README.md / state.py)、在途接续入口与
接续包、docs/、交付物/、交接单/、归档/、scripts/、.codebuddy/、
.workbuddy/memory/;共 398 件,其中 >60 KB 的 26 件全为文档。

排除(.gitignore):tmp/、待清理/、运行态日志与缓存、*.db 与 DB 备份整目录、
打包二进制(*.tar.gz / *.tgz)、记忆修复前备份。
2026-09-24 07:51:03 +08:00

879 lines
77 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PLAYBOOK — 实例与插件坑(DSH 平台)
> **为什么单独一个文件**:`MEMORY.md` 有**注入上限**(超了会被截断)⇒ 把「**出手前 10 秒必须看到**」的判据留在 `MEMORY.md §四`,把**详版(命令 / 日志口径 / 恢复手法)**挪到这里,由 `MEMORY.md` 指过来。
> 读法:`MEMORY.md` 是索引与状态;本文件是**排障手册**。两边冲突时以**实测**为准,并回头修这里。
---
## 1. 插件机制的能力边界
- 插件机制 = **扩展**,**无**「替换官方内置文案」扩展点;官方硬编码中文的 20+ 包只能**在注入层改 DOM**;**替换官方同名包 ⇒ 不做**。
- ⚠️ 第三方**客户端**插件**不得**把官方 UI 包写进 `dsh.client.inject`(`-settings-models` / `-settings-plugins` / `-cordis` 等被角色补丁禁用 ⇒ 该插件**浏览器半边永久静默挂起**)。
- 判据(客户端插件没 UI):解析 `__DSH_BOOT__` → 各 `inject` 与实际插件集**求差集**:**非空 = 该 fiber 永久挂起(静默)**。
## 2. 原生绑定 / GLIBC
- **宿主 glibc = 2.32** ⇒ 要求 ≥2.35 的原生绑定(univer 的 rust / node binding)默认装不上。
- ✅ **2026-09-14 已解并上线**:给指定进程挂一份**自带 glibc 2.35**(`/usr/local/dsh-runtime/glibc-2.35/`;放 `/usr/local` 是为 **bwrap 沙箱内可见**)⇒ **插件 0.2.28 起 worker/gateway 经 `ld.so --library-path` 启动**(`src/host/processes/glibc-runtime.ts` **自探测**;`UNIVER_DSH_GLIBC_RUNTIME_DIR` 可覆盖 / 置空关闭;**平台侧零改动**)。
- ⚠️ **必须整套 20 个 so 一起给** —— 只给 libc/libm + 宿主 `/lib64` 会撞 `GLIBC_PRIVATE` / `__libc_siglongjmp`。
- **删目录即完全回退**。⛔ **仍然不要**动宿主系统 glibc。**仍未解**:截图 / PDF。
## 3. `Failed to load plugins` 的三种真因
### 3.1 真因之三 = `431 Request Header Fields Too Large`(Cloudflare 拒的,不是 nginx)
**成因**(`proxy.ts` 注释原文):实例**每次 (重)启动都会换 launch token 和 `dsh-auth-<随机后缀>` 的 cookie 名**;浏览器把旧名字全留着(平台只在**转发时**丢弃:`mergeCookies` 的 `!/^dsh-auth-/`,**从不回写浏览器**)⇒ `Cookie` 头**单调增长** ⇒ 超 CF 上限 ⇒ **431**。
⚠️ **不是 nginx**:本机 vhost 已配 `large_client_header_buffers 4 32k`。
⇒ 那条 11 MB 合并脚本取不到 ⇒ 报 `bundle script … failed to load`。
**判据(三条)**:① 平台日志里**没有**那条 bundle 请求 ② **无痕正常**(无 cookie ⇒ 头小)③ F12 里状态是 **431**、Remote Address 是 **CF**。
**恢复**:清 `alotbuy.com` 的 cookie(**只删 `dsh-auth-*`,保留 `sid`**)。⚠️ **频繁重启实例会加速它**。
✅ **已治本(档案 98)**:平台在响应里对**陈旧 `dsh-auth-*`** 回写 `Set-Cookie: …; Max-Age=0`(只在"本次响应确实下发了 dsh-auth"时才动手)⇒ jar 不再增长。⚠️ 但它**救不了已 431 的当下**(那时请求进不来)。
### 3.2 浏览器侧(扩展 / 缓存)
🔴 **报障排查第一步就问:「普通窗口」与「无痕窗口」是否同样报错?**(09-14 实测教训:我绕了十几轮才问到)
**无痕默认禁用全部扩展 + 全新缓存** ⇒ 一步区分**浏览器侧(扩展/缓存)**与**服务器侧**。
⚠️ 客户端报 `Failed to load plugins` / `bundle script … failed to load` 时:dsh 用 **`document.createElement("script")`** 加载那条 `/plugins/??…&rev=…` 合并脚本(官方 `dsh-client-modules`)⇒ **被扩展拦掉时浏览器根本不发请求** ⇒ 平台日志里**查不到任何失败请求**(实测:用户刷新只留 7 条请求,bundle 那条一次都没出现)。
⛔ 此时**别再往服务器侧挖**(OOM / 配额 / 插件表 / 启动日志都会是"干净"的)。
### 3.3 cgroup OOM 杀实例
🔴 **「实例起不来 / 加载插件报错」先跑三日志判据,别猜客户端**(实测:我围着客户端 bundle 转了 6 轮,真因是 **cgroup OOM 杀实例**):
1. `journalctl -k --since -6h | grep -E "oom-kill|CONSTRAINT_MEMCG"` —— **`oom_memcg=/system.slice/dsh-<uid>-*.scope` 就是配额太小**
(实测:admin 上限 384 而 0.1.5 基座吃 ~312 ⇒ 只剩 70 MiB 给 mcn 7 插件 ⇒ 一分钟被 OOM 杀 4 次 ⇒ 熔断)
2. `journalctl -u dshs --since -1h | grep -o "crash-restart.*"` —— 看平台记的 `exitCode` + `lastError` 栈
3. `tail -5 /var/log/dsh-instance-mem.log` —— **每 10 分钟一行「当前 / 峰值 / 上限」**,一眼看出配额够不够
⛔ **别把 OOM 之后的 `cordis-plugin-loader … Entry._init` 栈当成"插件不兼容"** —— 那是饿死时的表象。
⚠️ 实例「起不来」**先查属主 / EACCES**(见 `MEMORY.md §三` R10),**不要先怀疑 OOM**。
## 4. 铺插件 / 换产物的两个静默陷阱
- ⚠️ **换共享 tgz 的瞬间 + 跑 `ensure-biz-plugins.cjs` = 静默卸载**:它的 `pruneBrokenFileDeps()` 会把「指向不存在文件」的 `file:` 依赖直接摘掉 ⇒ **先放新产物、再跑脚本,跑完核对 `bundles`**(guest 的 univer 就这样消失过,见档案 95)。
- 🔴 **铺插件 / 重启 `dshs` 后,用户「已打开」的页面会报 `Failed to load plugins`**:dsh 把全部 `client.js` 拼成一个脚本,
URL = `/plugins/??<模块列表>&rev=<hash>` 是**严格契约**(实测:原样 200 | 只改 `rev` → 404 | 少一个模块 → 404)
⇒ 页面持有的 rev/列表一过期就 404。**硬刷新即恢复**。(纪律见根 `CODEBUDDY.md` R8 —— **09-14 已放宽为「开发环境直接做 + 动手前一句说明」**。)
## 5. `/plugins/` 的 ETag / 304(档案 97)
- ✅ 那条把全部客户端插件拼起来的脚本**实测 11,172,365 B、未压缩**(官方 `dsh-client-modules` 的 combo URL),
原先只有 `no-cache` 且无校验器 ⇒ **每次打开页面全量重下 11 MB**(经 CF 实测 **114.87 s**)。
- 现按 URL 派生 ETag、命中 `If-None-Match` 直接 **304(0 字节)**。
- ⚠️ **首次访问仍是全量 11 MB**(压缩未确认);⚠️ 实例**每次重启换 nonce** ⇒ 旧页面的 rev **必 404**。
## 6. 待修 / 待办(技术债)
- 门户 `web/portal.html` 的 `readAsBase64()` **缺 `await`** ⇒ 三个上传入口**恒传空数据**(待同修)。
- **档案 82 自身严重重复**(§一–§八 6 份)⇒ 待单独立项去重。
- MCN 插件:**`mcn-task` 重复建组未解**(需产品决策);`/mcn/api/accounts` 500 为既有缺陷。
## 7. 开源导出 · K8s 残留清理(09-14~15)
> 工作根 `E:\ProgramData\AI技能\dsh-laijing-github`(**不在受保护根 ⇒ 写它不需要锁**)。
- **判据不是字面量,而是语义**:「**这段描述在单机形态下还成立吗**」⇒ 清 `Pod`(K8s Pod ≠ DSH 子进程)/ `sidecar`(已移除的 per-user file sidecar)/ `a Linux Pod`。
- **三类扫**(只扫注释必漏):① 字面量 `k8s` ② **语义措辞**(`Pod`/`sidecar`/`Headless`)③ **标识符名**(`LivePod`/`K8sSpawner`/`stampFencing`)—— **第三类最易漏**(注释改了、名字还在;实测 `spawner.ts` 的 `export interface LivePod` 0 引用死代码,首轮 `cf8b7b1` 漏掉、`68c0a32` 补删)。扫法:拿 K8s 词汇表捞**非注释行**里的 `interface`/`type`/`class`/函数名。
- 工具:**`_k8s_comment_scan.py [--wide]`**(按「注释 / 代码」分类输出;`os.walk` —— bash `grep -r` **不遍历隐藏目录**,会漏 `.github/`)。实测注释命中 **0**、`tsc` exit 0。
- ⛔ **噪音不要清**:`--profile headless`(dsh 自己的 profile 名)·`headless-univer`(第三方插件包名)·`manifest`(npm 包清单,非 K8s manifest)·`egress`(nftables 出网护栏,**保留**能力)。
- ⚠️ **本机跑不了 `_build_export.py --force`**:`shutil.rmtree(DST)` 被**安全删除层**拦下(`SAFE_DELETE_FAIL_CLOSED`),且它会**连导出物的 `.git` 一起删**。
替代法 = 新规则抽成**具名表**(`K8S_SEMANTIC_RULES`,仍 `REGEX_RULES += …`)→ **`_apply_k8s_semantics.py --write` 就地热应用**(不删任何东西;与整目录重建**逐字节一致**;幂等,复跑命中 0)。
真要整目录重建:先 `mv dsh-users-platform/.git <导出根>/_keep_git_tmp` → 重建 → `mv` 回来。
- ✅ **依赖已清**:走「**源仓先下线 K8s 后端 → 再 `npm uninstall @kubernetes/client-node`**」⇒ 源仓 6 模块 + 2 测试 + 1 脚本移入 **`D:\github\_dsh_shenxian_K8s后端备份_20260915\`**(README 含**还原命令** + 集群化方案要复用的模板清单)。
⚠️ **刻意保留(不是遗漏)**:`deploy/` · `poc/01–04` · `Dockerfile.dsh` · `docs/k8s*.md`(已加状态横幅)· `config.ts` 的 K8s 配置字段与 `DeployMode` 联合类型。
- ⚠️ **连带判据 =「产物不在,引用它的步骤也不该在」**:CI 里 3 条步骤依赖由已撤 `Dockerfile.dsh` 构建的 `dsh:ci` 镜像(`Smoke — dsh resolves runtime plugin` / `Trivy — dsh` / `Push dsh to ACR`)⇒ **整块删除**(新增 `_drop_block()`:逐行 `re.escape` 拼接,避免手写正则静默失配)。
- 留档:**`_K8s清理影响评估与回归说明_20260915.md`**(影响评估 / 回归命令 / 继续开发四纪律 / 工具陷阱 —— **接手先读**)|`_K8s排除台账.md §五·补`|`_待清理_K8s残留清单_20260914.md`(顶部已挂 ✅ 完成横幅)。
- 未清:**ACR 推送段用占位符 `registry.example.com`** ⇒ master 推送会失败(与 K8s 清理无关)。
## 8. 兼容判定收口(档案 93)
- ⚠️ **「单测长期红」≠ 代码坏**:真因常是**服务器源码落后于 git**。**判断法**:两侧各自 `git ls-files src test` + `tr -d '\r' | sha256sum` → 排序 diff(**两侧都要行尾归一化**;`diff <(a) <(b)` 在本机不可用 ⇒ 落临时文件)。
- ⚠️ 伞包 `@deepseek-ai/dsh` **必须单独解析**(它不在自己的 `node_modules` 里)⇒ 收口到 `dsh-install.platformPackageVersion()`,`plugin-compat` 与 `plugin-dsh-compat` 共用版本表。
- ⚠️ **兼容闸门判据 A 必须带 prerelease 兜底**:平台是 prerelease,semver 默认语义下**声明 `*` 也会被判不兼容**(抽样 300 实测假阳性 ≈39%,含在跑的 `dshmarket`)⇒ 改成「默认语义不满足时再看 `includePrerelease`,能满足的只计数不阻断」。**判据 B 未动。**
- 🔑 **复刻官方 UI 的取证入口** = 官方包 `lib/client.js` 头部 `\0dsh-css:` 区块(CSS 模块,类名带哈希)+ 同文件 `zh`/`en`(全部文案)+ `README.zh.md`(语义)。
## 9. 实例侧取数·验收四坑(06 §7 三段式的实操,2026-09-15 实测全部踩过一遍)
> 场景:**不靠浏览器**验证「UI 改动真的上线了」——抓实例壳页 → 取 combo URL → 请求 bundle → grep 新特征。
> 四坑都在同一条链路上,按顺序踩完才拿到绿。
| # | 现象 | 根因 / 正确姿势 |
|---|---|---|
| 1 | 抓实例页只拿到 **10.7 KB 的登录页**(title「DeepSeek」、只有 `/i18n.js`) | 实例子域代理**按会话判归属** ⇒ curl 必须**带上门户 `sid` cookie**(只有 launch token 不够)。正确:303 → `set-cookie: dsh-auth-*` → 200,壳页 **≈58 KB**(title「DeepSeek Harness」) |
| 2 | `/plugins/??…` **404(2702 B)** | 两点同时要对:① 站点必须是**实例子域**(`/plugins/` 是 dsh 自己的路由;打门户域只会得到门户 404);② **仍要带 `sid`**(否则被当"无租户"请求 → **假 404**,与「fetch 不能设 Host」同族) |
| 3 | 壳页 combo 里**没有**要验的包(如 `@dsh-local/business-plugins`;58 条 `/plugins/??` 里 0 条命中) | **新用户 profile 是首次进实例时才创建**的 ⇒ `ensure-biz-plugins` 对它直接「无 profile → 跳过」。正确顺序:**先 `enter` 起一次实例**(生成 profile)→ 跑 `ensure-biz-plugins.cjs --all` → **`POST /api/dsh/restart`**(client bundle 只在实例启动时加载)→ 再 enter。⚠️ 另:生产里**"审批"会顺带铺 bundle**(档案 36),直改 DB 置 `active` 会**绕过**该钩子 —— 这也是坑 3 的另一个成因 |
| 4 | `POST /api/dsh/restart` **400** | `restartSchema` 要求 `command` 是**必填 string**(handoff 停写后恒传空串)⇒ 必须发 `{"command":""}` + `Content-Type: application/json` |
**配套事实(R4 一次性用户模板)**:`role` 取值是 **`active`**(CHECK 约束 `admin|pending|active|disabled`),**不是 `approved`**;清理要按 `DELETE /api/admin/users/:id` 同一套 —— 顺次删 `credential_vault / domains / sessions / dsh_instances / folder_plugins / workspaces / audit_log / users`,再 `rm` `users/<id>` 整棵(`platform` 侧的 `users/<id>/home` 是 dsh 的 watch 域),最后 `userdel dsh-<id 去横线前 20 位>`。
⚠️ 停实例**只准按 uid 过滤该用户自己的 scope**(`systemctl list-units --type=scope | grep <uid>` 再 stop)—— ⛔ 绝不能 `stop 'dsh-*.scope'`,那会连别人的实例一起停。
**验收基线(可复用)**:壳页 combo 含目标包 + 壳页字节数变大 + `rev` 变了;bundle 请求 **200** 且体积 **≈11.36 MB**(全量拼接脚本量级)并含新特征串。
### 9.1 ⛔ 集群化之后的两个**必踩**(2026-09-15 晚实测)
1. **临时用户的审批必须写 PG,写 SQLite 等于没写**
- 平台已 `DSHS_DEPLOY_MODE=cluster`,权威库 = `postgres://dshs:[email protected]:15432/dshs`(单元 `dshs-pg`,数据目录 `/var/lib/dshs-pg`)。
- `/var/lib/dshs/dshs.db`(SQLite)**是回滚用的旧库、平台根本不读** ⇒ `UPDATE users SET role='active'` 写进去后 `login` 仍 **403 `pending_review`**(脚本表现:"登录失败",很误导)。
- ⚠️ **列名也不同**:PG 里 `users` 的主键是 **`id`**(不是 `user_id`)⇒ 清理脚本别照抄 SQLite 时代的列名。
- 连接方式二选一:`psql "$DSHS_DB_URL" -tAc "…"` 或 `/opt/dshs/node_modules/pg`(`pg` 模块在服务器上现成可用)。
2. **新用户不再落在 47 上** ⇒ 「起实例 → 抓壳页 → 取 combo」这条验收路走不通
- 集群按容量分配:**存量用户锚定 `w-47`(47 本机)、新用户落 `w-106`**(106 经**反向隧道**连到 47 ⇒ **47 侧 ssh 不到 106**)。
- 实测表现:新建的一次性用户 `enter` 成功、但 **47 上没有它的 `users/<id>` 目录**、壳页 combo **不含** `@dsh-local/business-plugins`(因为新用户的 profile 在 106 上,`ensure-biz-plugins --all` 在 47 跑不到它)⇒ 别据此误判"插件没生效"。
- ⇒ 想给 UI 改动做**实例侧/浏览器验收**,先解决「新用户落哪台 Worker + 怎么到那台机」,否则只能做到**磁盘层**(profile 里的 `client.js` 有 grep 命中)为止。
---
## 10. 本机 Python 编码坑(2026-09-15 实测 · 害我误判一整天)
**现象**:`stop-dialog-guard.py` 装了 `UserPromptSubmit` 钩子,但**全盘找不到任何日志**,看起来"宿主从没调用过它"。
**真因**:安装形态是 `python -S -E <脚本>`,而 **`-E` 会忽略 `PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`**(本机环境确实设了这俩)
⇒ `sys.stdin.encoding` 回退 **cp936**;钩子 payload **必然含中文**(用户的提示词)⇒ 文本模式读出乱码
⇒ `json.loads` 抛 `ValueError` ⇒ 原代码 `except ValueError: return` **静默返回** ⇒ 每次调用都空转。
| 判据 | 结论 |
|---|---|
| `python -S -E` | `stdin.encoding = gbk`、`utf8_mode=0` ⇒ **中文 payload 必炸** |
| `python -S`(无 `-E`) | `stdin.encoding = utf-8`、`utf8_mode=1` ⇒ 正常 |
| 对照脚本 `lock-guard-hook.py` | 安装时**没加 `-E`** ⇒ 一直正常(但同样依赖环境变量,是**潜在**风险) |
**正解(与 flag / locale 解耦)**
```python
raw = sys.stdin.buffer.read().decode('utf-8', 'replace') # 读
sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode('utf-8')) # 写
```
- 原 `sys.stdin.read()` / `sys.stdout.write(...)` 在 cp936 下会**解码失败 / UnicodeEncodeError**
- **钩子类脚本必须「入口即留痕」**(记 event / cwd / in_scope / transcript 尾部)——
否则"宿主没调用"与"调用了但被静默 return"**分不开**,而两者处置完全相反(前者卸载、后者放宽作用域判据)
- ⛔ 安装命令**不要**给这类脚本加 `-E`
**同类风险(未改,待定)**:`lock-guard-hook.py` 目前可用,但若环境不再提供 `PYTHONUTF8=1` 会**静默 fail-open**(守卫失效)。
### 10.1 🔴 md 文件里几个非法字节 ⇒ **整份读出来全乱码**(2026-09-16 实测)
**现象**:`Read` 工具读 `.workbuddy/memory/2026-09-16.md` **全文乱码**(`浠d环` = "代价"),文件本身看起来"有内容"。
**真因**:该文件是 UTF-8,但**开头有 2 个非法字节**(`b5 84`,半个汉字残渣)⇒ **自动编码检测**(Read / 编辑器 / chardet 类)判定"不是 UTF-8" ⇒ 走偏为 **GBK** ⇒ **整份文件**按 GBK 解码 ⇒ 全乱。
⚠️ 关键:**只有 2 字节坏,却毁掉 212 KB 文件的可读性**(其余 212,337 字节全合法)。
**判据(一条命令,别靠肉眼看)**
```python
b = open(p, 'rb').read()
try: b.decode('utf-8'); print('OK') # 合法
except UnicodeDecodeError as e: print('BAD', e.start) # 非法 ⇒ 必须修
```
- 逐字节 `b[i:i+1].decode('utf-8')` 统计"非法字节数"是**错的**(所有续接字节都会报错,得 147426 这种荒谬值)⇒ **必须整块 decode**。
- 坏字节在**开头** ⇒ 检测必走偏;在**中间** ⇒ 检测多半仍判 UTF-8(但严格读会抛)⇒ 两种都要修。
**处置**:字节级删掉那 2 个字节(先备份),再整块 `decode('utf-8')` 自证。⛔ 不要"按 GBK 重读再写回"——那会把整份文件**真的**转成 GBK。
**防线**:日志/笔记追加一律 `open(p,'ab').write(blk)`(Python 字节级),⛔ 不用 `cat X >> Y`。
本机 `cat >>` 有两个独立毛病:① 内容被写到**文件开头**(同 §10 上文,2026-09-16 实测)② 可能留下非法字节。
⇒ 每次追加后**必做两件事**:`grep -n` 核章节在**末尾** + 整块 `decode('utf-8')` 核编码合法。
**排查记录**:坏字节**不是** `fix_log.py` 造成的(它是纯 `rb`/`wb` 字节级搬移 `rest + block`,**不可能新增字节**)⇒ 判据 = **先看那个脚本有没有"文本模式读写"**;纯字节级脚本一律排除。
---
## 11. 「去查」索引(原 `MEMORY.md §四`,2026-09-15 迁入 —— 避免与 `CODEBUDDY.md §2` 重复占 MEMORY 的 12 KB 额度)
| 要做什么 | 去查 |
|---|---|
| 改权限档位 / 内存配额 / 插件启停 / web provider | `04/33`|`04/58`·`74`(V8 堆 256→160 反证)|`04/16`·`64`·`65` |
| 改 UI / 改完「看不到变化」|univer / GLIBC / worker 故障 | `06-工作台UI规范 §7` + `04/82`|`04/76 §追加` |
| 用户自传个人技能(上传/启停/删) | `04/11`·`04/41`|**已上线,UI = 档案 100** |
| 实例内要「AI 生成 docx/xlsx/pptx」 | univer 插件 `office-file-generation` 技能。⚠️ 技能是**显式清单**注册 ⇒ 加技能**必须同时**改 `src/host/skills/plugin.ts` 的 `DEFINITIONS` 并重建 `lib` |
| **升级 `business-plugins`** | `npm pack`(先删 `lib/*.bak`)→ scp `/opt/dsh/artifacts/` → `ssh bt-server 'cd /opt/dshs && node scripts/ensure-biz-plugins.cjs --all --restart'`。**不走候选池** |
| admin 管任意用户的实例 / 文件 | `04/86`:`/api/admin/users/:id/{fs,dsh}/*`(全 `requireAdmin`)+ 插件「实例管理」页用户切换器 |
| 要给平台**加新能力**(文件 / UI 类 —— **别先想着自己写**) | `github.com/web-casa/Awesome-DeepSeek-Harness-Plugins` + npm `keywords:dsh-plugin`。**采纳前三关**:inject 的官方包**存在**吗 / 被角色补丁禁了吗(⇒ 静默挂死)/ `peerDependencies` 覆盖 dsh 版本。✅ 已采纳 `@softspark/[email protected]`(`04/89`) |
| **dsh 版本(现行)** | **`0.1.5-rc.1`**(`04/90`)|**升级照档案 07 走**(R1)|**易错三处**:① 自研插件的行由 **profile 层单点插入** ② 角色补丁要**禁用官方 auto picker** ③ 确认目标版本**原生预览包是否已下发** |
| 开源导出 / 发版 / 脱敏 | 技能 **`dsh-opensource-release`**(16 条事故清单 + 验证六件套)|工作根 `E:\ProgramData\AI技能\dsh-laijing-github\`(**不在受保护根 ⇒ 不需要锁**) |
### 9.2 「某个 UI 分区是谁提供的」—— 三步定位(别再绕 30 次工具调用)
1. **只在"活着的 profile"里找**(不是官方树、不是备份、不是文档库快照):
`grep -l settings.section <profile>/node_modules/@dsh-local/*/lib/client.js`
2. 🔴 **中文 UI 文案要**同时**搜 UTF-8 与 `\uXXXX` 转义** —— `portal-entry` 的 label 就是
`"\u7528\u6237\u8bbe\u7f6e"`(用户设置)⇒ **只搜 UTF-8 会全域 0 命中**(2026-09-15 实测绕了十几轮)。
反推转义:`python3 -c 'print("用户设置".encode("unicode_escape").decode())'`。
3. **登记表**:`dsh-server-docs/07-实例UI分区登记表.md`(id/order/label → 提供者 → 源码 → 可改性)。
⛔ 改分区必须同步改那张表。
⚠️ **自研 bundle 的源码必须在仓内 `poc/<name>/`** —— `portal-entry` 曾只有服务器上的 tgz(源码不在仓)
⇒ 想改只能"从产物反推源码"(2026-09-15 已补入 `poc/portal-entry/`)。**约定:产物只从仓内源码构建。**
### 9.3 🔴 部署平台的**两条硬规矩**(2026-09-15 实测,两条都能搞出事故)
1. **平台部署(2026-09-15 已修根)** —— 曾有个大坑:`/opt/dshs/src` 是**集群化之前的旧源码**
(`server.ts` 里还是 `K8sSpawner`、git 停在 `8390eb3`),而线上 `lib/` 是**集群版产物** ⇒
**源码与产物不一致,在那边 build 会把线上编译回旧版**。
✅ **已修**:以**工作树**(不是 HEAD)打包 `src scripts test web poc package.json tsconfig.json` 覆盖
+ 删掉 9 个 K8s 时代遗留(`tcp-bridge` / `file-service` / `k8s-user-fs` / `reconcile` / `leader` /
`k8s-spawner` + 两个对应 test)⇒ 服务器 `npm run build` **通过**,且**产物 57/57 与本机逐字节一致**。
备份在 `/opt/dsh/backups/dshs-tree-20260915-210559/`。
⇒ 现在的正确姿势(两条都行):**(a)** 送源码 → `cd /opt/dshs && npm run build && systemctl restart dshs`;
**(b)** 只送编译产物(本机 build → `scp lib/**` → restart),**送前 diff 线上 `lib/xxx.js`** 确认只差本次改动。
⚠️ **判据工具**:`_tmp_t01/compare-tree.py <manifest> <工作树根>`(逐文件比对"一致/不一致/仅基线有/服务器独有")。
2. **同版本号不能覆盖重发** —— `pnpm` 会按版本复用同名 tgz ⇒ 改完必须**升号**;
而**从未安装过**的新版本(例如改了内容但谁都没装过)覆盖同号**是安全**的(判据:看 profile 里已装版本)。
另:`npm pack` 前先 `rm -f *.tgz`,且每个包都要有 `.npmignore` 排除 `*.tgz`
(0.2.5 与 0.5.4 **两次**都把上一版 tgz 打进了产物)。
🔧 **定位实例 UI 分区**:`node scripts/find-ui.mjs [关键词] [--md] [--grep]`(自研在前、官方在后;
label 会**解码 `\uXXXX`**;同时给"能不能改")。别再靠逐个包 grep(2026-09-15 绕了约 30 次)。
## 12. 会话成本的**实测依据**(为什么有"会话预算"这条规矩)
- 某会话实测:上下文 **5.2 万 → 59.2 万 token**,累计 input **1.93 亿** vs output **51.9 万**(**371 : 1**)。
- 其中 **33 次缓存失效**,每次把 **~50 万 token 按全价重算** ⇒ **缓存失效是单项最大成本**(不是"读了多少文件"本身)。
- ⇒ 由此定了三条硬操作:① 少读大文件 ② 单条工具输出 >4K 字符**落盘、只回摘要** ③ 同文件**禁重复 Read**、多条只读检查**合并成一条 Bash**。
- ⇒ 还有一条最容易被忽略的:**常驻注入文件**(`CODEBUDDY.md` / `MEMORY.md` / 技能 / hooks)**攒批改** —— 每改一次会让**所有活跃会话**的缓存失效一次。
- 阈值:**~15 万 token 或 ~120 次工具调用 ⇒ 收口开新会话**(`stop-dialog-guard.py` 已自动告警)。
---
## 13. 跨 worker 用户迁移(47 → 106,2026-09-16 实测走通)
**迁移规则(读代码得到,`src/web/routes/admin.ts:205-245`)**:
`POST /api/admin/users/:id/dsh/migrate {targetHost}` = **drain 源机 → 目标机承租约拉起 → 归属 epoch+1**。
⚠️ 代码注释原文「**数据不搬家**」⇒ 该路由**只改归属**,**数据一致性由调用方保证**(前提 = 两机 dataRoot 同路径 + 用户数据已同步)。
本平台两机路径**完全相同**(`/var/lib/dshs/users/<uuid>/`)⇒ 绝对路径与符号链接(pnpm 的 99 个 fallback 链接)全部直接可用。
**🔴 判据:worker agent 在跑 ≠ 该节点能跑实例。** 目标机必须查这四件套:
1. **`/var/lib/dshs/users` 的权限** —— 47 是 `drwx--x--x`;若目标机是 `drwx------` ⇒ `setpriv --reuid <uid>` **无法穿越该路径** ⇒ **实例必崩**(现象是"实例起不来",不会报权限错)。修:`chmod 711`(`/var/lib/dshs` 也应对齐 711)。
2. **dsh 主程序** —— 106 上原本**完全没有**(`/usr/local/bin/dsh` 不存在)。装法:`npm i -g @deepseek-ai/dsh@<与 47 同版本>`(会落到 `/usr/lib/node_modules`,bin 在 `/usr/bin/dsh`),再补 `/usr/local/bin/dsh` 符号链接(平台以 `dsh` 命令解析;worker env 里也可能有 `DSHS_DSH_BIN`)。
3. **`/usr/local/dsh-runtime`** —— python3.12.x(python-build-standalone,自包含可整体搬)+ `bin/{jq,rg,ffmpeg,ffprobe}`;缺失则实例内 python 全废。ffmpeg/ffprobe 各 ~170 MB,可暂缓。
4. **OS 账号** —— 从 `DSHS_BASE_UID` 起;**106 没有 provisioner**(`dsh-provision.path` 只在 47)⇒ 需手工 `useradd -u <uid> -M -s /usr/sbin/nologin dsh-<uuid>`。
**慢链路搬运(47 出口单流仅 ~16 KB/s)**:
- 并行确实有效:**8 路 ≈ 98 KB/s、16 路 ≈ 140 KB/s**(瓶颈是单连接 BDP,不是总带宽);
- **本机中转最优**:`47 →(8 路并行拉)→ 本机 →(实测 4.2 MB/s)→ 106`;
- ⚠️ **scp 并发 >10 会被 47 的 sshd 拒**(`Connection closed`);即便降到 6 路,**分片仍会静默中断**(数量齐、内容缺)⇒ **必须逐片比对大小 + 重拉**(脚本已存 `.workbuddy/tools/relay-transfer.sh`,本次第 1 轮补 5 片、第 2 轮归零);
- 口径:源机 `tar czf - | split -b 8m` 分片 → 本机 `xargs -P8 scp` 拉取并校验 → `scp` 到目标机合并 `tar xzf` → **`chown -R <uid>:<uid>`**。
**能重建就别搬**(省下的都是 47 出口带宽):
- dsh 主程序 → npm 在目标机装(295 MB,本地网络);
- MCN venv → 按 `mcn-req-no4.txt` 在目标机 `python -m venv` + `pip install`(免传 819 MB);
- `node_modules` → 本次**仍走搬运**(压缩后仅 89.6 MB)——因为 pnpm 的 fallback 符号链接是**绝对路径**,而两机路径恰好相同,直接搬最稳、且保证与源机逐字节一致;
- `whitelist-cache`(28 MB)**不传** —— 只被 Manager 侧 `src/web/routes/whitelist.ts` 读,worker 用不到。
**验收(本次实测判据)**:migrate 返回 `{"ok":true,"from":"w-47","to":"w-106","epoch":N+1,"port":…}`;目标机 `systemctl list-units 'dsh-*.scope'` 出现该 uid 的 scope 且 running;`curl 127.0.0.1:<port>` 得 **401**(存活);源机 scope 消失;插件磁盘层版本在位(本次 0.3.23 / 0.5.5)、journal 0 error。
**收尾**:源目录先 `tar czf` 到 `/opt/dsh/backups/migrated/` 并**验证条目数**,再删源。
**本机(Windows/WorkBuddy)坑**:安全删除层**同时拦 `rm`、`mv` 和 Python `shutil.move`**(trash 失败即 FAIL_CLOSED)⇒ 搬迁本机临时产物只能用 **PowerShell `Move-Item`**。
---
## 14. 删目录前的引用检查(2026-09-16 事故教训)
**事故**:在 106 上删掉旧控制面 `/root/dsh-users-platform` 后,**同机的集群 worker 起不来** —— 报
`Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'fastify' imported from /opt/dshs-cluster/lib/web/server.js`。
**根因**:`/opt/dshs-cluster/node_modules` 与 `/usr/local/dshs-cluster/node_modules` **都是符号链接** →
`/root/dsh-users-platform/node_modules`。删掉被指向的目录 ⇒ **依赖链断裂**。而删前的
`grep -rl '<path>' /etc/systemd/system/` 只显示"它自己的单元引用自己",看起来**没人依赖**。
📌 **纪律:删任何目录前,两个方向都要查**
1. **服务引用**:`grep -rl '<path>' /etc/systemd/system/ 2>/dev/null`
2. **符号链接引用**:`find / -lname '<path>*' -not -path '/proc/*' 2>/dev/null | head`
⚠️ 第 2 步最容易被跳过 —— 而「依赖目录被符号链接共享」在本项目里**是常态**
(集群目录的 `node_modules` 就链接到 `/root/dsh-users-platform/node_modules`)。
**配套动作**
- 删前**先备份可控数据**(本例数据根仅 176 K,备份成本≈0);
- 删后**立即验证同机其他服务仍 active** —— 本次就是靠 `systemctl is-active dshs-worker` 才发现事故的。
**依赖被误删时的修复套路**
拿一份完好的 `package.json` + `package-lock.json`(从另一台健康节点取)→ 目标机
`npm ci --omit=dev --ignore-scripts`。
⚠️ **必须 `--ignore-scripts`**:`npm ci` 默认会跑 `prepare`(= `npm run build`),
而纯运行目录通常没有源码 ⇒ 会中断,且**只装了一半**(表现为"装了 109 个包但仍报缺包")。
---
## 15. 后台任务「迟到通知」的处置(2026-09-16 实例)
**现象**:一个**早已被 `pkill` 杀掉**的后台搬运任务(`_relay.sh` 版),在 **2 小时 9 分**后才回报 `completed`,
并附上一份"看起来像它跑完了"的输出。
**为什么不用慌**:看它的输出就能自证没造成损害 ——
`Read from remote host … Connection reset by peer`(我杀它导致的)→ `本机: 0 片 0 字节`(源分片已被移走)
→ `上传106: 0 片` → 最后那条 `ls` 列出的目标目录**完好如初**。
📌 **纪律**:收到后台任务的迟到 `completed`,**不要直接采信"它做完了"**,先按三步核实副作用:
1. 读输出里**实际计数**(拉了几片 / 传了几片 / 耗时);
2. **对账目标目录**(两侧 `ls -l` 逐项比大小);
3. 确认**无残留进程/临时文件**、且**同机服务仍 active**。
⚠️ 典型教训:这类任务若在"源分片已被移走"后才继续跑,`scp` 会因通配符无匹配而失败 ⇒ **反而不会破坏目标**;
但若源分片**仍在**,它就会把**不完整的分片**解包覆盖到目标 ⇒ 所以**核实动作不能省**。
---
## 16. 🔴 碰新 HTTPS git 主机会弹「授权窗」= 那是 **git 凭据管理器(GCM)**,不是 WorkBuddy 闸门(2026-09-18 实测)
**现象**:对**新的** HTTPS 远端(例:`git ls-remote https://cnb.cool/...`)执行 git 命令后,桌面**弹出登录/授权窗**,
命令**无任何输出**(挂着等凭据);用户看到会以为"平台又要我授权",质问「不是已经授权了吗」。
**真因(本机 `~/.gitconfig` 实录)**:
- `[credential] helper = !"…/mingw64/bin/git-credential-manager.exe"` ⇒ **全局**挂 GCM;
- `[credential "https://work.alotbuy.com"] provider = generic` ⇒ **只为这一台主机**配过 provider;
- `[http]/[https] proxy = http://127.0.0.1:10800`。
⇒ `work.alotbuy.com` 一直走 **SSH**(`[email protected]:…`)⇒ 用 SSH key、**根本不进 GCM**、从不弹窗;
而 `cnb.cool` 走 HTTPS、又是**没配过凭据的新主机** ⇒ GCM 必弹。**"授权过 SSH key" ≠ "所有 git 主机都授权了"**。
**判据(区分"平台拦了"还是"git 在要凭据")**:
`E:\ProgramData\.workbuddy\audit-log\spool\audit-spool-<pid>-<date>.jsonl`(⚠️ 当日**未 flush**,只在这里;正式分片 `YYYY-MM-DD.N.jsonl` 会滞后)
—— 若该命令有 `command-safety.sandbox-executed | allowed` 记录 ⇒ **平台已放行,弹窗来自 git 自己**。
(对照:今天 00:04 那条 `file-safety.bulk-delete.approved` 才是**平台**弹的,且**只对那一条命令生效**,不具传染性。)
**修法**:① 新主机**优先用 SSH**;② 一次性命令显式关掉 GCM ⇒ 加 `-c credential.helper=` + `GIT_TERMINAL_PROMPT=0`;
③ 必须用 HTTPS ⇒ 先给 `[credential "<host>"]` 配 provider / 存一次 token,把交互收敛成一次性。
⛔ 别用 `git config --global credential.helper ""` 全局关 —— 会连带打断 `work.alotbuy.com` 的现有链路。
**✅ 本轮已修**:给 `cnb.cool` 补 `credential.https://cnb.cool.provider = generic` + `git credential approve` 存入 GCM
⇒ 之后 `ls-remote` 不再弹窗(实测不带任何显式凭据也可通过)。
**⚠️ 反向坑**:凭据已入库后 ⛔ **不要**再写 `git -c credential.helper=` —— 它把"已存好的凭据"也一起禁掉,
报 `could not read Username for 'https://cnb.cool'`。该参数只在"绕开 GCM、显式传 token"时才用。
---
## 17. 🔴 `rm -rf` / `shutil.rmtree` 超过 50 文件被闸门拦住时,**脚本不会停** —— 会静默产出「假绿」(2026-09-18 实测)
**现象**:脚本里"先删旧目录、再重建",执行后脚本**继续往下跑**,只有一行
`[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] {"count":474,"threshold":50,...}`
—— **看起来像警告,其实删除根本没执行**。
**后果链(本轮真实发生过一次)**:
删除被拦 ⇒ 目标目录还在 ⇒ `os.makedirs(..., exist_ok=True)` 使**老文件原封不动**保留(或 `makedirs` 直接抛错 ⇒ 复制整段被跳过)
⇒ `rm -rf .git` 同样被拦 ⇒ `git init` 变成 **re-init**(会提示 reusing existing repository)
⇒ 新提交**叠在旧内容之上** ⇒ 产出「**commit message 写着"原样",内容其实还是上一版**」的**假绿**。
**判据**:脚本里凡有 `rmtree` / `rm -rf`,**执行后必须复检目录是否真没了**(`[ -d "$D" ] && echo 仍在`);
⛔ 不能以"命令没报错"推定删除成功。
**解法(首选)**:**换一个全新的目录名,别删**。删除只在"确实要回收空间"时单独做一步、单独复检。
**同理(同一类误判)**:`TaskStop` 报 `killed` **不代表撤销了已发出的网络动作** —— 本轮后台任务被 kill 之后,
远端 sha 依然从 `8e88cd2` 变成 `d83ed62` ⇒ **终止后必须重新核对远端状态**(`git ls-remote` 取裸 sha),别只看 kill 回执。
---
## 18. 🔴 实例子域 **500** `host "w-NN" 声明 via=relay 但解析不出落点`(2026-09-19 实测 · **观测面绿而控制面红**)
**现象**:某用户打开 `<user>.<baseDomain>` ⇒ **HTTP 500**,正文
`{"statusCode":500,"error":"Internal Server Error","message":"host \"w-106\" 声明 via=relay 但解析不出落点:拒绝回落到 endpoint (relay 语义下 endpoint 是落点,回落会打到本机同号端口)"}`。
⛔ **不是域名问题、不是 nginx 问题、不是实例挂了** —— 报障时别往那三个方向查。
**定位(三步,别绕)**:
1. **看是哪台 host**:正文里的 `host "w-106"` 即 `dsh_hosts.id`;`dsh_instances.host_id` 可查"谁归属哪台"
(`select u.username, i.host_id, i.status from users u left join dsh_instances i on i.user_id=u.id`)。
2. **看 manager 读的那份 `/status` 里该 host 的端点是不是 `online:false`**:读 `DSHS_RELAY_STATUS_URL`
(本平台 = `http://127.0.0.1:20080/status` = **47 中继**)⇒ `endpoints[].online`。
⚠️ **它 `online:false` 有两种可能,必须分开**:**(a)** 该 worker 真的离线;**(b)** **它挂到另一台中继上去了**(`online` 是**中继本地**的健康判定,不是全局事实)。
3. **拿另一台中继的 `/status` 对照**(106:`ssh test106 'curl -s http://127.0.0.1:20080/status'`)⇒ 看 `online[]` 与 `sessions[]`。
**实测形态**:47 说 `w-106 … online:false`(陈旧条目),106 说 `online: ["w-106(session=… ports=19000/21001 … hbAge=…ms …)"]` ⇒ **worker 活得很好,只是挂在 106 那台**。
**根因(文件级)**:`lib/net/relay/rendezvous.js#RelayRendezvous.resolve()`
= `const pushed = opts.presence?.(name); const online = pushed ?? opts.online?.(name); if (online === false) return undefined;`
⇒ `online` 只来自**单一**中继的状态快照;`undefined` ⇒ `lib/net/reachability.js#agentBaseUrlOf()` **按设计抛错**
(`via===relay` 时**不许**回落 `agentUrl`:那会打到控制面本机同号端口,属 P0-3 刻意失败)。
**解法(按代价从小到大)**:
- **① 让 worker 回到 manager 正在读的那台中继上**(= 两机 `SEEDS` 的**主入口**必须落在同一台):
`DSHS_OVERLAY_BOOTSTRAP_SEEDS` 的**首位**决定主入口(参数表语义「主入口首位」)。
⚠️ 106 的 worker 曾因候选链首位是自己的裸 IP 中继而**长期不注册**到 47 ⇒ 表现为"实例面 500 但探针全绿"。
- **② 修代码:可达性/在线态改为「两台中继并集」** —— 与 `scripts/overlay-probe.cjs` **序㊾ 的并集口径同源**
(`eps` 键 = `network:hostId:port`、`online` 取**或**、`used` 按 `network/hostId` **去重**)。
⛔ 这是**独立立项**(改 `web/server.js` 的 `online`/`presence` 提供者),别顺手改。
**配置坑(同族 · 2026-09-19 踩过)**:`DSHS_OVERLAY_BOOTSTRAP_SEEDS` 的**唯一载体 = drop-in**
(`/etc/systemd/system/dshs.service.d/overlay-443fb.conf`)。
🔴 **在 `/etc/dshs.env` 里写同名键会静默压掉 drop-in**(`EnvironmentFile` 后应用)⇒ 入口列表被**窄化**、
丢掉第二中继 ⇒ 直接诱发本节的 500。⛔ **改这个键只改 drop-in**;改完用
`tr '\0' '\n' < /proc/$(systemctl show dshs -p MainPID --value)/environ | grep SEEDS` 复核**进程实际值**。
**判据(可机读)**:`journalctl -u dshs | grep -c "解析不出落点"` 的首现时间 = 该 500 的真实起点;
`[relay-client] registered host=w-NN network=ops session=…` + `[overlay-candidates] … urls=` 的**首位** 是修复是否生效的直接证据。
**⚠️ 本条最值钱的教训**:**"探针全绿" ≠ "实例面可用"** —— 序㊾ 让探针按两台中继**并集**看,所以 worker 漂到哪台它都绿;
而**控制面仍只读一台**。凡"用户报某个域/某台机器打不开"而探针全绿 ⇒ **先查 `dsh_hosts.via` 与归哪台**,别信探针的绿。
---
## 19. 🔴 报「实例子域 **502**」先查这一条:平台到底**有没有回响应**(2026-09-19 实测)
**现象**:用户打开 `<user>.<baseDomain>` 得到 **502**(浏览器一张无信息错误页),
而门户正常、平台进程正常、实例进程也正常。**别往 nginx 配置 / 域名迁移 / 实例挂了这三个方向查。**
**判据(两条,10 秒定案)**:
1. nginx error log 里该请求是 `upstream prematurely closed connection while reading response header`
—— ⛔ 不是 `connect() failed (111)`、也不是 `no live upstreams`;
2. **平台 journal 里那条请求只有 `incoming request`、没有 `request completed`**
⇒ **平台接了请求却没写出任何响应**(Fastify 的 completed 日志不打 = 响应从未发出)。
**根因(档案 141)**:`src/supervisor/proxy.ts#proxyHttp()` 在转发重试**两次都失败**时
`reply.raw.destroy()` —— **不写响应头、不写 body,直接断连**。
触发场景 = **实例冷启动窗口**:`/api/dsh/enter` 已返回、scope 才建立 10 余秒,
**实例进程已在、端口已分配,但 dsh 还没开始监听**;
而 `resolveSubdomainAccess()` 走 `supervisor.endpointFor()`(**不读 `dsh_instances.status`**)⇒ 拿得到 endpoint
⇒ 进**转发分支**,**不会**落到档案 49 的 `404 not_running → 302 /wake.html` 过渡页分支。
**修复**:新增 `replyUpstreamUnavailable()` —— 响应头未发出时回 **503 + `Retry-After: 2`**
(导航请求给一页会自动 `location.reload()` 的极简 HTML;其余回 JSON `instance_starting`),
只有已 `headersSent`/`writableEnded` 才允许 destroy。
**回滚**:`/opt/dsh/backups/proxy.js.pre141-20260919-161444` → `systemctl restart dshs`。
**⚠️ 两个别踩**:
- 🔴 **`dsh_instances.status` 可能长期停在 `stopped` 且 `pid`/`port` 为空,而 scope 一直在跑** ——
它是**控制面视图**,`endpointFor()` **不读它**,⛔ 别拿它判"实例到底在不在跑"(也别据此判"平台会重复拉起")。
- ⚠️ **该 502 只在冷启动窗口内**,实例就绪后**自行恢复**(`curl 127.0.0.1:<实例端口>` 会回 401)。
复现差异大:事后同机 `curl -H "Host: <user>.<baseDomain>"` 打 3080 只会拿到 401,
**复现不出 502** —— 别因"我打不开复现"就否定结论,判据看上面两条。
---
## 20. 🔴 插件「点了没反应」先查**槽位名**这类外部契约(2026-09-20 实测 · MCN工作台 + 语言下拉)
**症状**:实例里点左侧「MCN工作台」**毫无反应**;设置 → 用户设置里切「界面语言」**看着也没反应**。
**同一根因类别**:**dsh 升级改了插件依赖的外部契约,插件里写死的老契约静默失效** —— 不是「改不生效」,也不是用户操作问题。
| 症状 | 真因(dsh 0.1.5-rc.1 实测) | 10 秒判据 |
|---|---|---|
| 工作台点了不出现 | 对话区**槽位名** `conversation` → **`main.conversation`** ⇒ `querySelector('[data-slot="conversation"]')` 恒 null ⇒ 面板宿主一直 null、`createPortal` 不渲染 | `!!document.querySelector('[data-mcn-panel]')` = false;页面 `data-slot` 列表里只有 `main.conversation` |
| 切语言「没反应」 | `ctx.locale.getLocale()` 返回**快照对象** `{active,locales,revision}`(老版返回 id 字符串)⇒ `String(obj)` = `"[object Object]"` ⇒ 受控 `<select>` 匹配不上任何 option,**浏览器只显示第一项 English** | `document.documentElement.lang` 与 select 的 `value` **不一致**(界面中文 / 下拉 English)= 命中 |
- ⚠️ 别按「界面语言对不对」判:**它可能是对的,错的是下拉显示** —— 选「中文」时本就已是中文 ⇒ 看着毫无变化。
- 🔧 **浏览器实测姿势**:`mksess.cjs` 造 admin 临时会话 → `agent-browser`:**`set headers` 注 Cookie 无效**,改用
`open https://ai1net.com/login.html` + `eval document.cookie='sid=…; domain=.ai1net.com; path=/'` → 再 `open` 实例子域。
⚠️ `agent-browser` **前台会被沙箱 SIGTERM**;用 `run_in_background` 起一次(守护进程常驻)后,后续命令加 `> 文件 2>&1`
(**不走管道**)即可前台跑。用完**删会话**(`user_agent='poc-curl2'`)。
- 🔧 **重打包前先做「新产物 vs 线上在跑的那份」`diff`**:本次正是靠它抓到 3 处「一构建就负向回归」
(已摘除的计划任务被装回 / 死链「自动任务」菜单项复活 / 1.9 MB 历史 tgz 被打进包)。详见 **档案 `04-调整方案/143-MCN工作台入口失效与语言切换显示修复.md`**。
- 🔧 手动跑平台脚本(`ensure-*.cjs`)必须带**运行环境**:`/etc/dshs.env` + `dshs.service.d/*.conf` 的 `Environment=`
(缺 `DSH_PLATFORM_DIR` ⇒ `artifactDir()` 落到 `/root/.dshs/platform/artifacts`);模板见 `.workbuddy/tmp/run-with-platform-env.sh`。
---
## 21. 🔴 浏览器侧「注入 UI」验收的两个**假阴性**(2026-09-21 实测 · 差点误报成产品缺陷)
> 场景:改 `assets/inject/recovery.js`(浮层)后要证明"动画真的在跑、观感对了"。两条都会让**正确的实现看起来是坏的**。
### 21.1 `--virtual-time-budget` 会**冻结 CSS 动画时钟** ⇒ 假"动画卡死"
- **现象**:`chrome --headless=new --virtual-time-budget=6000 --dump-dom` 采样计算样式,
`transform` 恒为**起始帧**(`__dshr-in` 的 0% = `scale(.8)`),在 150/400/900/1800/3200ms **一模一样** ⇒ 看着像动画不动。
- **判据(10 秒分真假)**:读 **Web Animations API** ——
`document.querySelector('.__dsh-hub-icon').getAnimations()[0]` 的 `currentTime`/`playState`:
- `currentTime` **恒 0** 且 `playState = running` ⇒ **时钟被冻结,是夹具假象**(本轮命中);
- `playState = idle / paused` 或 `getAnimations()` **为空** ⇒ 才是动画真坏了。
- 加 `--run-all-compositor-stages-before-draw` **无效**,假象照旧。
- ✅ **正解 = 拿真时钟证据**:本机 `playwright / selenium / websockets / pyppeteer` **一个都没装**,但
**Node 22 自带全局 `WebSocket`** ⇒ 直接走 **CDP**:
起 `chrome --headless=new --remote-debugging-port=9225 --user-data-dir=<临时>` → `GET /json/list` 取 page 的 `webSocketDebuggerUrl`
→ `Page.enable` / `Runtime.enable` / `Page.navigate` → `Runtime.evaluate({awaitPromise:true})` 里用**真 `setTimeout`** 采样
→ `Page.captureScreenshot` 抓帧。脚本:`tmp/anim_final.cjs`。
- **加餐(比真时钟更硬)**:`anim.pause()` 后**手设 `currentTime`** 扫关键帧 ⇒ **零时钟依赖**的确定性验证。
本轮实测:`ct=0 → 0.8`,`ct≈367ms → 1.05`(60% 过冲),`ct=633ms → finished / 1.0`,`opacity` 全程 `1`。
### 21.2 抠底图配 `box-shadow` ⇒ 又长出一圈**方形**光晕
- `box-shadow` 沿**元素矩形边界**绘制;`filter: drop-shadow()` 跟随 **alpha 轮廓**。
- ⇒ **透明底 / 抠底图做"发光"只能 `drop-shadow`**;用 `box-shadow` 会在图外画出一圈方框,观感 = "图外面又套了个矩形"。
- 自查:`getComputedStyle(img).boxShadow` 应为 `none`。
- 同类:**同一元素上两个 CSS 动画抢同一属性 ⇒ 后声明的覆盖前者**(曾致 `opacity=0`、图已加载却看不见)⇒ 原则是**一个元素只挂一个动画**。
### 21.3 静态资源取图通路(浮层/注入 UI 通用)
- **实例子域只放行 `/favicon.svg` 一个路径**,其余静态资源一律 **401** ⇒ 注入 UI **不能**用子域相对路径取图。
- 改取**门户根域**(`https://ai1net.com/<asset>` = 200,**无 CORP / 无 CORS 限制** ⇒ 跨域 `<img>` 可加载),
根域由 `location.hostname` 去首段推出(IP 或两级以内域名原样用),配 `onerror` 兜底。
- ⚠️ 取图通路必须**显式验**:`curl -A "<浏览器 UA>" -o 文件 -w "%{http_code}|%{content_type}|%{size_download}"`,
再比 **md5 与本地一致**(本机 `urllib` 会被 UA 拦成 403)。
### 21.4 本机工具路径坑(本轮定位 · 会让脚本**静默整段无输出**)
- PortableGit `1.2.0` 的 **`mingw64/bin` 里没有 `curl.exe`**(`usr/bin` 也没有)
⇒ 照抄旧脚本的路径调 curl ⇒ `subprocess` 抛 `FileNotFoundError`(未捕获)⇒ **该步之后全静默**,看着像"没输出/没命中"。
- ✅ 本机可用:`C:\Windows\System32\curl.exe`、`D:\Program Files\Git\mingw64\bin\curl.exe`;ssh/scp 用 PortableGit `usr/bin`。
- ⚠️ `scp` 的端口是**大写 `-P`**(小写 `-p` 是保时间戳,会**静默什么也不传**)。
- 🔴 **`GIT_SSH_COMMAND` 在 Windows 上必须用正斜杠**:传 `E:\...\ssh.exe`(反斜杠)⇒ git 转手时反斜杠被吃掉 ⇒
先报 `E:ProgramData...ssh.exe: command not found`,紧接着报
**`fatal: Could not read from remote repository.` + `Please make sure you have the correct access rights and the repository exists.`**
—— **这句极具误导性**(看着像密钥/权限/仓库不存在,实为路径被吃)。
✅ 正解:**别设 `GIT_SSH_COMMAND`**,靠 PATH 里的 PortableGit `usr/bin/ssh.exe` 即可(裸 `git ls-remote` 也是这么通的)。
- 🔴 **无人值守会话里 PATH 可能只剩「壳」** —— 实测(2026-09-21 automation 轮)本机 bash 首条命令就报
`shell-runtime-bash-env.sh: line 3: dirname: command not found` + `bash.exe: line 1: head: command not found`,
**`| head` / `$(dirname …)` 全废、脚本看着像"无输出"**。✅ 每条命令首行前置即可(与 `state.py` 用的同一套):
`export PATH="/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/usr/bin:/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/bin:$PATH"`
⛔ **不要**改前置 `/c/Windows/System32`(那里的 `bash` 是 WSL 启动器 ⇒ 撞 Program Blacklist,整条命令被拦)。
- 🔴 **批量字符串替换时,`python - << 'PYEOF'` 内联的 `old` 极易 MISS**(实测 2026-09-21):
路径、含单引号/反斜杠/中文引号的 `old` 一旦与文件里的字节不完全一致,`str.replace` **静默什么都不做**,
脚本照样 `print` 成功 ⇒ 看着像"改了却没生效"(像 bug,实为 MISS)。
✅ 判据:**每次都打印替换前后的字符数变化**(`len(t)` 前后对比),长度没变就是 MISS;
✅ 更稳的做法:**先把替换逻辑写成 `.py` 文件再跑**(用 Write 工具写,避免 shell 转义),并 `assert old in t`。
- 🔴 **改 `MEMORY.md` 前先数字符数**(实测 2026-09-21):项目 `MEMORY.md` 有 **≈7,800 字符注入上限**,
超出的部分**不会被注入**(=等于不存在)。本轮因新增一行涨到 ≈8,340 **已超**,
不得不回头压缩四处旧行才降下来。✅ 铁律:**只减不增**;要加内容先想好"哪一段下沉到 `04/<NN>`"。
### 21.5 提交/推送前必做(⛔ 防"把别人的在途活一起提交")
- 🔴 **绝不用 `git add -A` / `git add .`**。本仓常态是「多线并行、工作树常驻 30+ 已改文件 + 60+ 未跟踪」,
全量 add 会把别条线的在途改动一起卷进你的提交。
- ✅ 顺序:**① 逐个文件 `git diff -- <path>` 确认归属 → ② 用显式路径 `git add -- <p1> <p2> …` → ③ `git status --short -- <路径>` 复核暂存清单 → ④ 提交**。
- ✅ **推前分叉判定**(比 `git log` 可靠):`git ls-remote <url> refs/heads/master` 取**裸 sha**,
与本地提交前的基线比;**相等 ⇒ 纯快进可推**;不等 ⇒ 先 `fetch` 比对,⛔ 别强推。
再用 `git merge-base --is-ancestor <基线> HEAD`(rc=0)交叉验证。
- ✅ **推后复核**:两端裸 sha 应 **= 本地 HEAD**(三方一致)才算闭环;只报「push 成功」不算。
- ⚠️ 本仓 `origin` 配了**两条 pushurl**(`work.alotbuy.com` SSH + `cnb.cool` HTTPS)⇒ 一次 `push` 即双推。
CNB 令牌会过期,401 会被 git **泛化成「仓库不存在 / 权限不足」** ⇒ ⛔ 别据此改远端 URL,先查 helper。
### 21.6 🔴「这三类禁止入库」+判定它的两个 git 语义坑(2026-09-21)
**禁令(用户明令)**:`tmp/` · `交接单/` · 中间产物 —— **禁止提交**。已由根 `.gitignore` 机械兜住:
`/tmp/`、`/_tmp*/`、`/_中间产物*/`、`dsh-server-docs/交接单/`(⛔ 一律**根锚定**,否则会误吞 `dsh-server-docs/archive/_tmp_r6_s8.md` 这类既有跟踪文件)。
🔴 **坑 1:`git check-ignore -v <目录带斜杠>` 会假阳性。**
实测 `git check-ignore -v tmp/` → rc=0,且指向 `.gitignore` 的**某一行**(当时指向第 39 行,而那是个**空行**,文件里根本没有 `tmp/` 规则)。
✅ **判据一律用「目录内的文件路径」**:`git check-ignore -v tmp/x.txt`;并与 `git status --porcelain`、
`git ls-files -o --exclude-standard` **交叉验证**(这两条才是权威口径)。
🔴 **坑 2:`status` / `ls-files` 输出里的中文路径会被 C-quoted。**
`"_\\344\\270\\255\\351\\227\\264\\344\\272\\247\\347\\211\\251_..."` ⇒ **用中文字面量做 `in` 过滤会全部漏掉**(表现为"没有未跟踪的交接单",其实是过滤失效)。
✅ 判据:**输出里看到 `"\\3xx` 形式的转义** ⇒ 说明路径非 ASCII 被引号包起来了,改用**转义后的形式**匹配,或干脆**只数总数**、别按字面量筛。
🔴 **坑 3:改 `.gitignore` 前先确认它的行尾。** 本仓根 `.gitignore` 是 **CRLF**(`dsh-server-docs/.gitignore` 是 LF)。
按 LF 追加 ⇒ **混合行尾**(露馅信号:追加后 `CR` 计数**没变**)。✅ 复核判据:**`CR 数 == 总行数`** 才算全 CRLF。
⚠️ 被忽略的**预期副作用**:这些路径从此**不再出现在 `git status`** ⇒ 别把它当"没生成"。已跟踪文件(如 `交接单/README.md`)不受影响,仍可正常更新提交。
### §21.4 补 · INDEX.md 类台账的字节级单行插入:三处断言纪律(2026-09-21 实测)
**场景**:往 `dsh-server-docs/INDEX.md` 这类**按行组织的大台账**里插一行(档案登记)。
**⚠️ 第一条坑:`CR == CRLF 配对数` 这个断言是错的**(本次连中两次):
写插入脚本时想当然断言 `out.count(b'\r\n') == cr_before`。实测 `INDEX.md`:
```
bytes 62292 CR 5 LF 273 CRLF 0 ← CR=5 但 CRLF=0
```
⇒ **5 个 CR 全是孤立 CR**(不是 CRLF,是历史残渣)。既然 `CRLF` 本来就是 0,任何「CR==CRLF」的断言必炸。
✅ **正确断言**:`cr_after == cr_before` + `lf_after == lf_before + 1` + `out.count(b'\r\n') == b.count(b'\r\n')`(配对数不变)。
**⚠️ 第二条坑:孤立 CR 的绝对偏移必然右移,⛔ 不能断言位置相等**:
那 5 个孤立 CR 位于**插入点之后**(byte 55550 > 148 行所在处)⇒ 插一行后整体右移。
断言 `lone_after == lone_before` **必炸**。✅ **正确断言**(三选一,本次用前两条):
```python
assert len(lone_after) == len(lone_before) # 数量不变
gap = [x - lone_after[0] for x in lone_after] # 相对间距不变
assert gap == [x - lone_before[0] for x in lone_before]
shift = lone_after[0] - lone_before[0] # 右移量 == 新行字节数 + 1
assert shift == len(new_line.encode('utf-8')) + 1
```
右移量那一条最有价值:**它同时验证了「新行确实被插进了文件」**(若插失败,shift 会是 0)。
**🔴 第三条(教训):`assert` 报的是「我的期望错了」还是「文件坏了」,必须分清**。
本次两次 `AssertionError` 都是**我的断言写错**,文件本身是对的。
⛔ **不要一报错就 `git checkout --` 或改写文件** —— 先 print 实际值,判明是期望错还是数据坏。
✅ 先跑**只读诊断**(数 CR/LF/CRLF、用 `re.finditer(rb'\r(?!\n)')` 定位孤立 CR 并打印上下文),再决定改断言还是改文件。
**顺带发现(未处理)**:`INDEX.md` byte 55550 起有 5 个连写孤立 `\r`(`\r\r\r\r\r`),
夹在「2026-09-14 完成 |」与「|**T09–T21**」之间。`docs-audit.py` **只数 CR 总数、不区分孤立/配对** ⇒ 未报此问题。
⇒ **审计脚本有盲区**:`CR` 计数正常不代表行尾干净。属「只做被明确要求的事」,未擅自修。
### §21.5 · 改「表格类」文件时 Edit 的两个静默破坏(2026-09-21 实测)
**背景**:给 `MEMORY.md` 压字数,连续用 `Edit` 替换整行。两次出问题,**都不报错、都静默**。
**坑 1 —— 把行尾的换行带进 `old_string`,两行黏成一行。**
`new_string` 以 `|` 结尾(表格行),若把上一行的 `old_string` 尾写成 `…|\n|` 而 `new_string` 尾只写 `…|`,
则**分隔两行的 `\n|` 被吃掉** ⇒ 这一行与下一行黏成一行、表格行数少 1。
`MEMORY.md` 因此从 `49 行` 变 `48 行`(字符总数只少了几个,**看总数发现不了**)。
⇒ 🔴 **判据**:改表格类文件后,**必须核对「行数」与「每行长度表」**,⛔ 不能只看字符总数。
行数掉 1 而字符数只微降 = 黏行的典型指纹。
**坑 2 —— `old_string` 结尾跨到下一个 `##` 标题,把标题删掉。**
为省事把 `old_string` 一路写到紧邻的下一个标题处,`new_string` 却没带上该标题 ⇒ **标题消失**。
`MEMORY.md` 的 `## 三、成本与自动化` 就这样丢过一次。
⇒ 🔴 **判据**:`old_string` 的**结尾不要跨过任何 `##` 标题**;要跨小节就分两次 Edit。
**共性**:两者都是**局部成功、整体破坏** —— 工具返回 `Successfully edited`,文件也能正常读,
只有**结构级校验**(数行数、比对标题清单、比对表格行数)才发现。
⇒ ✅ 定式:**改结构性文件 → 收尾必须跑一次「结构校验」**(`行数 + 标题清单 + 每行长度`),
脚本量完即删,⛔ 别省这一步。
### §21.6 · `git status | grep <中文路径>` 恒为空 —— 别据此判「文件没生成/被忽略」(2026-09-21)
**现象**:新建 `dsh-server-docs/架构设计/README.md` 后,
`git status --short | grep 架构设计` **返回 0 行**;`git check-ignore -v` 也 `rc=1`(未忽略)。
看起来像「文件根本没被 git 看到」。**实际文件是好的。**
**根因**:`git` 默认 `core.quotepath=true`,把非 ASCII 路径**转义成八进制**输出
(`"dsh-server-docs/\346\236\266\346\236\204\350\256\276\350\256\241/..."`),
所以 `grep 中文` **永远匹配不到**。⚠️ 与本仓 `grep`/`ls` 无关,纯粹是输出转义。
**✅ 正解**:`git -c core.quotepath=false status --short --untracked-files=all | grep <中文>`
**判据(别搞反)**:
- ① 中文路径**一律**加 `-c core.quotepath=false` 再看;⛔ 别用「grep 没命中」当判据。
- ② 新增目录首次要 `--untracked-files=all`,否则只显示目录名、看不到里面文件。
- ③ 想确认「有没有被忽略」用 `git check-ignore -v <path>`,**退出码 1 = 未忽略**(不是出错)。
- ④ 与 §21.4 同源:**「我 grep 不到」≠「东西不存在」** —— 先分辨是「工具口径」还是「真没有」。
## 22. 🔴 共享只读层(`bundled-*`)落地与验收的四个坑(2026-09-22 实测 · 插件投放与分库线执行棒①)
### 22.1 部署真源 = `lib/`,⛔ 不是 `/opt/dshs` 的 git HEAD
- 47:`ExecStart=/usr/local/bin/node lib/cli.js`(`WorkingDirectory=/opt/dshs`);106:`/opt/dshs-cluster/lib/cli.js`。
- ⚠️ **`git -C /opt/dshs log -1` 是噪声**:曾在 `8390eb32`(main) 而 `src/` 全量显示 modified —— 那棵树是 **scp 铺出来的**,与本地仓 `3d8f50e`(master) 完全不同代。
- ✅ **正确判据 = 与本地构建产物比字节**(跨平台先 `tr -d '\r'` 再 md5):开工时抽 4 个未改文件比,**全等** ⇒ 部署形态 = `npm run build` + `scp lib/**` + `systemctl restart dshs`。
- ⛔ **别 scp `src/` 上去再在服务器 build** —— 服务器的 `src/` 是另一代,会混出不一致产物。
### 22.2 ⛔ 两节点 `lib/` 会**静默分叉** ⇒ 部署后**必须**比一次全量
**实测**:106 `/opt/dshs-cluster/lib` 缺 **14 个 `.js`**、**33 个共有文件内容不同**(逐次 scp 拼出来的旧树)。
**后果**:新代码 `config.js` `import './platform-paths.js'`,而 106 没这个文件 ⇒ `dshs-worker` **崩溃循环**(`ERR_MODULE_NOT_FOUND`,`restart counter 9+`)。**47 正常不代表 106 正常**。
**必修姿势**:
```
本地:tar czf /tmp/lib-sync.tgz lib
远端:cd <部署根> && tar xzf … && chown -R root:root lib && systemctl restart <unit>
对账:find lib -type f | 逐文件 tr -d '\r' | md5sum → 两节点 diff 行数 = 0(`*.bak-*` 历史残留除外)
```
⚠️ **判据**:`systemctl is-active` **看一次不算** —— 崩溃循环期间它会短暂显示 `activating`;必须 `journalctl -u <unit> --since @<epoch> | tail` 看有没有 `ERR_MODULE_NOT_FOUND` / `status=1/FAILURE`。
### 22.3 🔴 沙箱内验收:`nsenter` 挑错 PID = **读的是宿主**(假阳性)
**实测坑**:`nsenter -t $(pgrep -f '^/usr/bin/bwrap' | head -1) -m` **取的是宿主命名空间** —— bwrap 会留一个**父监控进程在外面**,只有它的**子进程**在沙箱里。
用错 PID 时:`ls /var/lib/dshs/bundled-plugins` **能看到东西**(那是宿主同名路径!)、`touch` 报 **`Permission denied`**(宿主权限,而非 `Read-only file system`)⇒ **看起来"验证通过",其实什么都没验**。
**正确姿势**:
```
N=$(ps -eo pid,comm,args --no-headers | awk '$2=="node" && /dsh --profile/ {print $1}' | head -1)
grep -E "bundled-(skills|plugins)" /proc/$N/mountinfo # ← 前提:必须能看到既有 bundled-skills 那一条,否则 PID 还是错的
nsenter -t $N -m -- setpriv --reuid <uid> --regid <uid> --clear-groups -- /bin/ls -l <路径>
nsenter -t $N -m -- setpriv --reuid <uid> --regid <uid> --clear-groups -- /bin/sh -c "touch <路径>/x" # 期望 Read-only file system
```
**判据三条**(缺一不可):① `/proc/<pid>/mountinfo` 里目标路径带 **`ro,nosuid,nodev`** ② 实例内能看到内容 ③ 实例内写 = **`Read-only file system`**(不是 `Permission denied`)。
⚠️ R10:对用户实例的一切验证**必须 `setpriv` 到该 uid**。
### 22.4 共享层物化(`pnpm install` 到只读共享目录)的四个必修
| # | 坑 | 现象 | 修法 |
|---|---|---|---|
| 1 | **peer 自动安装** | `ERR_PNPM_NO_MATCHING_VERSION @deepseek-ai/dsh-client-runtime@^0.1.2-rc.1`(公共源只有 `0.0.1-rc.1` / `next 0.1.1-rc.2`) | dsh 插件的 `@deepseek-ai/dsh-client-*` 是 **optional peerDependencies**(宿主提供)⇒ 加 **`--config.auto-install-peers=false`**。⛔ 别去掉 |
| 2 | **scoped 包名路由 404** | `POST /api/plugins/business/@scope/name/share` → `404 Route … not found`(Fastify `:id` 不吃 `/`) | 候选池 4 个里 3 个带 scope ⇒ 必须**再加一条 body 形态**(`{id}`);既有 `DELETE …/:id` 有同一限制 |
| 3 | **tar 以 root 解包按归档 uid 还原属主** | 共享层里出现 `197108:197121`(开发机 uid)—— 若该 uid 本机存在就能**改写共享只读层** | 解包后强制 `chown -R root:root <dest>`;验收 `find <dest> -not -user root | wc -l` = 0 |
| 4 | **回滚素材对存量包缺失** | 首次 share 回 `backupTgz:""`(上传钩子的备份只对新上传生效) | 在 **share 时兜底补落**一份 tgz 到备份面(幂等:已存在则跳过) |
### 22.5 🔴 候选池**记录字段不可信**,版本只认**内容指纹**(2026-09-22 实测)
**现象**:`dsh-plugin-mcn-suite` 三处读数互相矛盾 ——
| 来源 | 读数 |
|---|---|
| `business_plugins` 记录 | `version=0.3.9` · `file_size=1953267` · `updated_at=2026-09-13 00:02:16` |
| 池内 tgz(磁盘) | **`0.3.13`**(包内 `package/package.json`)· **1989680 B** · `mtime=2026-09-20 22:13:45` |
| 共享层 `.manifest.json` | `version:"0.3.9"` · `tgzSha256=d14cd5fe…` ← **sha 与磁盘那份相同** ⇒ 同一份文件 |
| 备份面 | `/opt/dsh/backups/plugins/dsh-plugin-mcn-suite/**0.3.9**.tgz`(sha 同上面)⇒ **文件名带错版本号** |
**根因判据**:记录 `updated_at`(09-13)**≠** 文件 `mtime`(09-20)⇒ 该 tgz 在 **09-20 被平台之外的方式**(直接 cp/scp)替换过,记录从未同步;平台**没有"文件被外部替换"检测**(无 sha 台账)。
**对照**:`@dsh-local/storyforge` 记录 `0.1.0 / 1754963` 与磁盘**逐项一致** ⇒ 走上传 API 的路径本身没问题。
**由此得出的硬判据(写更新/回滚逻辑时必守)**:
1. 判「有没有新版本」= **tgz 内容 sha256 或包内 `version`**,⛔ 不用池记录字段;
2. 上传钩子须**回读校验**(记录 version == 包内 version),不一致 ⇒ 写审计告警;
3. B 档回滚素材文件名改用 **`<包内版本>-<sha256 前 8 位>.tgz`** —— 现名(`0.3.9.tgz`)会让"取上一版"取错;
4. 凡"平台外可写"的目录(候选池、共享层、备份面)都要有 **sha 台账 + 定期对账**,否则指纹与标签会静默脱钩。
### 22.6 本机 bash 包装器 PATH:`export` **有效**,且必须**每条命令前置**
⚠️ **修正一条旧口径**:工作区 MEMORY 曾写「若包装器整体起不来(`ls`/`cat` 也 not found)⇒ export 救不回,改走 PowerShell」—— **不成立**。
2026-09-22 复测:确实出现了 `dirname / head / grep / ls / which: command not found`(`shell-runtime-bash-env.sh: line 3: dirname: command not found` + `bash.exe: line 1: head: command not found`),而
```bash
export PATH="/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/usr/bin:/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/bin:$PATH"
```
**一次就恢复**(`dir`/`head`/`grep`/`sed`/`ssh` 全部可用)。
🔴 **关键补充**:工具每次调用**不共享 shell 状态** ⇒ 这个 `export` **必须前置在每一条** bash 命令里,否则下一条又会 127。PowerShell 只在 export 也救不回时才需要(本机至今未复现该情形)。
**只读共享层的正确布局**(B 档 D-i = 只留最新一版):
- 目录 = **扁平化包名**(`sanitizeFileName`,`@scope/name` → `_scope_name`),**⛔ 路径里不带版本号** —— 带版本号则每次替换都让用户 profile 里写死的 `file:` **悬空**。
- `file:` 引用 = `file:<bundledRoot>/<flatName>`(pnpm 对目录型 `file:` 建软链 ⇒ **实体一份、链接多份**,天然满足"不复制")。
- 版本/哈希/备份路径落在 `<bundledRoot>/.manifest.json`;`pnpm` 的 store 指到共享层内部(`--config.store-dir=<bundledRoot>/.store`)⇒ 同一文件系统、硬链成立、不依赖任何用户 HOME。
- 替换用 `rename` 换目录(先 `.trash-*` 再进),保证引用路径不变。
---
### 22.7 🔴 PG **`CREATE DATABASE` 不能在函数内执行** —— 「`SECURITY DEFINER` 建库」这条路**根本不存在**(2026-09-23 实测)
**结论**:PG 对 `CREATE DATABASE` 有**两层独立禁止**,`SECURITY DEFINER` 也绕不过:
```
ERROR: CREATE DATABASE cannot run inside a transaction block (PL/pgSQL 函数体恒在事务内)
ERROR: CREATE DATABASE cannot be executed from a function (PG 显式禁止从函数发起)
```
**实测对照**(同一私有 schema、同一 `SECURITY DEFINER` 姿势,只换语句):
| 函数内语句 | 结果 |
|---|---|
| `CREATE SCHEMA` | ✅ 成功 |
| `CREATE ROLE` | ✅ 成功 |
| **`CREATE DATABASE`** | ❌ **内核拒绝** |
⇒ 凡"**想靠 DB 侧函数封住建库权限、又不想给角色 `CREATEDB`**"的方案,**在 PG 上不可实现**,不要在方案阶段就写进设计(本例已因此白做一版 Runbook)。可行路径只剩:① 给角色 `CREATEDB`(权限扩大,白名单只能靠代码自觉)② 平台侧持超管凭据直连、**单语句** `-c` 建库(免事务,实测可建可删)。
**旁路一并探明(⛔ 别重复试)**:`dblink` / `postgres_fdw` **均不可用**(`pg_available_extensions` 无条目、调用报 `function does not exist`);本机只装了 `plpgsql`。
⚠️ **两个易错前提**:
- `su postgres -c 'psql …'` **必须先 `cd /tmp`**,否则 `su` 切目录被拒(`could not change directory to "/root"`),报错长得像"连不上 server"。
- 平台的 `dshs` 是 **systemd 服务名**,**不是系统用户**(`su: user dshs does not exist`);但它**以 root 运行**(`systemctl show dshs -p User` ⇒ 空)。
- `dshs` PG 角色在 `dshs` 库 `has_database_privilege(...,'CREATE') = t` ⇒ **建 schema 零扩权**,别误报成需要授权。
**验证姿势**:跑这种"函数能不能干某事"的探测时,**⛔ 别把 SQL 写在 `ssh 'bash -s' <<'REMOTE'` 里用 `\$\$` 转义** —— `$$` 会被 shell 展开成 PID,报 `syntax error at or near "1057744"`(本轮踩过)。✅ 本地 Write 一个 `.sql` 文件 → `ssh 'cat > /tmp/x.sql' < 本地文件` → 远端 `psql -f /tmp/x.sql`。
---
## 23. 🔴 真机取证(建库这类**不可回滚**动作)的三个坑(2026-09-23 实测 · 插件投放与分库线第 4 棒)
**背景**:本棒要在 47 上真建一个测试库、核 `pg_database` × 台账双写一致,再清理。
三个坑全部命中,且**前两个会直接产出错误结论或残留**。
### 23.1 🔴 清理必须写进 `catch`(只清成功路径 = 留残留)
首跑探针**失败**在台账步骤(47 控制面停在迁移 v13,无 v15 两张表)⇒ 异常抛出、
后面的清理块**根本没执行** ⇒ 测试库 `dshs_pl_bselftest4` **留在了生产 PG 上**。
第二跑把 `cleanup()` 同时放进 `catch` 才回到零残留(`pluginDbs: []`)。
**判据**:凡是「建了东西要删」的取证脚本,`cleanup()` 必须在 `finally` 或 `catch` 里被调用;
⛔ 只在 `try` 尾部写 `await cleanup()` 是**假安全**。收工前**独立再查一次**残留(⛔ 不信探针自报的 `cleaned`)。
### 23.2 🔴 `source /etc/dshs.env` 取到的 `DSHS_DB_URL` 是**错的**
47 上 `source /etc/dshs.env` 得到的连接串指向 **5432**,而真 PG 在 **15432** ⇒ 探针首跑
`ECONNREFUSED ::1:5432`。**真值只在 drop-in** `/etc/systemd/system/dshs.service.d/cluster.conf`
(`Environment="DSHS_DB_URL=…:15432/dshs"`)。
**正确取数**:`grep -h '^Environment="DSHS_DB_URL=' <drop-in> | sed -E 's/^Environment="(.*)"$//' | sed -E 's/^DSHS_DB_URL=//'`
⇒ 与既有口径一致并**再次实证**:「**drop-in 才是唯一载体**」。⚠️ 别用 `/proc/<pid>/environ` 之外
的"读配置文件"姿势 —— `/etc/dshs.env` 里**压根没有**这个键(首跑我还被一个残留的 shell 变量误导过一次)。
### 23.3 ⚠️ 47 的部署根 = **`/opt/dshs`**,⛔ 不是 `/opt/dsh`
`systemctl cat dshs` ⇒ `WorkingDirectory=/opt/dshs`、`ExecStart=/usr/local/bin/node lib/cli.js`。
`/opt/dsh` 只放 `artifacts/` 与 `backups/`。要跑真实编译产物做取证时,把 `lib/` 拷到 `/tmp/xxx/pd/`
再 `sed` 改 import 路径 —— 并在 `/tmp/xxx` 下 `ln -s /opt/dshs/node_modules node_modules`
(**ESM 不认 `NODE_PATH`** ⇒ 只设环境变量会 `ERR_MODULE_NOT_FOUND: Cannot find package 'pg'`)。
### 23.4 附带一条:`CREATE DATABASE` 的姿势(B 档落地)
平台侧走 `pool.query()` 的**隐式自动提交**(单语句、⛔ 不 `BEGIN`)。
`outcome='exists'`(PG `42P04 duplicate_database`)是**幂等成功**,⛔ 不是失败 ——
把它记成 `ok:false` 会让"重复点建库"被误报成故障。权限不足 `42501` ⇒ 503 `PG_CREATEDB_MISSING`。
## 24. 🔴 业务插件**投放**(候选池 → 共享层)与三条伴随坑(2026-09-23 实测 · IM 线第 14 棒)
### 24.1 「候选池」≠「已发布」——`link:` 协议下这是**两步**
装配协议 = `link:`(2026-09-23 拍板)⇒ profile 里写的是 `link:/var/lib/dshs/bundled-plugins/<flat>`,
**目标是共享层目录**。因此投放 = **① `POST /api/plugins/business`(入池)+ ② `POST /api/plugins/business/share`(发布到共享层)**,
缺 ② ⇒ 用户点启用必撞 `plugin_bundle_missing`(`business-plugins.ts` 的 `friendlyError` 把它翻成
「该插件尚未发布到共享包库,请先联系管理员完成发布」)⇒ 那是**半成品投放**。
一条完整的投放命令链(47 就地;admin 途径,⛔ 不铺 profile):
```bash
# 0) 打包(package/ 布局;findPackageJson 递归 ≤3 层,npm pack 同形)
mkdir -p /tmp/pack/package && cp -r <插件目录>/{package.json,cordis.patch.yml,lib} /tmp/pack/package/ \
&& (cd /tmp/pack && tar -czf /tmp/out.tgz package)
# 1) 入池(body = {filename, file:<base64 tgz>},cookie 名 sid,端口 3080,需 -H 'Host: alotbuy.com')
TK=$(ssh bt-server 'node /opt/dshs/mksess.cjs admin') # 临时 admin 会话,TTL 600 s,用完必删
# 2) 发布(带 scope 的包名走 body 形态,`:id` 形态吃不下 '/')
curl -X POST -H "Host: alotbuy.com" -H "Cookie: sid=$TK" -H 'Content-Type: application/json' \
-d '{"id":"@scope/name","confirm":true}' http://127.0.0.1:3080/api/plugins/business/share
```
判据:入池回 `replaced/trustedOverride/compat.level`;发布回 `action:"created"` + `fileRef` + `fileCount`;
两处都写 `audit_log`(`upload_business_plugin` / `share_business_plugin`)。回滚素材自动落
`/opt/dsh/backups/plugins/<flat>/<ver>.tgz`。
⚠️ **池目录真身 = `/var/lib/dshs/business-plugins`、共享层 = `/var/lib/dshs/bundled-plugins`**
(`dataRoot` 实际是 `/var/lib/dshs`,⛔ 不是 `/opt/dsh`)—— 别按 `dataRoot=/opt/dsh` 去猜路径。
### 24.2 🔴 沙箱里**看不见共享层** ⇒ `link:` 软链悬空(47 实测)
实例的 bwrap 命令行里必须有 `--ro-bind-try /var/lib/dshs/bundled-plugins` 才读得到 `link:` 的目标。
**实测 47 上在跑的实例缺这一项**(scope 启动于 09-21,早于该绑定上线;代码 `orchestrator.ts` 已含该绑定,
106 在跑的实例 cmdline 里有)。判据:
```bash
systemctl show dsh-<uid>-<hash>.scope -p ExecStart | grep -c 'bundled-plugins' # 0 = 缺
```
⇒ **凡改了实例可见的绑定,必须让实例"走一次重启"才生效**(插件启用动作自带重启,所以通常不用额外动作)。
⇒ 判据写法:**光看共享层目录存在不够,要同时确认实例沙箱 cmdline 里绑了它**。
### 24.3 🔴 **Windows 上往文本文件"追加"必须用二进制模式**(差点污染文档库行尾)
Python 文本模式 `open(p, 'a', encoding='utf-8')` 在 Windows 会把 `\n` **静默翻成 `\r\n`**
(`newline=None` ⇒ 按 `os.linesep` 翻译)⇒ 往文档库 md 追加一段,就把那一段变成 CRLF,与仓库 LF 规范混行。
本棒实测:E 单被追加出 **60 个 CR**、当日日志 **17 个 CR**。
- ✅ **判据(字节级,⛔ 别信肉眼)**:追加**前后**各算一次 `open(p,'rb').read().count(b'\r')` —— **必须不变**。
- ✅ **做法**:`open(p, 'ab').write(text.encode('utf-8'))`;或先把要追加的内容写成独立 `.md`(Write 工具走 LF),再二进制追加。
- ✅ **修法(只回滚自己那一段)**:定位**自己块的首个 CR 行**(`min(i+1 for i,l in enumerate(parts) if l.endswith(b'\r'))`),
**断言它 ≥ 我的起始行**,再从那里起把行尾 `\r` 剥掉。⛔ **不做全文件 CRLF↔LF 批量转换**(用户级记忆硬禁令)。
- ⚠️ 陷阱:用文本模式**读**做差分算字节数会被骗(读取时 `\r\n` 已被归一成 `\n`)⇒ 前后字节差会"恰好等于"源文件长度,
让你误判"没加 CR"。**一律走 `rb` 算。**
### 24.4 🔴 协议类取证:**IM 链路服务端零日志**
`src/im/**` / `src/web/routes/im.ts` / `src/supervisor/proxy.ts` 三处 `process.stderr` / `console.*`
**零命中**;`reject()`(`ws.ts:227`)只回一帧 `error` 再 `destroy()`,**不落日志**。
⇒ `journalctl -u dshs | grep bad-op` **恒空**,而"空"会被读成"没发生"(同类坑 PB §21/§23 已记)。
⇒ 协议/帧类判据**只能在客户端取**:DevTools → Network → WS → `/api/im/ws` → Messages 看帧流水
(出站只应出现 `{"op":"subscribe"|"ping"|"resume"}`;入站首帧 `{"op":"hello"}`;不出现 `{"op":"error","error":"bad-op"}`)。
### 24.5 ⚠️ 本机 `tmp/claim_lock.py` 的 `ME` 是**写死的** ⇒ `--release-exec` 会报「非本人持有」
`tmp/claim_lock.py` 里 `env["ME"] = "aliyun-dsh-server-IM架构对齐"`(硬编码)⇒ 用它放锁时,
`handoff-guard.sh` 的 `_my_owner()` 拿到的会话名与 OWNER 文件对不上 ⇒ 回 **「· 无可释放的锁(或非本人持有)」**
(**rc 仍 = 0** ⇒ 极易被当成"已释放")。本棒实测踩到一次。
- ✅ **判据**:放锁后必须复核 **`交接单/.locks/` 为空**(`ls -la`),⛔ 别只看返回文案。
- ✅ **放锁姿势**(绕开该脚本):直接给足 `OWNER=` 与 `ME=` 两个 env:
```bash
export PATH="/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/usr/bin:/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/bin:$PATH"
cd D:/github/dsh_shenxian/dsh-server-docs
OWNER="<会话名>" ME="<会话名>" bash scripts/handoff-guard.sh --release-exec "<会话名>" # ⇒ ✓ 已释放域锁
```
⚠️ 该脚本**仅用于抢锁**没问题(抢锁不看 ME 对不上);放锁一律按上面来。
🗒 待办(⛔ 超出本棒范围,未改):把 `claim_lock.py` 的 `ME` 改成从 argv 的 `--claim-exec/--release-exec` 派生。
### 24.4 🔴 反过来的一种更坏情形:**不设 `ME` 时 `--release-exec` = 清空所有锁**(2026-09-23 21:1x 实测 · 插件投放线即席应答棒)
`handoff-guard.sh:250-262` 的 `_who="${ME:-}"`,随后判 `if [ -z "$_who" ] || [ "$o" = "$_who" ]` ⇒ **`ME` 为空时条件恒真 ⇒ 遍历 `交接单/.locks/*` 全删**。本次因此**误删了别的线(IM 线第 15 棒)正在持有的域锁** —— 违反 R9 精神(锁只能由持有者自己放)。
- 🔴 **两种坏法不是一回事**:`ME` **写错** ⇒ 报「无可释放的锁」**rc 仍 0**(§24 上文,假绿);`ME` **没设** ⇒ **静默全删**(本次,更坏且不可逆 —— OWNER 文件一起没了,**救不回**)。
- ✅ **判据**:放锁**前**必须确认 `[ -n "$ME" ]`;放锁**后** `ls -la 交接单/.locks/` —— **若为空而你只应放自己那把 ⇒ 已误删**(有别人的锁残留才是正常态)。
- ✅ **姿势**:`ME="<会话名>" bash scripts/handoff-guard.sh --release-exec`(⚠️ 位置参数 `<会话名>` **脚本根本不读**,只当文档看)。
- 🗒 **修法(⛔ 未擅改 —— 机制层需独占 + 当时 IM 线在跑)**:`_who` 为空 ⇒ **直接报错退出**,⛔ 不得进全删分支;顺带把位置参数当第二来源(`_who="${ME:-${2:-}}"`,两者都空即拒)。
- 💡 **事故处置(本次采用)**:⛔ 不能"替别人重造一把同名锁"(名字对、`session_id` 对不上 ⇒ 那个棒下次抢锁会把自己挡在门外);正确处置 = **如实上报 + 让该线收口时自行核对/重领**。