Files

878 lines
77 KiB
Markdown
Raw Permalink Normal View History

# 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\AIProject\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\AIProject\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` 对不上 ⇒ 那个棒下次抢锁会把自己挡在门外);正确处置 = **如实上报 + 让该线收口时自行核对/重领**。