201 lines
17 KiB
Markdown
201 lines
17 KiB
Markdown
# 业务插件故障(②)
|
||||
|
|
|
|||
|
|
> **归属**:技能 `dsh-diagnose` · 详情档(主干 `../SKILL.md` §2)
|
|||
|
|
> **本档覆盖**:原技能 `dsh-plugin-diagnose` **全文**
|
|||
|
|
> **原行段**:原 `SKILL.md` 全文 186 行,其中 frontmatter 占前 8 行已剥离 ⇒ 本档承载第 9–186 行
|
|||
|
|
> **搬运方式**:**逐行未改**(⛔ 未删任何判据 / 命令 / 事故事实)
|
|||
|
|
> ⚠️ 本技能已由 `dsh-diagnose` **合并**,原名 `dsh-plugin-diagnose` 退役 ⇒ 见到该名按本档读。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
|
|||
|
|
# DSH 业务插件故障诊断
|
|||
|
|
|
|||
|
|
> 适用对象:**候选池里投放的业务插件**(`dsh-univer-office`、`dsh-plugin-mcn-suite`、`@dsh-local/*` 等)。
|
|||
|
|
> 实例本身的故障(打不开 / OOM / 崩溃重启)用 `dsh-instance-diagnose`,不是本技能。
|
|||
|
|
|
|||
|
|
## 0. 第一原则:**静默失败要当默认假设**
|
|||
|
|
|
|||
|
|
业务插件最容易「坏得没声音」:host 工具照跑、日志干净、界面就是没有东西。
|
|||
|
|
所以排查顺序永远是「**先证明某一层到底有没有活着**」,而不是先读代码猜。
|
|||
|
|
|
|||
|
|
## 1. 三层归属(先定层,再动手)
|
|||
|
|
|
|||
|
|
| 层 | 怎么判「它活着」 | 死了什么样 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **host 半边**(`lib/index.js`) | 插件的 DSH 工具能调通(`univer_*` / 业务工具返回 `ok:true`) | 工具直接报「未知工具」 |
|
|||
|
|
| **client 半边**(`lib/client.js`) | 页面 `__DSH_BOOT__` 里**有**该插件行,且槽位探针能看到它的占位者 | **有无都没痕迹**:无卡片/无 dock/无服务、无报错 |
|
|||
|
|
| **网关 / 原生绑定** | 网关进程在、socket 能连、`/[健康路径]` 200 | 进程崩、连接被 reset、`.node` 加载报错 |
|
|||
|
|
|
|||
|
|
## 2. 客户端半边**静默挂死**(最隐蔽的一类,2026-09-13 实测)
|
|||
|
|
|
|||
|
|
**症状**:host 工具全好,浏览器里**一个 UI 元素都没有**,Console 无报错。
|
|||
|
|
|
|||
|
|
**机理**:dsh 客户端加载器(`@deepseek-ai/dsh-client-modules`)对每个插件行解析 `package.json` 的
|
|||
|
|
`dsh.client` → `inject`(**包名**列表);**cordis 的 `inject waiting`** 决定 fiber 何时 `apply()`。
|
|||
|
|
⇒ **只要有一条 inject 永远不可满足,`apply()` 永不执行**(连注册都没有,静默)。
|
|||
|
|
|
|||
|
|
**判据:`inject` 差集**(一把尺子,通用):
|
|||
|
|
|
|||
|
|
1. 取实例页面(**不碰用户浏览器**,用临时会话走平台代理):
|
|||
|
|
```bash
|
|||
|
|
SID=$(node /opt/dshs/mksess-guest.cjs) # 需对应用户;用完必须删会话
|
|||
|
|
curl -s -H "Host: <用户名>.ai1net.com" -H "Cookie: sid=$SID" http://127.0.0.1:3080/ -o page.html
|
|||
|
|
```
|
|||
|
|
2. 从页面里取**实际下发的客户端插件集合**(权威清单,别用 manifest 正则——会漏行):
|
|||
|
|
```python
|
|||
|
|
import re; s=open('page.html',encoding='utf-8',errors='ignore').read()
|
|||
|
|
served={p.split('/client.js')[0] for u in re.findall(r'/plugins/\?\?[^"\'&]+',s)
|
|||
|
|
for p in u.split('??',1)[1].split(',')}
|
|||
|
|
```
|
|||
|
|
3. 解析 `__DSH_BOOT__` 里目标插件的行 → 取它的 `inject` → **求差集**:
|
|||
|
|
```python
|
|||
|
|
rows=re.findall(r'\{"id":"([^"]+)","url":"[^"]*","rev":"[^"]*","inject":\[(.*?)\]\}', s)
|
|||
|
|
need=re.findall(r'"([^"]+)"', dict(rows)['<插件包名>'])
|
|||
|
|
print([x for x in need if x not in served]) # 非空 = 该客户端 fiber 永久挂起
|
|||
|
|
```
|
|||
|
|
4. **差集非空 = 确诊**。两个常见根因:
|
|||
|
|
- **inject 了被平台角色补丁禁用的官方包**。平台对普通用户禁:
|
|||
|
|
`@deepseek-ai/dsh-client-ui-settings-models` / `-settings-plugins` / `-settings-plugin-inventory` /
|
|||
|
|
`@deepseek-ai/dsh-client-ui-cordis` / `dsh-client-hmr` / `dsh-host-directory-picker-auto`
|
|||
|
|
(`ensure-role-profile-patch.cjs`,档案 15;**admin profile 不禁**——A/B 对照一眼能看出来)。
|
|||
|
|
- **inject 了根本不存在的包名**(如 `@deepseek-ai/dsh-client-runtime`)。
|
|||
|
|
⇒ 直接 `ls /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/ | grep <名字>` 核一下。
|
|||
|
|
5. **修法**:从插件 `package.json` 的 `dsh.client.inject` **移除**该包(或改成真实存在的)。
|
|||
|
|
⚠️ **别混两个 inject**:`package.json` 的 `dsh.client.inject` 是**包名**(模块级依赖);
|
|||
|
|
客户端源码里的 `export const inject = ['slots','locale','conversation']` 是**服务名**。前者不可满足会**整体不 apply**,后者只影响个别 `ctx.inject` 块。
|
|||
|
|
|
|||
|
|
**第二类(2026-09-20 实测):组件挂上了、点击却「没反应」—— 外部契约失效**(与上面同族,但更难看出)
|
|||
|
|
|
|||
|
|
- 症状:侧栏入口**看得见**、Console 无报错,点下去 **UI 毫无变化**(不是"没渲染",是"面板宿主找不到")。
|
|||
|
|
- 真因:客户端靠 **DOM 契约(槽位名)** 找宿主 ⇒ dsh 升级**改了槽位名**后 `querySelector` 恒 null。
|
|||
|
|
实测(0.1.5-rc.1):`conversation` → **`main.conversation`**;`document.querySelector('[data-slot="conversation"]')` 恒 null
|
|||
|
|
⇒ 判据 = `!!document.querySelector('[data-mcn-panel]')` 为 **false**(同时 `#/mcn` 的 hash 已变、入口高亮已生效)。
|
|||
|
|
- 同族还有 **官方 API 形态变了**:`ctx.locale.getLocale()` 由「返回 id 字符串」变成「返回**快照对象** `{active,…}`」
|
|||
|
|
⇒ `String(obj)` 恒为 `"[object Object]"` ⇒ 受控 `<select>` 只显示第一项 ⇒ 看着像"切语言没反应"(其实界面语言可能是对的)。
|
|||
|
|
判据:`document.documentElement.lang` 与控件 `value` **不一致**。
|
|||
|
|
- 取证姿势(浏览器实测 · 平台实例):`mksess.cjs` 造临时会话 → `agent-browser`。⚠️ **`set headers` 注 Cookie 无效**,
|
|||
|
|
改用 `open https://ai1net.com/login.html` + `eval document.cookie='sid=…; domain=.ai1net.com; path=/'` 再 `open` 实例子域;
|
|||
|
|
⚠️ 前台会被沙箱 SIGTERM ⇒ 先 `run_in_background` 起一次守护,后续命令加 `> 文件 2>&1`(**不走管道**)即可前台跑。用完**删会话**。
|
|||
|
|
- 详版 ⇒ **`PLAYBOOK-实例与插件坑 §20`** / 档案 **`04-调整方案/143-MCN工作台入口失效与语言切换显示修复.md`**。
|
|||
|
|
|
|||
|
|
**预期管理(必须一起告知用户)**:
|
|||
|
|
- 插件激活**之前**产生的**旧回合不会回溯渲染**(预览卡片由 conversation turn 定义驱动)⇒ 要**新跑一轮**才看得见。
|
|||
|
|
- 槽位探针里 `active:false` 常常**不是故障**:很多组件在「没内容可显示」时 `return null`
|
|||
|
|
(如 univer dock:`if (operation.worktreeId === null) return null`),同槽里恒有内容的项才是 `true`。
|
|||
|
|
- 某类槽(chain 类,如 `conversation.chat.turnTail`)的探针输出**不带名称字段** ⇒ 「按名字找不到」是**探针局限**;
|
|||
|
|
改用**注册时写的常量**(如 `priority: -10`)去对号。
|
|||
|
|
- **不是所有插件都注册 `tool.*.toolview`** ⇒ 「工具卡片没变样」≠ 插件坏了。
|
|||
|
|
|
|||
|
|
## 2.5 host 半边「桥接类」插件:**连不上 / 静默无限重连**(2026-09-21 实测 · carbon 插件线)
|
|||
|
|
|
|||
|
|
**症状**:实例起得来、`dsh web:` 正常、日志干净,但**会话里一个桥接工具都没有**(如 `mcp__*`)。
|
|||
|
|
这类插件(MCP 桥 / 外部服务代理 / 远程 API 插件)的 host 半边**只在连上之后才注册工具**
|
|||
|
|
⇒ 「连不上」的表现与「插件压根没装」**完全一样**。
|
|||
|
|
|
|||
|
|
⚠️ **先破一个假象**:内核 cordis 的默认日志 exporter **只写内存环形缓冲、不落 stdout**
|
|||
|
|
⇒ 内核插件(含 `@deepseek-ai/dsh-mcp-client`)的报错在实例日志里**看不见**。
|
|||
|
|
第一件事就是把日志挂一份出来(公开 API):
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
ctx.logger.exporter({ colors: 0, levels: { default: 3 }, export: (m) =>
|
|||
|
|
process.stdout.write(`[log] ${m.type} ${m.name}: ${(m.args || []).map(String).join(" ")}\n`) });
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
挂上后立刻能看到 `warn mcp-client: connection attempt failed: …`(不挂就永远在猜)。
|
|||
|
|
|
|||
|
|
**诊断配方(四层,逐层排除,⛔ 别跳步)**
|
|||
|
|
|
|||
|
|
| # | 尺子 | 判读 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 1 | **上游直连**:官方 SDK / curl 直打端点 | 通 ⇒ 上游没问题,问题在 dsh 侧 |
|
|||
|
|
| 2 | **实例进程内出站**:插件里 `fetch` 打同一 URL | 通 ⇒ 不是沙箱 / nft / 代理拦;不通 ⇒ 查实例 egress 规则 |
|
|||
|
|
| 3 | **出站抓包**:包一层 `globalThis.fetch`,打 `method / url / rpc / status`,失败时打**实发 header**(秘密只留尾 5 位) | 🔴 关键:能看出「**从哪一代请求开始丢凭据**」 |
|
|||
|
|
| 4 | **硬编码对照**:把秘密**直接写进配置**(仅测试用) | 立刻全绿 ⇒ **病根 = 凭据交付通道**,与上游 / 协议 / 网络无关 |
|
|||
|
|
|
|||
|
|
**本线实测真因(值得背下来)**:内核 `@deepseek-ai/dsh-mcp-client` 建传输时用
|
|||
|
|
`{ requestInit: { headers: config.headers } }` —— **按引用捕获 headers**;且每代重连都从
|
|||
|
|
**同一个 config 对象**重建 ⇒ **第 1 代能带上运行期注入的凭据,第 2 代起每次 `initialize` 都 401**
|
|||
|
|
(抓包实发 header 只剩 `accept` / `content-type`)⇒ 无限退避重试、工具永远挂不上。
|
|||
|
|
「原地改内存 header」与「每周期重新注入」两种修补**都不收敛**
|
|||
|
|
⇒ 结论:**插件自持连接**(自己用 `fetch` 实现 streamable-HTTP:凭据只留内存、每请求自带 header、失败响亮报出)。
|
|||
|
|
|
|||
|
|
**可见性判据(「注册成功」≠「模型可见」,必须分开对账)**
|
|||
|
|
|
|||
|
|
```js
|
|||
|
|
const list = tools.schemas(); // 省略 scope ⇒ 全局视图
|
|||
|
|
const set = new Set(list.map((s) => s.name));
|
|||
|
|
const missing = myNames.filter((n) => !set.has(n));
|
|||
|
|
console.log(`schemas()=${list.length} 缺=${missing.length} get(首个)=${!!tools.get(myNames[0])}`);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- ⛔ **读法陷阱**:⛔ 别用 `Object.getOwnPropertyNames(tools.layers.global.tools.data)` 数工具 ——
|
|||
|
|
`new Map()` 的属性名是**空数组**,会把「有 N 个工具」误读成 `size=0`(本线因此误判过一轮)。
|
|||
|
|
必须**先判 `instanceof Map`** 再读 `size`。
|
|||
|
|
- `register()` 返回 disposer 只说明「调用没抛错」;**能不能被模型看见**要用上面这把尺子另证。
|
|||
|
|
|
|||
|
|
## 3. 原生绑定 / glibc 门禁
|
|||
|
|
|
|||
|
|
**第一步永远先直测**(别读代码猜):
|
|||
|
|
```bash
|
|||
|
|
ldd --version | head -1 # 宿主 glibc(本平台 = 2.32,Alibaba Cloud Linux 3)
|
|||
|
|
node -e "try{require('<那个 .node>');console.log('OK')}catch(e){console.log(e.message.split('\n')[0])}"
|
|||
|
|
```
|
|||
|
|
**两类绑定,对策不同**:
|
|||
|
|
|
|||
|
|
| 类型 | 例子 | 对策 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **有 JS/上游回退开关** | `engine-formula-rust-binding`(`useRustEngine:false` 走 JS 引擎) | **资产侧垫片**:包一层同名导出,**绑定装不上才**强制关掉(可用则原样交上游 ⇒ 宿主升级 glibc 后自动恢复) |
|
|||
|
|
| **无回退** | `exchange-node-binding`(Office 导入导出) | **只能在宿主/镜像层解决**(换 glibc ≥ 2.35 的基底)⇒ 上报用户决策,别硬凑 |
|
|||
|
|
|
|||
|
|
**关键警示(2026-09-13 踩过)**:
|
|||
|
|
- 同一条 glibc 门禁可能被**多个进程**撞到 —— 同一插件在 **worker** 与 **gateway** 里可能**都要**建投影;
|
|||
|
|
**只给 worker 打垫片不够**:网关崩掉的表现是「**写入报错但数据已落盘**」(提交时崩 → 客户端读不到响应 → 连接 reset ⇒ 假错误 ⇒ 有重复写入风险)。
|
|||
|
|
- 打垫片要**同时**处理 ESM(`import.meta.url` 可用)与 **CJS**(esbuild 把 `import.meta` 降级成 `{}` ⇒
|
|||
|
|
`createRequire(undefined)` **启动即崩**);CJS 产物里让垫片显式 import 该包的 **CJS 入口**。
|
|||
|
|
|
|||
|
|
## 4. 「改了却没生效」的四类(按可能性排序)
|
|||
|
|
|
|||
|
|
1. **打包顺序把垫片废掉**:模块级 `const` 被降级为 `var` 整体提升 ⇒ 垫片跑到时是 `undefined`,
|
|||
|
|
比较恒 false ⇒ **静默回退原实现**。判据:在**产物**里插一行探针打印该常量。
|
|||
|
|
**修法**:垫片内用**函数内字面量**,不依赖任何模块级常量。
|
|||
|
|
2. **插件没真正换上新包**:`mine/apply` 对**已启用**插件是 **noop** ⇒ 必须**停用 → 启用**两步;
|
|||
|
|
任务终态是 **`success`**(不是 `done`,轮询别只判 done,否则空转)。改完**核 md5**(实装产物 == 你构建的)。
|
|||
|
|
3. **客户端包按 `rev` 缓存**:客户端半边改动后**必须硬刷新**页面;顺带记住**硬刷新会取消在途的
|
|||
|
|
`platform: client` 工具查询**(由页面回答),而 **host 侧调用不受影响** —— 别把它当链路故障。
|
|||
|
|
4. **同 `version` 号不会被重装**:改完 `lib/` 却忘了升 `package.json` 的 `version` ⇒
|
|||
|
|
`dsh plugin add file:<tgz>` 之后跑的**还是旧代码**(本线因此被误导数轮排查)。
|
|||
|
|
判据 = 装完立刻做**内容门禁**:`grep -q "<新符号>" <profile>/node_modules/<pkg>/lib/index.js`,
|
|||
|
|
未命中就先 `rm -rf` 该包目录再装。⇒ **改代码必升 version**(0.1.0→0.1.1→0.1.2 …)。
|
|||
|
|
|
|||
|
|
## 5. 验证纪律(每条结论都要有可复现的命令)
|
|||
|
|
|
|||
|
|
- **结构自检 ≠ 能打开**:`zipfile.testzip()` 只证明「能解压」。文档类产物要上**严格解析器**
|
|||
|
|
(`python-docx` / `openpyxl` / `python-pptx`;装进隔离 venv)。本次正是靠它抓到 `w:tbl` 缺必需子元素 `w:tblGrid`。
|
|||
|
|
- **改完必须在真机跑一次**,并尽量用**租户 uid**(`setpriv --reuid=<uid>`)+ 真实 socket/真实数据。
|
|||
|
|
- 找不到「日志」时别下结论:业务插件的 stdout **不落 journald**,实例 journal 只有 systemd 启停两行。
|
|||
|
|
- **`grep` 会骗人**:esbuild 的 CJS 导出用 getter(`__toCommonJS` / `apply: () => apply`),
|
|||
|
|
`grep "exports.apply"` 会给你**假阴性** —— 要按打包器形态去找。
|
|||
|
|
|
|||
|
|
## 6. 平台侧事实(省得反复查)
|
|||
|
|
|
|||
|
|
- 插件投放:admin `POST /api/plugins/business`(`{filename, file:<base64>, trust?}`)→ 池 `/var/lib/dshs/business-plugins/`;
|
|||
|
|
再 `mine/apply` 启用。临时会话:`node /opt/dshs/mksess{,-guest}.cjs`,**用完必须**
|
|||
|
|
`DELETE FROM sessions WHERE user_agent='poc-curl2'`。
|
|||
|
|
- 技能是**显式清单**注册(`src/host/skills/plugin.ts` 的 `DEFINITIONS`),**不是扫目录** ⇒ 加技能要**同时**改清单并重建 `lib`。
|
|||
|
|
- 起自带网关做复现时:`NODE_PATH` 要带 `…/node_modules/.pnpm/node_modules`,否则 `libsql`/`ws` 等外部依赖解析不到,会被误判成插件坏了。
|
|||
|
|
- 平台「我的文件」面板 = **浏览 + 下载,没有预览**;**只有 `.univer` 能在线预览**,Office 文件在浏览器里没有原生渲染。
|
|||
|
|
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 变更历史(原 frontmatter · 逐字保留)
|
|||
|
|
|
|||
|
|
```text
|
|||
|
|
name: dsh-plugin-diagnose
|
|||
|
|
description: DSH 多租户平台(ai1net.com / 47.77.182.89)**业务插件**的故障诊断技能 —— 专治「工具能用但浏览器里什么都没有」「原生绑定装不上」「明明改了却没生效」这类**静默失败**。当出现「插件没 UI / 预览不出现 / 卡片不渲染」「**装了插件但界面没变化 / 界面跟没启用一样**」「导入导出报 GLIBC 版本」「网关崩了但数据其实写进去了」「改了包上传了还是老行为」时触发。⚠️ **与 `dsh-desktop-dev-shell` 的边界**:本技能管**服务器实例里的插件**(投放后没生效 / 启用了没 UI);「**本机**源码构建的壳里挂插件、界面不出现」属对方。核心:先按 **host 半边 / client 半边 / 网关·原生绑定** 三层归属定位,再用「**inject 差集**」「**glibc 直测**」「**产物插探针**」三把尺子取证——**每一次都要有可复现的命令**。
|
|||
|
|
version: 1.0.0
|
|||
|
|
updated_at: 2026-09-22
|
|||
|
|
last_change: 【2026-09-22 按要求统一版本号】frontmatter `version` → `1.0.0`(原 v1.3.0);正文与历史中的版本号为当时记录,未改动。此前 v1.3.0(2026-09-22):补触发词「装了插件但界面没变化 / 界面跟没启用一样」并写明与 `dsh-desktop-dev-shell` 的边界 —— 实测:用户说这句时原描述命不中本技能(只有对方声明了同一说法),服务器实例语境下会加载错。此前 v1.2.0(2026-09-21):新增 **§2.5 host 半边「桥接类」插件:连不上 / 静默无限重连** —— 含「cordis 默认 exporter 不落 stdout,必须先挂 logger.exporter 才能看见内核插件报错」这条前提,四层诊断配方(上游直连 → 实例内 fetch → 出站抓包 → 硬编码对照),以及本线实测真因(内核 mcp-client `requestInit.headers` 按引用捕获 ⇒ 第 1 代带凭据、第 2 代起 401);并补「注册成功 ≠ 模型可见」的公开面回读判据与 `Map` 属性名空数组的读法陷阱;§4 从三类扩为四类(新增「同 version 号不会被重装 ⇒ 改代码必升 version + 内容门禁」)。v1.1.0(2026-09-20):§2 新增「第二类:组件挂上了、点击却没反应」—— dsh 升级改了**外部契约**(槽位名 `conversation`→`main.conversation`;官方 `ctx.locale.getLocale()` 由返回 id 变成返回**快照对象**)导致 `querySelector`/取值静默失效;补浏览器实测姿势(`agent-browser` 的 `set headers` 注 Cookie 无效 + 前台会被沙箱 SIGTERM 两个坑)与判据。v1.0.0:首版。由 2026-09-13 一整天 guest 实例 univer 插件四轮排查沉淀(worker socket 垫片被打包顺序废掉 / 宿主 glibc 2.32 < 绑定要求 2.35 / 网关提交 changeset 也建投影致其崩溃 / client 半边因一条不可满足的 inject 永久挂起),含 4 类真因、3 套取证配方与 8 条实测踩坑。
|
|||
|
|
agent_created: true
|
|||
|
|
```
|