chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进

1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
This commit is contained in:
admin committed 2026-09-15 18:47:13 +08:00
1 parent c70d5d860e
commit 5ad755116e
173 files changed
+27632

No files matched your search

@@ -0,0 +1,555 @@
# dsh(DeepSeek Harness)多用户部署与配置改造方案
| 项 | 值 |
|---|---|
| 版本 | v1.0(2026-09-08) |
| 适用对象 | 服务器 47.77.182.89(root SSH 端口 32022) |
| 软件 | @deepseek-ai/dsh 0.1.2-rc.1(镜像 dsh-web:0.1.2) |
| 状态 | **P0 迁移已执行(2026-09-08 17:30)**;**域名接入完成(2026-09-08 18:00)**;**架构切换为 dshs 多租户 + account 硬隔离(2026-09-08 18:30)** |
| 文档位置 | 本机 `D:\AI技能\aliyun-work-space\dsh-server-docs\`;服务器 `/opt/dsh/docs/` |
---
## 一、背景与目标
dsh Web UI 已单实例部署并跑通(外网 http://47.77.182.89/,模型对话正常)。需要从「单人单实例」升级为「公司多人使用」形态。
**目标(5 项)**:
1. **多用户使用**:多个编导/账号运营各自登录使用;
2. **数据持久化**:会话、配置、凭据、工作区不随容器重建丢失;
3. **跨用户文件安全**:用户之间互相看不到对方的数据与工作区(严格隔离);
4. **便于使用/复制/恢复**:一个用户 = 一个主目录,备份恢复按目录粒度一键完成;
5. **登录方式可扩展**:前期用账号密码(注册审核制),后期可按需接入飞书 OAuth。
---
## 二、现状盘点(2026-09-08 服务器实测)
```
/opt/dsh/
├── backups/20260908_initial/ # 旧备份(已归档)
├── docs/dsh-improvement-plan.md # 本文档
├── users/main/.dsh/ # ★ 迁移后:容器 /root/.dsh(原 data/)
├── users/main/workspaces/ # ★ 迁移后:容器 /root/workspaces(工作区卷,新约定)
├── .env # 系统模型 key(--env-file)
├── backup.sh / restore.sh # 备份恢复脚本
└── dsh-web-0.1.2.tar # 镜像 tar(140MB)
容器 dsh-web:Up,--network host,--restart always
挂载:/opt/dsh/users/main/.dsh → /root/.dsh
/opt/dsh/users/main/workspaces → /root/workspaces
环境:DEEPSEEK_API_KEY(--env-file /opt/dsh/.env)
端口:容器内 127.0.0.1:8900 → nginx 反代 80(Host/Origin 伪装 127.0.0.1)
访问:token 认证(docker logs 取)→ dsh-auth cookie(30 天)
```
已知问题:容器内工作区 `/root/maiyamcn` 已丢失(重建容器后被清掉,注册表残留失效路径)——**工作区必须卷化持久化**。迁移后此问题已解决,但 storages/workspace.json 中旧路径 `/root/maiyamcn` 记录仍在,需在 Web UI 删除该失效工作区入口。
---
## 三、总体架构(目标态)
**核心原则**:
- **一个用户 = 一个主目录 + 一个实例**:`/opt/dsh/users/<uid>/`,备份/复制/恢复只对这一个目录操作;
- **严格隔离**:每用户独立实例(容器),独立 `~/.dsh` 与工作区,互相不可见;
- **系统标准层(公共模型/插件/技能)只读共享**,用户增量放在个人层,同名覆盖系统层(dsh 原生分层机制,无需自研);
- **配置分层**:dsh 插件 bundle → profile 层 → home 级用户覆盖层 → `--patch`,天然支持「公司统一 + 个人覆盖」。
### 目录布局(目标态)
```
/opt/dsh/
├── .env # 系统级模型 key(DEEPSEEK_API_KEY),容器 --env-file 注入
├── std/ # ★ 系统标准层(只读,管理员维护)
│ ├── agents/skills/ # → 容器 /root/.agents/skills(rank400 兜底标准技能库)
│ │ └── mcn-video-script/ # 公司标准技能(SKILL.md + references,只读)
│ └── profile-skel/ # 新用户 profile 种子(含公共插件 bundles 声明)
├── users/ # ★ 每用户 = 一个主目录(实例隔离边界)
│ ├── main/ # ─ 管理员实例(由现有 data/ 迁移而来)─
│ │ ├── .dsh/ # → 容器 /root/.dsh(DSH_HOME)
│ │ │ ├── profiles/web/
│ │ │ │ ├── dsh.profile # bundles 有序清单(官方 + 公共插件)
│ │ │ │ ├── cordis.patch.yml # profile 层配置
│ │ │ │ └── node_modules/ # 个人插件落点(dsh plugin add,只影响本用户)
│ │ │ ├── cordis.patch.yml # ★ home 级用户覆盖层(优先级高于 profile 层)
│ │ │ ├── .credentials.yaml # ★ 个人模型 key(用户在 Models 页填写)
│ │ │ ├── settings.yaml # 用户全局设置
│ │ │ ├── skills/ # ★ 个人技能 rank300(可覆盖系统标准同名技能)
│ │ │ ├── sessions/ # 会话数据
│ │ │ └── storages/ # 存储数据
│ │ └── workspaces/ # → 容器 /root/workspaces
│ │ ├── maiyamcn/ # 工作区项目(=容器内 workspace 根)
│ │ │ ├── .dsh/skills/ # 项目级技能 rank100(最高优先,随项目分发)
│ │ │ ├── .agents/skills/ # 项目级技能备选 rank200
│ │ │ ├── AGENTS.md # 项目指令
│ │ │ └── ... # 项目文件/素材/脚本
│ │ └── <其他项目>/
│ ├── zhangsan/ # ─ 编导 A(与 main 完全同构)─
│ │ ├── .dsh/
│ │ └── workspaces/
│ └── lisi/ # ─ 编导 B ─
├── backups/ # 备份产物(backup.sh 打包 users/ → 时间戳 tar.gz)
│ └── 20260908_initial/ # 旧备份归档
├── backup.sh # 一键备份
└── restore.sh # 一键恢复
```
### 运行拓扑(多用户)
```
镜像 dsh-web:0.1.2(官方 bundle + 公共插件 + std 标准技能内置/挂载)
├── dsh-web-main --network host → 8900 # 管理员
├── dsh-web-zhangsan --network host → 8901 # 编导 A
└── dsh-web-lisi --network host → 8902 # 编导 B
nginx 外网按子域名/路径分流到 127.0.0.1:890x(沿用现有 Host/Origin 伪装模板)
```
---
## 四、dsh 分层机制要点(方案依据)
dsh 配置与插件天然分层,以下机制是方案成立的前提(无需自研):
1. **配置/插件叠加顺序**(后层覆盖前层):
```
插件 bundle 默认(官方/系统)
→ profile 层 $DSH_HOME/profiles/web/cordis.patch.yml
→ 用户层 $DSH_HOME/cordis.patch.yml
→ 命令行 --patch(最高)
```
2. **SKILL 发现目录**(rank 越小优先级越高,同名高优先覆盖低优先):
| rank | 目录 | 性质 |
|---|---|---|
| 100 | 工作区根 `/.dsh/skills/` | 项目级(随项目分发) |
| 200 | 工作区根 `/.agents/skills/` | 项目级备选 |
| 300 | `~/.dsh/skills/` | 用户个人 |
| 400 | `~/.agents/skills/` | 用户级全局(**系统标准库落点,只读兜底**) |
3. **插件安装**:`dsh plugin --profile <name> add <源>`(npm/GitHub/git/tarball/link),写入该 profile 的 bundles 层与 node_modules,**只影响该用户实例**。
4. **模型 provider**:即插件;key 走 credentials service(UI Models 页 → `.credentials.yaml`)或环境变量(`DEEPSEEK_API_KEY`)。
---
## 五、模型 / 插件 / SKILL 归属矩阵
| 能力 | 公共(系统层,管理员维护) | 个人(用户可改) | 项目(随工作区) |
|---|---|---|---|
| 模型 | `.env` 统一 key + profile 层默认 provider;**用户不自配模型**(见安全限制) | `.credentials.yaml` 个人 key(若开放) | — |
| 插件 | 镜像预置公共插件(官方 bundle 同级,人人可用,不可改) | `profiles/web/node_modules` + bundles 声明(`dsh plugin add`,需网络) | — |
| SKILL | `std/agents/skills`(rank400 只读兜底,**同名可被用户覆盖**) | `~/.dsh/skills`(rank300) | 工作区 `.dsh/skills`(100) / `.agents/skills`(200) |
> 一句话:**公共层只读放低优先,个人层可写放高优先**——公司统一给兜底,个人自配天然覆盖,无需把标准配置复制 N 份。
---
## 六、单实例迁移执行步骤(从当前 data/ 形态 → users/main 形态)
> ⚠️ 以下命令在服务器执行(root)。当前容器 `dsh-web` 以 `/opt/dsh/data` 为 `/root/.dsh`。
```bash
# ===== 1. 停容器 + 重组目录 =====
docker stop dsh-web
mkdir -p /opt/dsh/backups
mv /opt/dsh/backup-20260908 /opt/dsh/backups/20260908_initial # 旧备份归档保留
mkdir -p /opt/dsh/users/main
mv /opt/dsh/data /opt/dsh/users/main/.dsh # data 落入主目录
mkdir -p /opt/dsh/users/main/workspaces
# ===== 2. 固化密钥 + 重建容器(挂载主目录内两点) =====
DK=$(docker inspect dsh-web --format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^DEEPSEEK_API_KEY=' | cut -d= -f2-)
printf 'DEEPSEEK_API_KEY=%s\n' "$DK" > /opt/dsh/.env
chmod 600 /opt/dsh/.env
docker rm dsh-web
docker run -d --name dsh-web --restart always --network host \
-v /opt/dsh/users/main/.dsh:/root/.dsh \
-v /opt/dsh/users/main/workspaces:/root/workspaces \
--env-file /opt/dsh/.env \
dsh-web:0.1.2 dsh web --host 127.0.0.1 --port 8900 --no-open --trusted-host 47.77.182.89
# ===== 3. 生成备份/恢复脚本 =====
cat > /opt/dsh/backup.sh <<'EOF'
#!/bin/bash
# 一键备份:打包 users/ 全部用户主目录 → backups/时间戳.tar.gz
set -euo pipefail
TS=$(date +%Y%m%d_%H%M%S)
OUT=/opt/dsh/backups/dsh-backup-${TS}.tar.gz
cd /opt/dsh
docker stop dsh-web 2>/dev/null || true
tar -czf "$OUT" users
docker start dsh-web 2>/dev/null || true
echo "备份完成: $OUT"
ls -lh "$OUT"
EOF
cat > /opt/dsh/restore.sh <<'EOF'
#!/bin/bash
# 一键恢复:./restore.sh <备份tar.gz>(当前数据先挪走可回滚)
set -euo pipefail
TAR=${1:?用法: ./restore.sh /opt/dsh/backups/dsh-backup-xxx.tar.gz}
test -f "$TAR" || { echo "备份文件不存在: $TAR"; exit 1; }
TS=$(date +%Y%m%d_%H%M%S)
cd /opt/dsh
docker stop dsh-web 2>/dev/null || true
[ -d users ] && mv users users.old-$TS
tar -xzf "$TAR"
docker rm -f dsh-web 2>/dev/null || true
docker run -d --name dsh-web --restart always --network host \
-v /opt/dsh/users/main/.dsh:/root/.dsh \
-v /opt/dsh/users/main/workspaces:/root/workspaces \
--env-file /opt/dsh/.env \
dsh-web:0.1.2 dsh web --host 127.0.0.1 --port 8900 --no-open --trusted-host 47.77.182.89
echo "恢复完成(旧数据在 users.old-$TS,确认无误后可删)"
EOF
chmod +x /opt/dsh/backup.sh /opt/dsh/restore.sh
# ===== 4. 验证 =====
docker ps | grep dsh-web
curl -s -X POST http://127.0.0.1:8900/api/llm/listProviders \
-H 'Content-Type: application/json' \
-d '{"type":"client-request","rpcId":"v1","method":"llm/listProviders","payload":{"args":{}}}'
# 期望 {"ok":true,"value":[{"id":"deepseek-official",...}]}
/opt/dsh/backup.sh
```
---
## 七、备份与恢复语义
- **备份 = 打包 `users/`**(全部用户主目录,一个 tar);`.env`(部署配置)与 `std/`(可再生成)不进备份;
- **恢复 = 解压 + 重建容器**(restore.sh 已固化容器参数);
- **复制/迁移用户**:拷 `/opt/dsh/users/<uid>` 到新机同路径即可;
- 多用户同构扩展:新用户增加 `users/<uid>/`,脚本无需改动(tar 整体 users/)。
---
## 八、多用户扩展(dshs 落地前的过渡方案)
目标:**每用户独立实例 + 严格隔离**。新增一个用户 = 一套同构目录 + 一个容器 + nginx 分流:
```bash
# 以新增 zhangsan 为例(示例脚本 add-user.sh)
UID_NEW=zhangsan
PORT=8901
mkdir -p /opt/dsh/users/${UID_NEW}/.dsh /opt/dsh/users/${UID_NEW}/workspaces
# 从 main 复制 profile 骨架(不含凭据/会话),或从 std/profile-skel 初始化
docker run -d --name dsh-web-${UID_NEW} --restart always --network host \
-v /opt/dsh/users/${UID_NEW}/.dsh:/root/.dsh \
-v /opt/dsh/users/${UID_NEW}/workspaces:/root/workspaces \
--env-file /opt/dsh/.env \
dsh-web:0.1.2 dsh web --host 127.0.0.1 --port ${PORT} --no-open --trusted-host 47.77.182.89
# nginx:新 server/server_name 分流 → 127.0.0.1:${PORT},沿用 Host/Origin 伪装头模板
```
> 说明:web profile 首次启动会自动初始化模板,新用户空 `.dsh` 直接起容器即可;系统标准技能层由 `std/` 挂载提供。
### 认证路线
| 阶段 | 方案 | 说明 |
|---|---|---|
| 当前 | dsh 自带 token(一次性) | 仅单人过渡 |
| 近期 | **dshs 密码制基座**(每用户独立 DSH 实例 + 注册审核) | 多租户严格隔离的正规托管方案 |
| 后期 | **飞书 OAuth 登录** | 替换/并存 dshs 密码认证,预留扩展点 |
> 备选认知:`@xgone/dsh-remote` / `dsh-passwords` 类认证网关是共享单租户,无法满足严格隔离,不采用。
---
## 九、安全边界与已知限制
1. **dsh 禁 `--host 0.0.0.0`**(RCE 防护),容器内只监听 127.0.0.1 → 必须 `--network host` + nginx 反代;
2. **特权 API 强制 loopback-only**(settings/credentials.describe 等),即使 `--trusted-host` 也拒非本机访问 → nginx 必须把 `Host`/`Origin`/`Sec-Fetch-*` 头伪装为 `127.0.0.1:8900`(现有反代模板已含);
3. **Settings/Models 页前端 loopback 检测**:非本机浏览器地址访问时页面禁用(`settings are unavailable in this browser`)→ 外网用户**无法在 UI 自配模型**;普通用户模型一律管理员统一配置;要开放自配需对 web 前端打 `--patch` 放行(待验证项);
4. **插件 = 任意代码执行**:普通用户不开 `dsh plugin add` 权限;公共插件管理员审核后入镜像;
5. **token 认证**:每实例首次启动生成 token,nginx 后建议加一层 Basic Auth 或尽快接 dshs;
6. `.env` 含密钥,权限 600,不进备份包(整机迁移需单独携带或重设)。
---
## 十、待验证项(Open Questions)
| # | 问题 | 影响 | 验证方式 |
|---|---|---|---|
| 1 | `.env` 注入的 `DEEPSEEK_API_KEY` 与用户 credentials 中个人 key 的优先级 | 决定统一 key 是否为硬兜底 | 容器内同时存在时调 `llm/listProviders` 观察 |
| 2 | web profile 空 `.dsh` 首启是否能自动初始化模板(供 add-user 流程) | 新用户目录初始化方式 | 用空目录起测试容器观察 |
| 3 | 前端 loopback 检测能否用 `--patch` 放行 | 用户是否可自配模型 | 社区/源码调研 + 试验 |
| 4 | dshs 对 web Settings 限制的处理方式 | 密码制基座下用户可配置面 | 部署后实测 |
| 5 | 容器内 `dsh plugin add` 走 npmmirror 是否可装 | 用户/管理员装公共插件渠道 | 实测 add 一个测试插件 |
---
## 十一、路线图
- [x] **P0 执行本方案第六节迁移命令**(data → users/main,双卷挂载,脚本就位)— **2026-09-08 17:30 完成**
- [x] **P2 前置调研完成**(2026-09-08):dshs 为多租户首选候选;确认硬前提=域名+通配DNS+通配证书(纯 IP 打不开聊天 UI);服务器 1.8G 内存限 1-3 人规模;**用户决定:解决域名后再做多用户改造**
- [ ] **域名就绪后选型落地**:dshs(模式 A 单机,优先)或自研 Node 网关(备选);新用户按 users/<uid> 同构扩展
- [ ] **P1 镜像 v2**:内置公共插件 + `std/agents/skills` 标准技能库(rank400 层)
- [ ] **P1 系统标准技能库内容确认**(MCN 脚本创作技能等随镜像/`std/` 分发)
- [ ] **P3 飞书 OAuth 登录扩展**(预留接口,按需启用)
- [ ] **P3 编导工作区模板**(新用户自动带账号人设/素材库结构)
### 多用户改造前提条件(用户决策记录 2026-09-08)
1. **域名**:无(仅 IP)。海外服务器解析无需备案;买域名后配 `@` A 记录 + `*` 通配 A 记录 → 47.77.182.89,再签通配证书。域名就绪 = 多用户改造解锁。
2. **用户规模**:1-3 人(现 1.8G 内存可撑,dshs 每用户一个常驻 DSH 子进程)。
3. **候选对比结论**:
- dshs:现成密码注册/审核/隔离/网页桌面/每用户加密 key 库;需宿主机 Node ^22.19 + 全局 dsh(spawn 子进程,非 Docker);数据组织为 `DATA_ROOT/每用户home`,与 users/<uid> 不互通、官方无迁移工具;
- 自研 Node 网关 + Docker 每用户:复用现有 users/<uid> + 镜像,nginx 后加认证/路由层;开发量中等;
- dsh-webui-auth:仅单实例加锁,不做多用户隔离,可作过渡。
---
## 十二、域名接入记录(2026-09-08)
**域名**:`dsh.alotbuy.com`(Cloudflare 代理,海外服务器免备案)
| 项 | 状态 |
|---|---|
| DNS | `dsh.alotbuy.com` → Cloudflare(172.67.194.206 / 104.21.44.42),CF 回源服务器 80 |
| **通配 `*.dsh.alotbuy.com`** | ⚠️ **未配置**——dshs 每用户子域路由的前提,落地前需在 CF 加 `*` A 记录 → 47.77.182.89 |
| HTTPS | Cloudflare 边缘证书(源站无 443,CF SSL 需为 Flexible 模式,实测已通) |
| 源站 nginx | 新建站点 `/www/server/panel/vhost/nginx/dsh.alotbuy.com.conf`(监听 80,反代 127.0.0.1:8900,Host 伪装 + 剥 Origin/Sec-Fetch,含 WebSocket 头) |
| 验证 | token 登录 303→cookie(host-only 绑 dsh.alotbuy.com)→ 首页 200(24KB)→ API `{"ok":true, deepseek-official}` ✅ |
**访问方式**:`https://dsh.alotbuy.com/?token=<token>`(浏览器打开一次即种 cookie,30 天有效)
**踩坑记录**:
- Windows 原生 curl 写 `/tmp/` 路径失败导致 cookie 不落盘 → 必须用 `C:/...` 路径(历史已知坑,本次复现)
- 对根路径发无 cookie 裸 WS Upgrade 返回 502 为 dsh 正常拒绝(IP 站点同样 502),非配置问题
- nginx 新站点需与 47.77.182.89 站同样伪装头(Host 127.0.0.1:8900 + 空 Origin/Sec-Fetch),否则特权 API 403
## 十三、架构切换:容器 → dshs 多租户 + account 硬隔离(2026-09-08 18:30)
**决策**:去掉 Docker 单容器方案,全量迁移到 dshs 进程隔离模式(便于 dsh/插件升级迭代 = 一条 npm 命令);用户确认"立即全量迁移 main" + "account 账号级硬隔离"。
### 最终架构
```
宿主机:Node v22.23.2 + 全局 dsh 0.1.2-rc.1(/usr/local/bin)
dshs(systemd,127.0.0.1:3080,account 模式)
├── /var/lib/dshs/
│ ├── dshs.db # 用户/审核数据
│ ├── secret.key # 每用户 key 库主密钥
│ └── users/<userId>/
│ ├── home/ # DSH_HOME(profiles/sessions/credentials)— 由原 .dsh 迁入
│ ├── ws/ # 工作区(HOME 指向)— 由原 workspaces 迁入
│ └── patches/
nginx:dsh.alotbuy.com + *.dsh.alotbuy.com → 127.0.0.1:3080(端口动态分配由编排器管理)
```
### 关键文件清单
| 文件 | 作用 |
|------|------|
| `/etc/dshs.env` | BASE_DOMAIN=dsh.alotbuy.com / COOKIE_DOMAIN=.dsh.alotbuy.com / ISOLATION_MODE=account / BASE_UID=100000 / DSH_BIN=/usr/local/bin/dsh |
| `/etc/systemd/system/dshs.service` | 编排服务(ExecStart 带 `--db` 指向 dshs.db) |
| `/etc/systemd/system/dsh-provision.path` + `.service` | 监控新用户目录自动建 OS 账号 |
| `/usr/local/bin/provision-new-users.sh` | 建号脚本(用户名截断 20 字符,规避 Linux 32 字符限制;每次 chown 幂等) |
### 已验证
- admin 登录 API ✅(role=admin)
- account 降权:dsh 可跑(0.1.2-rc.1)、读 /root Permission denied、读自己 home 正常 ✅
- nginx 主域/子域 → 3080 路由 ✅(登录门户 HTML 返回)
- 备份/恢复脚本已切换新数据根(backup.sh 打包 /var/lib/dshs + 配置 + systemd 单元)
### ⚠️ 未完成 / 待办
- [x] **HTTPS 通配证书**(2026-09-08 18:40 完成):certbot dns-cloudflare 签 `*.dsh.alotbuy.com`,nginx 443 + HTTP 跳转,全链路登录验证通过
- [ ] **每用户 API Key**:dshs 为每用户独立 key 库(登录后桌面"管理密钥"填),原 .env 统一 key 的模型已不适用(迁移后 admin 需自填 key)
- [ ] 注册普通用户 → 审核 → 桌面/DSH 启动全链路验证(子域 404 需注册后确认)
- [ ] main 旧容器数据 /opt/dsh/users/main 已迁入 admin home,容器已停,可清理
- [ ] 飞书 OAuth 扩展(预留)
### 踩坑记录
1. **db 路径不一致**:bootstrap-admin 写入 `dshs.db`,但服务默认用 `dshs.db`(空)→ systemd ExecStart 必须显式 `--db`
2. **用户名超长**:`dsh-`+uuid=41 字符超 Linux 32 限制 → provision 脚本截断为 `dsh-<uuid去横线前20>`
3. **初始属主不一致**:bootstrap 建目录属主 100001,hash uid 为 114801 → 以 `uid-for-user` 输出为准
4. **中间路径权限**:`/var/lib/dshs` 与 `/users` 需 755(root 700 会挡住降权账号穿透),用户目录本身 700 保持隔离
5. **pkill 自杀坑**:`pkill -f 'lib/cli.js'` 会匹配到远程 shell 自身命令行 → 用 systemctl 管理
## 十四、dsh.alotbuy.com 注册为宝塔面板反代站点(2026-09-08 18:46)
**背景**:手写 conf(`/www/server/panel/vhost/nginx/dsh.alotbuy.com.conf`)在面板数据库无站点记录 → 面板重置/批量管理可能误伤。按用户偏好(能走 MCP 尽量走 MCP)用宝塔 MCP 完成注册。
**执行链(MCP 优先,全流程零中断)**:
1. SSH 备份 + 移走手写 conf:`cp` 到 `/opt/dsh/docs/nginx-dsh.alotbuy.com.conf.bak-20260908`,`mv` 出 include 目录(不 reload,服务无感)
2. MCP `ProxyProjectCreate`:domains=`dsh.alotbuy.com`,proxy_pass=`http://127.0.0.1:3080` → 站点 id=2 入面板 DB
3. MCP `ProxyWriteConfig`:把完整精调 conf(HTTP→HTTPS 跳转 ×2 + 443 主域 SSL + 443 通配子域 SSL)覆盖写回同路径
4. SSH `nginx -t` + reload + 本机 curl 验证
**⚠️ 关键坑**:面板反代项目 conf 写入路径与手写 conf 同名同路径(`/www/server/panel/vhost/nginx/<域名>.conf`)——直接 ProxyProjectCreate 会用面板模板覆盖,丢失 SSL/通配子域/301 等精调内容。**必须"先移走手写 conf → 创建 → ProxyWriteConfig 写回精调版"三步走**。
**验证结果**:
| 检查项 | 结果 |
|--------|------|
| SiteList 面板 DB | 2 站点:47.77.182.89(id=1)+ dsh.alotbuy.com(id=2)✅ |
| nginx -t + reload | OK ✅ |
| https://dsh.alotbuy.com(外网直连源站) | 200 ✅ |
| https://*.dsh.alotbuy.com(通配子域,未注册) | 404(dshs 正常拒绝,代理/SSL 层通)✅ |
| http://dsh.alotbuy.com | 301 → https ✅ |
| conf 保留 | 4 段精调 server 块完整(certbot 证书路径原样)✅ |
**遗留说明**:面板站点列表 SSL 状态显示 `enabled:false`——证书为 certbot 手动签发(面板不托管),面板只做站点/conf 管理;如需面板托管 SSL 需换面板 Let's Encrypt 流程(不建议,会与通配证书冲突)。
## 十五、启动 DSH 报"已有运行中"+ 无法对话 排障(2026-09-08 19:00)
**现象**:admin 登录桌面点"启动 DSH",提示"有运行中的 DSH 请先停止",无跳转,无法对话。
**根因一(P0,已修复):DB uid 与系统账号 uid 错配**
- 主 DB(`/var/lib/dshs/dshs.db`,服务 `--db` 显式指向)users.uid=**100001**(bootstrap 写入 `baseUid+rowid`)
- 但 OS 账号按 `hashUid` 建:`dsh-cce6d1cdb376430480f0` uid=**114801**(早期版本 provision 用 hash;后 provision 改为读 DB uid-for-user,但账号没重建)
- 结果:编排器 spawn 用 DB uid=100001 → `setpriv --reuid 100001` → 子进程以**不存在的 uid** 运行 → 打不开 `700 owner=114801` 的用户 home → `EACCES: mkdir .../home/profiles/web` 秒崩
- 编排器 `spawn` 事件即置 status=running,子进程崩后自动重启循环 → 前端一直显示"运行中"→ 再次点击启动返回 409 already_running(即用户看到的提示)
- **修复**:`UPDATE users SET uid=114801 WHERE id='cce6d1cd-...'`(备份在 `/opt/dsh/docs/dshs.db.bak-20260908-uidfix`)+ `systemctl restart dshs` + provision 脚本 `uid-for-user` 补 `--db /var/lib/dshs/dshs.db`(消除默认库分裂)
**根因二(上游集成缺口,未修复):dsh web 强制浏览器 token,编排器"打开"不带 token**
- dsh web(0.1.2-rc.1,`dsh-client-connection` BrowserAuth)强制:首次访问需 `?token=<launch token>`(进程启动随机、打印到 stdout)换取签名 cookie(绑定 authority=`127.0.0.1:<port>`,30 天)
- 上游 dshs 0.1.0:launch 返回 url **不带 token**,desktop.html "打开 DSH" 直接 `window.open(url)` → 首访 401 `dsh web authentication required`;且端口动态分配,实例重启换端口 → 旧 cookie 失效需重新带 token
- 已实测全链路可行:门户 sid + `?token=` → 303 种 dsh-auth cookie → 双 cookie 访问 `https://admin.dsh.alotbuy.com/` 200(24233B SPA)+ `/api/llm/listProviders` ok:true
- **修复方向(待用户定)**:A. 小改上游——编排器解析子进程 stdout 的 token,launch/status url 自动拼 `?token=`(改 src/supervisor/orchestrator.ts + routes/dsh.ts,tsc 可构建);B. 接受现状——每次启动后手动用 journald 里 token 开一次;C. 等上游修复
**排障经验**:`launch folder` 是相对 **ws/** 根的路径(空串=根);`/api/dsh/status` 返回 url 为子域;子域下所有路径(含 /api/dsh/status)都会被代理到实例,故探测实例要用 `/api/llm/*` 等实例 API;cookie 域 `.dsh.alotbuy.com`(sid)与 dsh-auth 双 cookie 缺一不可。
## 十六、方案 A 落地:launch/status 自动携带 dsh launch token(2026-09-08 19:30)
**决策**:用户选方案 A(小改上游,普通用户无需手动拿 token 即可打开聊天界面)。
**改动(3 文件,已 git commit 29c8907,本地分叉 patch)**:
| 文件 | 改动 |
|------|------|
| `src/supervisor/spawner.ts` | `Instance` 接口加 `launchToken?: string` 字段 |
| `src/supervisor/orchestrator.ts` | ① `trackChild` 监听子进程 stdout,正则 `dsh web: http://127.0.0.1:\d+/\?token=([A-Za-z0-9_-]+)` 解析 token 存入 instance;② 新增 `waitForLaunchToken()`(轮询最多 20s 等 token 出现,崩溃/停止即返回);③ `launch()`/`restartMain()` 返回前等待 token |
| `src/web/routes/dsh.ts` | `dshUrl()` 支持可选 token 参数;launch/restart/status 返回的 `url` 均拼接 `?token=`(status 从 `main.launchToken` 取) |
**验证结果(全链路)**:
| 步骤 | 结果 |
|------|------|
| launch 返回 | `{launchToken, url: https://admin.dsh.alotbuy.com/?token=...}` ✅ |
| 打开带 token url(带 sid,-L 跟随) | 200,24233B SPA,自动 303 种 dsh-auth cookie ✅ |
| 之后裸开 `/` | 200 ✅ |
| `/api/llm/listProviders` | `ok:true, deepseek-official` ✅ |
**构建/部署**:`npm run build`(tsc)+ `systemctl restart dshs`。TS 坑:`RegExp.exec` 返回 `null` 非 `undefined`,判空须 `m !== null && m[1] !== undefined`。
**admin API Key 已配置**:从旧 `/opt/dsh/.env` 取出 `sk-81c...` 经 `/api/me/keys` 注入(name=deepseek-main,AES-GCM 加密入库,自动触发实例重启生效)。**KEY 为每用户独立**——每个用户需在桌面"管理密钥"各自添加;无全局通用 key(原容器 .env 统一 key 模型已废弃)。
## 十七、权限边界实测 + DB 权限安全加固(2026-09-08)
**问题**:admin 登录后能否访问服务器所有目录?AI 对话窗口新建工作空间时能否输入系统根目录?
**两层防护模型(答案分两层)**:
| 层 | 机制 | 结论 |
|----|------|------|
| Web 桌面层 | fs-guard `resolveWithinRoot()`:所有文件浏览/上传/新建工作空间的路径解析被锁在用户自身 home/ws 内,拒绝 `../`、绝对路径、符号链接逃逸 | 输入 `/`、`/root` 等系统路径会被拉回/拒绝;admin 与普通用户同样受限,**不存在"admin 浏览全服务器"入口** |
| AI 进程层 | 每用户 DSH 实例以**降权 uid**(setpriv)运行,该进程可执行 shell/agent 工具,**不受 fs-guard 约束**,能读该 uid 有权限的一切文件 | admin 的实例同样是降权 uid,非 root。实测 uid 114801 边界:`/root`、`/etc/shadow`、`/opt/dsh/.env`、secret.key、dsh db 全部 Permission denied ✅;`/etc/passwd`、`/tmp` 世界可读可写 ✅ |
**结论**:即便 admin 也无法从 AI 对话触达 root-only 敏感文件;真正的服务器管理走 SSH/宝塔 root 通道,与 AI 会话隔离。这是 account 隔离模式的核心价值,无需额外改造。
**⚠️ 实测发现并修复的漏洞:DB 权限 644 → 600**
| 项 | 详情 |
|----|------|
| 漏洞 | `/var/lib/dshs/dshs.db` 权限 644 root——任意降权账号可 `cat` 拖库 |
| 风险 | 库内含全部用户 `pass_hash`(scrypt)+ `api_key_ref`(AES-GCM 密文)+ uid/sid → 离线爆破口令 + 窃取 key 密文 |
| 修复 | `chmod 600 /var/lib/dshs/dshs.db* /var/lib/dshs/dshs.db*` |
| 验证 | 降权账号读 db → Permission denied ✅;服务 600 下正常(portal 401 为未认证正常响应)✅ |
**目录级权限收紧(用户反馈"其他用户也能看就不行",2026-09-08 20:30)**:
普通用户实例同样是降权 uid,盘点发现除主 db 外还有更大泄露面:`/opt/dshs` 整棵树 755、`/opt/dsh/docs/` 内**主库备份**(含全部 pass_hash+key 密文)、`/opt/dsh/backups/` 备份包、`/opt/dsh/users/main` 旧数据残留均 644/755 可读 → AI 会话可拖取。收紧如下:
| 路径 | 改前 | 改后 | 说明 |
|------|------|------|------|
| `/opt/dsh` | 755 | **700** | 覆盖 docs(含 db 备份)、backups、users/main 残留 |
| `/opt/dshs` | 755 | **700** | root 服务运行不受影响(systemd 无 User= 默认 root) |
| `/var/lib/dshs` | 755 | **711** | 保留 o+x:降权进程需穿过根路径进入自己 home,去读防列表泄露 |
| `.../users` | 755 | **711** | 同上;用户目录本身 700 保持隔离 |
**验证(uid 114801 实测)**:读 `/opt/dsh/docs/*.md` → denied ✅;列 `/opt/dshs`、数据根 → denied ✅;**仍可进入自己 `ws/mcnworkspace` ✅**;systemd active、portal 200、admin 实例 `/plugins/events` 正常 ✅。
**⚠️ 经验固化(Linux 目录权限设计)**:数据根这类"root 服务 + 降权子进程都要访问"的目录,权限用 **711/700**(去 r 留 x),不能收 700——降权进程会因无法穿过根路径而崩溃(EACCES)。子目录各自 700 才是真正的隔离边界。
## 十八、统一 KEY:管理员统一设置,用户只能使用管理员 KEY 和模型(2026-09-08 20:40)
**决策**(用户确认):① KEY 存放 = 复用 admin 密钥库(桌面/API 自助换 key 即全局生效);② 用户 dsh 界面自配暴露面先实测再定,本次不做 dsh patch。
**改动(commit df5cc84,6 文件 +49/-14)**:
| 文件 | 改动 |
|------|------|
| `src/web/server.ts` | `resolveApiKey` 忽略入参 userId,改为 `listPublicUsers().filter(role==='admin')` 取 admin 的启用 key——**所有用户 spawn 注入同一把管理员 key** |
| `src/supervisor/spawner.ts` | Spawner 接口新增 `restartAllMains()` |
| `src/supervisor/orchestrator.ts` | LocalSpawner 实现:遍历内存 `mains` 逐个 `restartMain`(单个失败 console.error 不中断广播) |
| `src/supervisor/k8s-spawner.ts` | K8sSpawner 实现:遍历 `db.listInstancesByRole('main')` 逐个重启 |
| `src/web/routes/auth.ts` | `/api/me/keys` GET/POST/select/delete 由 `requireAuth` 改 **`requireAdmin`**(普通用户 403);POST/select 后 `restartAllMains()` 广播(key 是 spawn 时注入 env 的快照,不重启运行实例不刷新) |
| `web/desktop.html` | 非 admin 隐藏"管理密钥"按钮;面板文案改"全局 API 密钥(所有用户共用,仅管理员可改)" |
**验证(全链路实测)**:
- admin GET `/api/me/keys` → 200,deepseek-main 在列 ✅
- 注册 testuser(pending)→ admin approve(active)→ 登录 ✅
- 普通用户 GET/POST `/api/me/keys` → **403 forbidden** ✅
- **testuser launch DSH → 打开 200 SPA → `llm/listProviders` ok:true deepseek-official** ——证明普通用户无自配 key 也拿到 admin 全局 key ✅
**遗留**:用户 dsh 界面内自配 key/模型的能力面未实测(proxy 伪装 loopback 可能放行 dsh Settings)——第二道防线待 P1 实测后再定。testuser 账号保留作普通用户全链路验证种子。
## 十九、界面删除用户功能(2026-09-09 07:00)
**需求**:管理界面在禁用/恢复右侧加删除按钮 → 弹窗输入该用户名相同名称确认 → 删除(admin 不可删)。
**改动(commit 4943c8c,7 文件 +106/-2)**:
| 层 | 文件 | 改动 |
|----|------|------|
| 后端路由 | `src/web/routes/admin.ts` | 新增 `DELETE /api/admin/users/:id`:admin → 409;`supervisor.stop` → `db.deleteUser`(事务)→ `rm users/<id>/` 目录 → `userdel dsh-<id去横线20位>`(OS 账号,失败容忍)→ audit |
| DB 接口 | `src/db/adapter.ts` | +`deleteUser(userId)` |
| SQLite | `src/db/schema→repo.ts` + `sqlite.ts` | 事务按序清 credential_vault/domains/sessions/dsh_instances/audit_log/folder_plugins/workspaces/users(显式删除,不依赖 FK cascade 开关) |
| Postgres | `src/db/pg.ts` | 同序 `withTx` 实现 |
| 前端 | `web/admin.html` + `web/desktop.html` | 非 admin 行尾加 🗑 按钮(红色 ghost);点击 `prompt` 输入用户名比对确认后 `DELETE`;admin 行不渲染按钮 |
**验证(全部实测)**:① 删 admin 自己 → 409 `cannot_delete_admin` ✅ ② 删 testuser → 200 ✅ ③ 删不存在 → 404 ✅ ④ 残留检查:users/credential_vault/sessions/workspaces/dsh_instances/domains/audit_log 7 表全 0、`users/<id>/` 目录不存在、OS 账号 `dsh-2d1f22ffcde04874ba1d` 已删、用户列表仅剩 admin ✅
**注意**:`userdel` 账号名与 provision-new-users.sh 同规则(`dsh-` + uuid 去横线前 20 位);OS 账号删除失败只容忍不报错(DB/目录已清,残留仅 uid 占用)。testuser 已作为功能测试对象删除。
## 附录 A:常用命令速查
```bash
# 备份 / 恢复
/opt/dsh/backup.sh
/opt/dsh/restore.sh /opt/dsh/backups/dsh-backup-<时间戳>.tar.gz
# 容器日志(看 token / 排障)
docker logs --tail 50 dsh-web
# 进入容器
docker exec -it dsh-web bash
# 插件管理(profile web)
dsh plugin --profile web add <npm包名|github:owner/repo|link:/path>
dsh plugin --profile web remove <包名>
dsh --profile web --dump-config # 打印生效插件树
# 模型 API 直测(服务器本机)
curl -s -X POST http://127.0.0.1:8900/api/llm/listProviders \
-H 'Content-Type: application/json' \
-d '{"type":"client-request","rpcId":"v1","method":"llm/listProviders","payload":{"args":{}}}'
```
## 附录 B:参考链接
- npm:https://www.npmjs.com/package/@deepseek-ai/dsh(0.1.2-rc.1)
- 官方仓库:https://github.com/deepseek-ai/deepseek-harness
- CLI 行为参考:apps/cli/reference/README.md(分层优先级/部署默认)
- dsh skills 安装与优先级:`~/.dsh/skills`、`~/.agents/skills`、项目级 `/.dsh/skills`(社区文档,见当日讨论记录)
- 社区多租户方案调研(dshs / dsh-remote / dsh-passwords / dsh-im):见当日讨论记录
@@ -0,0 +1,152 @@
# T01 · 档案 16 阶段 3/4 —— 实例内「我的技能」
- 日期:2026-09-11
- 状态:✅ **已完成**(2026-09-15 执行会话 `T01我的技能-0825` 落地;档案 **100**;插件 `business-plugins` **0.3.21** 已投放并验收)
- **阶段 3**(实例内入口)= ✅ 完成,且形态按 2026-09-15 修正为**并入「功能管理」内分组**(见 §一 横幅 / §四 决策 6 / §九)
- **阶段 4**(权限收口复核)= ✅ 收口:**本单未新增 section、未改 `ensure-role-profile-patch.cjs`** ⇒ 用户可见性**不变**,不存在"新 section 的开放范围"问题(详 §八 回报)
- 来源:`04-调整方案/16-页面导航定稿-技能插件管理面三决策落地.md §六` 阶段 3 / 阶段 4
- 规划会话边界:本单由**规划会话**产出(未 ssh、未改码);执行会话按单开工
---
## 一、目标
让**普通用户在实例内**能自助管理个人技能(上传 / 启用 / 禁用 / 删除),并在 dsh 设置「**功能管理**」页内看到「**我的技能**」分组;随后完成阶段 4 的**权限收口复核**。
**做完的判定**:普通用户(guest)在实例内 dsh 设置 →「功能管理」里看到「我的技能」分组,能上传一个 zip 技能并完成 启用 → 禁用 → 删除 全流程,且共享技能行显示为锁定(不可操作)。
> ⚠️ **2026-09-15 形态修正**:原述为「新增一个「我的技能」`settings.section`」;现改为**并入既有「功能管理」section 内分组**。理由与边界见 **§四 决策 6** 与 **§九**。
## 二、只读前置(开工前先核实,别推断)
| # | 要核实的事实 | 命令 | 期望 |
|---|---|---|---|
| 1 | 后端 API **确实已就绪**(档案 41 §实现 B) | `ssh bt-server "git -C /opt/dshs ls-files \| grep -E 'routes/skills' "` 再读该文件 | 存在 `GET/POST /api/skills/mine`、`POST /:name/enable`、`POST /:name/disable`、`DELETE /:name`,全部 `requireAuth` |
| 2 | 现有 client bundle 的 section 注册写法(照抄形态,勿自创) | `ssh bt-server "ls -l /opt/dsh/artifacts/ \| grep -i biz"` + 本机 `04-调整方案/poc/business-plugins/` | 拿到 `lib/client.js` 的 `settings.section` 注册片段与 package.json 的 `dsh.client` 元数据 |
| 3 | 铺开链路入口(新用户自动 + cron) | `ssh bt-server "git -C /opt/dshs ls-files \| grep -E 'ensure-biz' "` 再读该脚本 | `scripts/ensure-biz-plugins.cjs`(幂等、版本感知、自动取最新产物) |
| 4 | 实例页注入的既有脚本仍在(别撞车) | `ssh bt-server "grep -c '__dshRecover\|__dshAssist' /opt/dshs/src/supervisor/proxy.ts"` | ≥2(档案 50 / 56 两个注入并存) |
| 5 | 本次是否需要重启实例 | 取决于 #1:#1 通过则**本单不改服务端**,改的是 bundle → 需 `POST /api/dsh/restart` 或让用户在确认弹窗触发 | — |
> **判定口径提醒(重要,来自档案 18 v3 的教训)**:`dsh --dump-config` **不反映 bundle / profile patch 层** —— 连已生效的 `portal-entry` / `business-plugins` 行都不显示。**因此不能拿 `--dump-config` 当验收口径**(档案 16 §六 阶段 4 原文的这句写法已过时)。改用「**bundle 加载标记(看 journald)+ 实例页 UI 实测**」。
## 三、范围
**要改**(路径以 `git -C /opt/dshs ls-files` 与 `04-调整方案/poc/` 实际为准):
| 区 | 文件 | 动作 |
|---|---|---|
| client bundle | `.../client.js` | 在**既有「功能管理」`settings.section`(id `business-plugins`)内新增「我的技能」分组**(**不新开 section**,见 §四 决策 6):列表(含 `source/enabled/locked`)+ 上传表单 + 启用/禁用/删除按钮 |
| bundle 版本 | `package.json` | **必须升版本号**(pnpm 同版本会命中缓存 → 改了不生效,档案 18 踩坑 1) |
| 加载标记 | `.../lib/index.js` | 加一行 `[<bundle>] loaded …`(把回归从"看 UI"变成"看日志";档案 26 §建议 / B5) |
| 网关/路由 | 无需改(除 #1 发现 bug → 另立单) | — |
**明确不动**:
- ❌ 不动 dsh 官方包与缓存(红线 2);❌ 不改 `src/web/routes/skills.ts` 的鉴权语义
- ❌ 不动门户 `#/skills`(admin 面);❌ 不动 `workspace-scoped-picker` / `portal-entry`
- ❌ 不碰 `folder_plugins` / `enablePatch` 分支(档案 16 §八 保留说明:无业务调用,勿加功能)
- ❌ **不扩大任何权限**(API 已是 `requireAuth`,本单只是加 UI)→ **不触发 R5 权限扩大门禁**
## 四、决策点
| # | 决策 | 结论 |
|---|---|---|
| 1 | **扩展 `@dsh-local/business-plugins` 注册第二个 section**,还是**新建 `@dsh-local/my-skills`** | ✅ **已定(2026-09-12 用户拍板)**:**采用 A —— 扩展 `@dsh-local/business-plugins` 注册第二个 section**(用户原话「扩展 business-plugins 不用做两套」)。不新建 `my-skills` 包。理由:复用现成的「铺开 / 探活 / 版本感知 / 新用户自动铺 + cron 巡检」链路,成本最低;代价是 `business-plugins` 职责由一个变两个(在包 README 与本档案里说明即可)。 |
| 2 | 上传是否复用门户的 P0/P1 内容扫描 | **已定(必须复用)**:上传走平台 root 链路,档案 41 的 symlink P0 就是这类。UI 只负责把 zip POST 到 `/api/skills/mine`,扫描在服务端(档案 41 §A / 档案 19 §C6) |
| 3 | admin 是否也看到这个 section | **已定(档案 16 §五)**:所有用户(含 admin)都显示 |
| 4 | 是否顺手补 B5(给两个 bundle 加加载标记) | **已定(顺手做)**:零边际成本,正是本单要改的文件(`03-路线图` B5 行已降级为"顺手做") |
| 5 | 是否同时补门户 `#/files` 下载按钮(P3) | **不在本单**(另立) |
| 6 | **「我的技能」是并进既有「功能管理」section,还是新开一个 section** | ✅ **已定(2026-09-15,自决,可推翻)**:**并入「功能管理」内分组,不新开 section**。依据 ① 用户口径「不要分开管理」② 该分区已由档案 60 命名为**「功能管理」**——另立「我的技能」= 在"功能"之外再造一个平行概念,与口径反向 ③ 素材库 **U2**「不做两套实现,能扩展就不新建」④ 两者交互形态同构(列表 + 启停)。**代价**:一个 section 承载两组内容(决策 1 已认下)。⛔ **仅入口层合并 —— 机制层保持分离**:两条后端 API、两套落盘位置、两种生效方式都不合并(见 **§九**)|
## 五、步骤(每步自带验证)
1. **核实前置**:#1–#4 逐条执行;若 #1 缺端点 → **停**,回报"后端未就绪",不要自己补后端。
2. **读规范**:`06-工作台UI规范.md`(强制基线)+ `档案 16 §七` 设计点;参考 `04-调整方案/poc/business-plugins/` 现有 section 的写法与 token 用法。
*验证*:能指出新 section 用到的设计 token 来源(`--dsw-*`)。
3. **写 UI**(决策 1 选定的载体):列表字段 = 名称 / 来源(shared·user)/ 状态(enabled·disabled)/ 锁定标记;操作 = 上传(zip)、启用、禁用、删除;共享行 `locked:true` → 按钮禁用。
*验证*:本地 `npm run build`(或该 bundle 的构建命令)通过。
4. **升版本 + 打包 + 安装**:`pnpm add file:<tgz>`,**`HOME=<ws>` 必须设**,且 **guest 的 profile 是 pnpm workspace 根需加 `-w`、admin 不需要**(档案 25 踩坑)。产物放 `/opt/dsh/artifacts/`,**不要复制进用户 ws**(档案 28 的平台污染 bug)。
*验证*:`ls` 确认 `lib/client.js` 与 `lib/index.js` **都在**(档案 18 踩坑 2:scp 静默失败);比对 md5。
5. **重启 + 探活**:走编排器 `POST /api/dsh/restart`(按 uid 选 pid);或复用 `restartAndProbe` 的探活口径(档案 34)。
*验证*:`journalctl -u dshs --since '5 min ago'` 出现 `[<bundle>] loaded`,且**无** `duplicate loader entry id`(档案 25 事故特征)。
6. **端到端实测**(用 R4 模板:注册 → admin approve → 用完 DELETE,临时会话 `poc-*` 用完即删;**禁止用真实账号做 API 登录测试**):上传 → 禁用 → 启用 → 删除。
*验证*:见 §六 B–F。
7. **阶段 4 权限收口复核**:确认新 section 对 guest 可见、且**没误伤** guest 已禁的 `ui-settings-*` 裁剪(档案 09 / 16 §六 阶段 4)。
*验证*:用**实例页实测 + 加载标记**判定(**不要用 `--dump-config`**,见 §二 提醒);输出一份"开 new section 前后 guest 可见 section 列表"对照。
8. **归档**:新建档案(**原子占号** `mkdir 04-调整方案/.lock-<NN>`;当前下一号见 `README` 的「下一号」);更新 `INDEX.md §二` 对应行状态、`03-路线图` 勾销阶段 3/4 行、`README` 下一号;`git mv` 本单到 `archive/交接单-已完成/`。
*验证*:`python3 scripts/docs-audit.py` 退出码 0。
## 六、验收标准(可被第三方复现)
| # | 命令 / 动作 | 期望 |
|---|---|---|
| A | `ssh bt-server "cd /opt/dshs && bash scripts/ci.sh"` | 退出码 **0**,单测全绿 |
| B | 硬刷新 guest 实例子域页 → dsh 设置 →「**功能管理**」 | 页内出现「**我的技能**」分组(**与「功能插件」同页**),列出共享技能(显示锁定)与个人技能 |
| C | 上传一个正常 zip 技能 | 200,列表出现该项且 `enabled`;`$DSH_HOME/skills/<name>` 存在 |
| D | 点禁用 → 点启用 → 点删除 | 禁用后文件在 `home/skills-library/<name>`;启用后回到 `skills/`;删除后两处均无 |
| E | 上传与共享技能同名的包 / 对锁定行操作 | **409**(档案 41 的守卫生效) |
| F | 实例页仍含两个注入脚本 | 页面源码含 `__dshRecover` **且** 含 `__dshAssist` |
| G | `journalctl` | 有 `[<bundle>] loaded`;无 `duplicate loader entry id`、无崩溃重启 |
## 七、回滚
- **bundle 回退**:装回上一版本 tgz(版本号必须不同,否则 pnpm 命中缓存)→ 重启实例;或让该 section 不注册。
- **本单不改服务端** → 服务端无需回滚;若 #1 阶段确实改了服务端,按 `cp <file>.bak-<ts> <file> && npm run build && systemctl restart dshs`。
- **确认无残留**:`ls <profile>/node_modules/@dsh-local/` 与 profile 的 `dsh.profile.bundles` 一致。
## 八、回报格式(执行会话填)
```
## T01 执行回报
- 决策 1 选定:A · 扩展 business-plugins(2026-09-12 用户拍板);形态修正为「并入既有「功能管理」内分组」(2026-09-15,见 §四 决策 6)
- 档案号:100
- 插件版本:`@dsh-local/business-plugins` 0.3.20 → **0.3.21**(产物 70,758 B / sha256 `77429d2c…`)
- 验收:A ✅ / B ✅ / C ✅ / D ✅ / E ✅ / F ✅ / G ✅(**逐项证据见下**)
- 偏离:① 形态由「新增 section」改为「并入既有 section 分组」(有依据,见 §四 决策 6);② **新增** `scripts/verify-my-skills.mjs` 并纳入 `npm run verify`(原单未要求,但把"不换行 / 危险操作二次确认 / 锁定行无按钮"钉成断言可防回退);③ 未改动服务端(同原单预期)
- 证据:
· A 本地 `npm run verify` 全绿(词典 zh/en 各 366 键、引用键 343 个全部已声明)
· B 两实例 profile 均 0.3.21;`msk.group` 命中 3 / `bp-skillRow` 命中 4
· C 壳页 combo 出现 `@dsh-local/business-plugins`;壳页 58,126 B → 58,724 B;`rev=533537bbc02b`
· D bundle HTTP 200 · **11,363,655 B** · 含 `msk.group` / `msk.replaceWarn` / `bp-skillRow` / 「我的技能」
· E 端到端 **19/19**(一次性用户,用完即删):上传 → 列表 enabled → 同名 `conflict`+`stagedId` → `apply` 全量替换 → 停用(文件落 `skills-library`、不在 `skills`)→ 启用(回到 `skills`)→ 删除(两处皆无、列表消失)
· F 对共享技能 `platform-capabilities` 的 disable / delete **均 409**;非 `.zip` **400**「仅支持 .zip 文件」
· G 浏览器(`agent-browser`)**全链路零缺陷**:设置→「功能管理」可见「我的技能」分组(与「功能插件」同页);共享技能行显示 `共享|🔒只读|已启用|1 个文件 · 2.9 KB` 且**无动作按钮**;展开上传面板 → 选 `poc-skill-demo.zip` → 点「上传」= `POST /api/skills/mine` **200** 且页面提示「✓ 已上传技能…」、列表新增 `我的|已启用|3 个文件 · 575 B|[停用][删除]`;点「删除」出确认弹窗(点名 + "不可恢复")→ 确认 = `DELETE` **200**、提示「✓ 已删除…」、列表回到只剩共享行。截图与逐条证据见档案 100 §8.4
- 阶段 4 复核:本单**未新增 section**(形态 = 既有 section 内分组)⇒ 段位可见性零变化;`ensure-role-profile-patch.cjs` **未改**(我的改动清单 = client.js / package.json×2 / verify-my-skills.mjs);实例侧 `cordis.patch.yml` 的禁用项(`ui-settings-models` / `-settings-plugins` / `-plugin-inventory` / `ui-cordis`)原样保留
```
---
## 九、口径依据与适用范围(2026-09-15 追加,本单形态修正的由来)
**问题**:「把 skill 和插件都看成功能(能力),统一管理」这条提法,**适用于用户管理个人 skills 吗**?
### 9.1 原文与该提法的适用范围
- **原话**(2026-09-12 09:18,会话 `ca58e09f`):「你是方案规划 不是执行,在明确一下 A 按照最佳方案处理 B 按照你的建议处理 **!业务技能能否打包到插件中 一起安装和使用,不要分开管理!** C D 按照最优方案处理」
- **它落地成什么** = **投放链路的合并**,即 `archive/交接单-已完成/T03` **§4.1「技能随插件包投放」**:整合包内置 `skills/` + `skills/.manifest.json`,插件 host 面每次实例启动跑 `ensureSkills()` 幂等对账;**与共享技能层互斥**(同名会按 rank 抢)。对应素材库 **U4「交付面越少越好」**。
- **适用对象 = 平台/业务侧投放的技能**(MCN 这类)。判据内核 = 用户心智「我装一个插件就全有了」,优于"分层更干净"。
### 9.2 对「用户管理个人 skills」——分两层,结论相反
| 层 | 是否适用 | 依据 |
|---|---|---|
| **机制 / 投放层** | ❌ **不适用** | 个人技能**不是投放物**,是用户自己上传的产物 —— 没有"第二条投放链路"可合并,也没有"插件包"能装它;硬套会把「用户自己产生的东西」塞进「平台投放的包」,方向反了。而且两向隔离**已被现决策刻意设计**:`T03 §4.1` 第 4 条明确「**不覆盖用户自建的同名技能**(目录存在但无 `.installed.json`)→ 跳过 + 日志 + UI 提示"被同名技能占用"」。⇒ 二者**必须保持两条独立通道** |
| **入口 / 心智层** | ✅ **适用** | 正是本单形态修正的原因(§四 决策 6):实例内那一栏已由档案 60 命名为**「功能管理」**,再另立一个「我的技能」section = 在"功能"之外再造平行概念,与「不要分开管理」反向 |
### 9.3 ⛔ 不能一起"统一"掉的机制差异(避免过度统一)
| 维度 | 功能插件(候选池) | 个人技能(用户自传) |
|---|---|---|
| 来源 | admin 投放(门户 → 候选池) | 用户自己上传 zip |
| 生效方式 | **需重启实例** | **watch 即时生效,无需重启** |
| 默认态 | 进池**默认禁用** | 上传即启用 |
| 平台共享层的语义 | 投放进池、用户自助启用 | **投放即生效且锁定只读**(不造开关 —— 档案 16 §2.1 决策 2「技能与插件启用语义不同」)|
| 用户可否删除 | 可禁用(包由平台管)| **可启用 / 可禁用 / 可删除**(档案 41 用户决策)|
| 落盘位置 | profile `node_modules` + `dsh.profile.bundles` | `$DSH_HOME/skills` 启用位 / `home/skills-library` 禁用位 |
| 后端 API | `/api/plugins/*` | `/api/skills/{shared,mine}/*` |
⇒ 本单"合并"的**只有可点击的那一层**;后端、落盘、生效链路一律各自独立。
### 9.4 相关档案
`04-调整方案/16`(导航定稿 + 三层归属 + §2.1 决策 2)· `04-调整方案/41`(技能上传安全加固 + 用户启停)· `04-调整方案/60`(分区改名「功能管理」)· `04-调整方案/11`(技能管理面)· `archive/交接单-已完成/T03 §4.1`(技能随包投放)· `skills/dsh-decision-method/references/素材库-U-用户决策.md`(U2 / U4 / U10)。
@@ -0,0 +1,165 @@
# T02 · 文档库收尾 —— 编号消歧(37/38)+ INDEX 瘦身与事实校正
- 日期:2026-09-11
- 状态:⏳ **待执行**(已规划,未开工)
- 来源:`04-调整方案/53-文档质量审查与精简方案.md §四.1`(编号冲突)+ `04-调整方案/54-文档信息架构优化-BRIEF与机读清单.md §四.A`(INDEX 瘦身)
- 规划会话边界:本单由**规划会话**产出(未 ssh、未改码);执行会话按单开工
---
## 一、目标
1. **消歧**:让 `37` / `38` 两个编号各自唯一指向一份档案 → `docs-audit.py` 的「编号冲突」归零。
2. **瘦身**:`INDEX.md` 从 **28,805 字符 / 185 行** 压到 **≤ 10,000 字符**,入口读取成本再降。
3. **校正**(本轮新增,见 §四.3):修掉 INDEX 里**与现状相反的陈述**(本机镜像是否存在、对账脚本是否存在、下一号、代码 HEAD)。
**做完的判定**:`python3 scripts/docs-audit.py` **退出码 0**(编号冲突 0 / 标题号不符 0 / 悬空引用 0),且 `INDEX.md` 字符数 ≤ 10,000。
## 二、只读前置
| # | 事实 | 命令 | 期望 |
|---|---|---|---|
| 1 | 当前基线(先量再改) | `python3 -c "import io;print(len(io.open('INDEX.md',encoding='utf-8').read()))"` | **28,805 字符 / 185 行**(2026-09-12 07:20 实测) |
| 2 | 双端是否已拉齐 | `bash scripts/docs-sync-check.sh` | 退出码 **0**;不为 0 先拉齐再改 |
| 3 | 现存冲突与悬空项 | `python3 scripts/docs-audit.py` | 实测:总 **107** 文件(md 78 / 其他 29);【1】37 与 38 各 2 份 + 01/02/03/06 的"两套体系"提示;【2】2 份标题号不符;【6】**无**悬空 |
| 4 | 引用清单可复现 | `grep -rnE "档案 (37|38)" --include=*.md .` | 实测 **27 处 / 13 个文件**(见 §3.2 下表;档案 53 估的 18 处偏少) |
| 5 | 代码 HEAD(填 INDEX §五) | `ssh bt-server "git -C /opt/dshs log --oneline -1"` | 实测值(**以实跑为准**) |
> **基线刷新记录(2026-09-12 07:20)**:并行通道在此期间新增档案 **57-60**(已登记进 INDEX)→ INDEX 25,581 → **28,805 字符**、旧编号引用 25 → **27 处**、audit 总文件 103 → **107**。
> **这再次印证**:T01 / T02 与并行通道处在**同一冲突域**(都改 `INDEX.md` / `README.md` / `03-路线图`)→ **必须串行**,且**开跑前务必复跑 §二#1–#4 重新取基线**,不要沿用本单旧数字。
## 三、任务 A:编号消歧(采用**方案 A** 字母后缀)
`37` / `38` 各被两份占用(并行通道撞号),且其中两份**标题号与文件名号不符**。规划会话已按 **档案 53 §四.1 方案 A** 立项:
| 现文件名(`04-调整方案/`) | 新文件名 | 标题改为 | 指向 |
|---|---|---|---|
| `37-guest会话取证与平台缺陷清单.md`(现标题写 36) | `37a-guest会话取证与平台缺陷清单.md` | `37a · …` | `37a` |
| `37-功能插件分区v0.2官方token与i18n.md` | `37b-功能插件分区v0.2官方token与i18n.md` | `37b · …` | `37b` |
| `38-实例软件安装共享与网络安边界核查.md`(现标题写 37) | `38a-实例软件安装共享与网络安边界核查.md` | `38a · …` | `38a` |
| `38-术语统一…改功能插件.md` | `38b-术语统一…改功能插件.md` | `38b · …` | `38b` |
> 方案 B(顺延重编号)与 C(只对齐标题)已在档案 53 §四.1 排除:B 改动更大且破坏时序语义,C 不能消歧。
### 3.1 ⚠️ 必须同步改两个脚本的正则(否则"改完更糟")
`37a-…` 不匹配 `^(\d+)-`,会让 4 份档案**从统计里消失**(`docs-manifest.py` 会把它们判成 `tier=doc` / `num=null`,档案份数与 hot/warm 分层全部失真)。所以方案 A 的**必要组成部分**是改 6 处正则:
| 文件 | 行 | 现值 | 改为 |
|---|---|---|---|
| `scripts/docs-manifest.py` | 41 | `r'^\|\s*04-(\d+)\s*\|'` | `r'^\|\s*04-(\d+[a-z]?)\s*\|'` |
| `scripts/docs-manifest.py` | 58 | `r'^(\d+)-'` | `r'^(\d+[a-z]?)-'` |
| `scripts/docs-manifest.py` | 51 | `r'档案\s*(\d{1,2})\b'` | `r'档案\s*(\d{1,2}[a-z]?)'`(**去掉 `\b`**,否则 `档案 37a` 匹配不到) |
| `scripts/docs-audit.py` | 59 | `r'^(\d+)-'` | `r'^(\d+[a-z]?)-'` |
| `scripts/docs-audit.py` | 60 | `(\d+)` 标题号 | `(\d+[a-z]?)` |
| `scripts/docs-audit.py` | 122 | `r'档案\s*(\d{1,2})\b'` | `r'档案\s*(\d{1,2}[a-z]?)'` |
**另外**:`docs-audit.py` 的 `num_map` 现在用 `int(mf.group(1))` 作键(L62)→ 改成**字符串键**(`mf.group(1)`),L118 的 `existing` 与 L124 的比较也一并按字符串,避免 `'37a'` 与 `int` 混比。
*验证*:改完在**未改名**状态下先跑一次两个脚本 → 输出应与改前**一致**(证明正则改动无语义漂移);再做改名。
### 3.2 引用修正规则(**按内容判定,勿机械替换**)
| 引用的语境 | 指向 |
|---|---|
| 会话取证 / 会话级播种 / 零技能投放 / 存量会话 | `37a` |
| 功能插件分区 v0.2 / 官方 token / i18n | `37b` |
| 实例软件安装共享 / Playwright / 网络与安全边界 / 共享技能层挂载 / 无磁盘配额 | `38a` |
| 术语统一 / 以某档案为准的术语口径 | `38b` |
已知待改位置(**2026-09-12 07:20 实测:27 处 / 13 个文件**;执行时以 §二#4 实跑复核):
| 位置 | 处数 | 现语境 → 改为 |
|---|---|---|
| `BRIEF.md:68` | 1 | 术语以旧 38 为准 → **38b** |
| `README.md:18` | 1 | 同上 → **38b** |
| `INDEX.md` | 3 | §二 表行与行内引用 → 37a / 37b / 38a / 38b 分别对齐(表行见 §4.1 重复行合并) |
| `skills/dsh-change-workflow/SKILL.md` | 3 | `:452` 边界章节 → **38a**;`:779` 术语章节 → **38b**;`:472` 按内容判 |
| `04-调整方案/38-实例软件安装共享与网络安边界核查.md` | 5 | 会话取证引用 → **37a** |
| `04-调整方案/38-术语统一…改功能插件.md` | 1 | 分区 v0.2 引用 → **37b** |
| `04-调整方案/39-实例可见面收窄与宿主访问封锁.md` | 2 | 边界核查起因 → **38a** |
| `04-调整方案/40-共享技能层挂载修复.md` | 1 | 挂载起因 → **38a** |
| `04-调整方案/41-技能上传安全加固与用户技能启停.md` | 1 | 无磁盘配额 → **38a** |
| `04-调整方案/45-存量会话新开会话提示.md` | 1 | 会话级播种 P0-1 → **37a** |
| `04-调整方案/46-实例共享工具jq-ripgrep-ffmpeg.md` | 1 | Playwright 是否每人一份 → **38a** |
| `04-调整方案/53-文档质量审查与精简方案.md` | 5 | 本档自身的冲突描述(§二 P0 行 + §四.1 全节)→ 改 37a/37b/38a/38b + 追加「已执行」一行 |
> ⚠️ **两处副本都要改**:`skills/dsh-change-workflow/` 在本目录是**服务器归档副本**,工作副本在本机 `.workbuddy/skills/`(技能必须本地加载)——**单向推进 本机 → 本目录,勿反向覆盖**。
> **自带验证器**:改名后 `docs-audit.py` 会把所有**残留的裸旧号**(不带字母后缀的那种写法)判为**悬空引用**(退出码 1)→ **改不干净就过不了验收**,这正是我们要的强制力。**注意:本单自身、以及归档后的本单同样会被扫描**,收尾时一并清理。
## 四、任务 B:INDEX 瘦身(目标 ≤ 10,000 字符)
体积主因 = **§二「全量清单」表**(60+ 行,每行 300–900 字符,含「文档路径 / 状态 / 最后更新 / 一句话内容 / 证据」五列)。
**做法(已定)**:
| 章节 | 动作 |
|---|---|
| §一 场景速查表 | **保留**(定位层,最高频) |
| §二 顶部「状态摘要(30 秒视图)」 | **保留** 4 行(状态计数 + 分层 + 指针) |
| §二 明细表 | **压成精简表**:`\| 号 \| 状态 \| 一句话(≤25 字) \|` 三列,**删掉**「文档路径 / 最后更新 / 证据」三列——路径与标题看 `docs-manifest.json` 的 `path`/`title`,日期看它的 `date`,commit 归各档案自身 |
| §三 变化追踪 | 保留,手段表 5 行 → 压到 3 行(把已作废的"服务器 git init 建议"按结论一行带过) |
| §四 归档残留 | 保留;归档表精简为"文件 + 状态"两列 |
| §五 仓库与同步拓扑 | **保留**(关键事实,逐行核对更新到当前 HEAD) |
| §六 落位规则 | 保留 7 条 + 新增第 8 条(交接单落位,本单已加) |
**测量**:`python3 -c "import io;print(len(io.open('INDEX.md',encoding='utf-8').read()))"`;一次压不到位时,继续删「一句话」列长度与 §三/§四 的冗余表述,**不要删 §一 场景表**。
### 4.1 顺手修掉的事实错误(同时是"歧义源")
| 位置 | 现文 | 应为 |
|---|---|---|
| `INDEX.md:6` | 「**服务器唯一源**…**本机不保留副本**…61 文件…原整库对账脚本 `docs-sync-check.sh` **已删除**」 | 与本机镜像存在(本目录即工作树)、且 `scripts/docs-sync-check.sh` **在用**(`README §对账`、`BRIEF §6` 都在引用)**直接矛盾** → 改为"本机工作树 + 服务器镜像双端,对账脚本在用" |
| `INDEX.md:7` | 本机工作区 = `D:\AI技能\aliyun-work-space\`,称 `aliyun-dsh-server` "**已不存在**" | **写反了** → 应为 `D:\AI技能\aliyun-dsh-server\dsh-server-docs` |
| `INDEX.md:8` | 最后核对 10:12 / 代码至档案 28 | 更新到当前(档案 56) |
| `INDEX.md` §二 | 3 处**重复行**:`04-37`×2、`04-38`×2、`04-21`×2 | 合并为 37a/37b、38a/38b 两行;21 只留一行 |
| `INDEX.md` §二 | 无「编号 48 未使用」说明 | 补一行说明(缺号) |
| `INDEX.md:117` | 阶段 3/4 待办 | T01 完成后勾销 |
| `INDEX.md` §五 | 代码 `master @ 8355e99` | 按 §二#5 实测值更新 |
| `INDEX.md` §六① | 「下一号 = **20**」 | 更新为当前值(以 `README §使用约定` 的「下一号」为准,改前先与 T01 当时的占号结果对齐,**避免两个通道同时取号**) |
| `README.md` 模块一览 | 称 `skills/dsh-change-workflow/` 的工作副本在本机 `.workbuddy/skills/` | 规划会话在 4 个候选路径(`~/.workbuddy/skills/`、`~/.dsh/skills/`、`D:\dshworkspace\` 下两级、本工作区 `.workbuddy/skills/`)**均未找到该技能**(`~/.dsh/skills/` 下是 `dsh-multi-user-migration`)→ 请核实真实位置并订正「同步方向本机 → 本目录」这句 |
## 五、明确不做
- ❌ 历史档案里的旧域名 / 旧术语**不回改**(档案 53 §五已判定:档案是当时事实)
- ❌ `archive/` 全文不动;❌ `02-运维手册` / `06-工作台UI规范` 长文不拆
- ❌ 档案 53 §四 的其余待裁定项(02 正文加历史前缀、19 份补状态行、`.bak` 清理、SKILL.md 拆分)→ **不在本单**
- ❌ 那个结尾带点号的 `INDEX.md.bak-20260911111003.`(Windows 落不了地)→ 不动
## 六、验收
| # | 命令 | 期望 |
|---|---|---|
| A | `python3 scripts/docs-audit.py` | **退出码 0**;【1】无冲突、【2】无、【6】无悬空 |
| B | `python3 -c "import io;print(len(io.open('INDEX.md',encoding='utf-8').read()))"` | **≤ 10000** |
| C | `python3 scripts/docs-manifest.py` | 档案份数**与改前一致**;`37a/37b/38a/38b` 出现在 items 且各自有 `refs` 与 `tier`(**证明 3.1 的正则改动生效**) |
| D | `grep -rnE "档案 (37|38)" --include=*.md .` | 只剩「指向 37a/37b/38a/38b」的表述,**无裸旧号** |
| E | `bash scripts/docs-sync-check.sh` | 退出码 0(含新增的 `交接单/` 目录已推送服务器) |
| F | `INDEX.md` 内所有指向 T01 的相关行 | 与 T01 执行结果一致(若 T01 未完成,保持"待办"表述) |
## 七、回滚
- **文档**:`git -C dsh-server-docs checkout -- <文件>`;改名用 `git mv` 反向再来一次(保留历史)。
- **脚本**:两处正则改动若异常,`git checkout -- scripts/docs-{audit,manifest}.py` 并**同时撤销改名**(正则与文件名必须成对,半改状态会让 manifest 统计失真)。
- **服务器镜像**:`scp` 前先备份 `/opt/dsh/docs` 对应文件;权限维持 root 600(README 644)。
## 八、回报格式(执行会话填)
```
## T02 执行回报(2026-09-12 执行完毕)
- **改名前基线**:INDEX **28,809 字符 / 184 行**;audit:【1】37 与 38 各被 2 份占用 + 2 份标题号不符;【2】2 份;【6】无悬空**但 T02 单子自身引用 `37a` 被判悬空** → 退出码 1
- **改名**:4 份经 `git mv` → `37a`/`37b`/`38a`/`38b`(git 识别为 **R 重命名,历史保留**)+ 4 个标题号同步改写(`# 36`→`# 37a`、原 `# 37`→`# 37b`、原 `# 37`→`# 38a`、`# 38`→`# 38b`)
- **脚本正则**:`docs-audit.py` **4 处** + `docs-manifest.py` **4 处** = 共 **8 处**(**多于单子列的 6 处**:单子漏了 `index_status`/`ref` 的键转换,以及 `%02d` 格式化 —— 键改字符串后 `%02d` 会 TypeError,必须同改);**未改名状态下先复跑** ✅,差异全部可解释且**更准确**:audit 唯一差异 = 新号 `37a` 被判悬空(预期);manifest 归一化后 11 条 = 9 条零填充(`1`→`'01'`,类型变更连带,反而与 INDEX 的 `04-01` 更一致)+「`03` 的 refs 4→2」(原先被 `int()` 误并进来的裸旧号 —— 即 `档案` 与数字分开写的那种 —— 分离出去,那是**单子自身的示例文本**)+「`16` 的 refs 24→25」(去掉 `\b` 后**修复了一个漏匹配**)
- **引用修正**:实跑命中 **28 处 / 14 文件**(单子估 27 处/13 文件,**偏少 1**);逐条按内容判定归属 —— **37a 7 处**(INDEX:98 / 38a:6,55,139,178,188 / 45:5)、**37b 1 处**(38b:56)、**38a 9 处**(INDEX:93,99 / 39:12,118 / 40:5 / 41:28 / 46:5 / SKILL:489,509)、**38b 6 处**(BRIEF:68 / INDEX:112 / README:18 / 53:55 / 60:43 / SKILL:816);另 **53 自身 3 处描述改写**(P0 行、§四.1 标题、方案 A 段落,均加「✅ 已执行」)+ **T02 单子自身 2 处示例文本**改写
- **INDEX 瘦身**:**28,809 → 7,468 字符**(184 → 168 行,**减 74%**,达标);删除列 = 文档路径 / 最后更新 / 证据(均指向 `docs-manifest.json`);§二 明细压成三列;§三 手段表重排(对账/机读/审计/代码侧/文档侧/时间线);§四 精简为 4 行;§五 补「行尾」约定;§六 保留 7 条 + 第 8 条(交接单落位)
- **事实校正**:L6(「服务器唯一源 / 本机不保留副本 / 对账脚本已删除」→ **实为双端模型且脚本在用**)✅|L7(本机工作区误写 `aliyun-work-space` 且称 `aliyun-dsh-server` 已不存在 → **写反了,已改正**)✅|L8(最后核对 → 2026-09-12 / md 78 / 代码 `ebe8075` / 档案 60)✅|§二 重复行(37×2、38×2、21×2 → 合并)✅|缺号 48 ✅|§五 代码 HEAD(`8355e99` → **`ebe8075`**)✅|§六① 下一号(20 → **61**)✅|**另修** README 的技能工作副本路径(规划会话在 4 个候选路径均未找到 → 实际在 **`E:\ProgramData\.workbuddy\skills\`**,因 `.workbuddy` 数据目录已迁至 E 盘)✅
- **验收**:**A ✅ B ✅ C ✅ D ✅ E ✅ F ✅**
- **证据**:
- `docs-audit.py` → `结论:无 P0 级问题`、`【6】✓ 无悬空档案号引用`、**退出码 0**
- `docs-manifest.json` → `items: 78`(与改前一致),含字母后缀编号 `['37a','37b','38a','38b']` 且各有 refs/tier
- 裸旧号检查(`档案` 后直接跟两位数字、不带字母后缀的写法)→ **残留 0**
- `docs-sync-check.sh` → `一致: 107 / 内容不一致: 0 / 仅本地: 0 / 仅服务器: 0` → **双端一致 ✅**
- **偏离**:① 脚本正则由 6 处增至 **8 处**(键类型连带的必需修改);② 引用实为 **28 处 / 14 文件**(单子估 27/13);③ 额外修正 **T02 单子自身 2 处示例文本**(原 `grep "档案\s*3[78]"` 会被新正则匹配成裸旧号 → 永久悬空,改为 `grep -rnE "档案 (37|38)"`);④ 除单子列的 8 处事实校正外,另修 README 技能工作副本路径
```
@@ -0,0 +1,345 @@
# T03 · 把 `plugin_package` 7 个自研插件整合为 1 个功能插件并投放
- 日期:2026-09-12
- 状态:✅ **已完成并归档(2026-09-13 18:0x)** —— 投放链路全通(上传 → admin 装 → **guest 启用** → 实例重启探活成功);主体验收通过。收尾记录见 **§十**
> ⛔ **2026-09-13 09:4x 现场取证:这一步已被试过,但被平台自动回滚了。**
> guest 在 **07:42:52** 通过「功能管理」提交了启用(`POST /api/plugins/mine/apply`,任务 `c8ac2686539aeb7c`),
> 随后实例 **07:43–07:45 连崩 5 次**(`exitCode=134`,栈 = V8 `JsonParser`/`HeapAllocator` ⇒ **V8 堆打满 abort**),
> 07:46 触发熔断,平台**自动回滚**(`package.json.bak-rollback-20260913T0748`)⇒ 现在 guest bundles 里**没有** mcn-suite。
> **根因指向 `--max-old-space-size=160` 太紧**(档案 74 的下调)+ guest cgroup 峰值已 **382/384 MiB**
> ⇒ **先把内存预算调上去(需重启窗口),再重试启用**。详见 `04-调整方案/74` 文末「现场反证」。
**原状态行**:⏳ **待续做(2026-09-13 07:3x 复核更正)** —— **步骤 1–9 已完成,且「上传候选池」亦已完成**(现行池内 = `dsh-plugin-mcn-suite @0.3.9`,09-13 00:02 上传;**admin 侧已装 0.3.9 且 host 面 3 个注册加载通过**),**剩**:**`guest` 侧在实例「功能管理」启用**(`POST /api/plugins/mine/apply` —— **用户动作,平台不代劳**)→ 重启实例 → §八验收 → 归档。**2026-09-12 14:55 已释放占用锁**(原 `exec-session-B`),接手者**直接续做**;产物与验收证据见 `交接单/README.md §一` 的 T03 行。
> **🔎 只读核查结论(2026-09-12 20:2x)—— 执行前的三个事实**
> ① **候选池里只有 `dsh-univer-office`(39.9 MB)** ⇒ §七 防线②"下架旧 7 包"**实际无需做**:那 7 个包**从未上架过候选池**,不存在新旧并存。
> ② **guest 的 `profile/web` bundles = `base / dsh-web-app / business-plugins / portal-entry / workspace-scoped-picker / @liustack/modlens / dsh-univer-office`** ⇒ **不含任何旧包** ⇒ §七 头号风险(新旧并存 → `duplicate loader entry id` 崩实例)**在本单不成立**,切换顺序可简化。
> ③ **时机未到**:guest 活动实测 —— `ws/MCN短视频创作` mtime = **20:06**、`sessions` mtime = **20:02**(距核查 13–17 分钟)⇒ 判定**活跃中**,按 **R8「能避开活跃时段就避开」** 暂缓"启用 + 重启"。上传(零影响)与启用(断 guest ~1 分钟)**同窗口做更安全**(避免为"上传"单独占一次 op-lock),故**整体等待空闲窗口**(判据:最近 30 分钟无活动)。
>
> ⚠️ **同窗口须一并处理**:guest 的 bundles 里仍挂着 **`@liustack/modlens`** —— 该包今天已从候选池下架(档案 70 收尾),但用户 profile **仍启用它**(包文件尚在 `node_modules/@liustack/modlens`,故当前不致命)。这是"**已下架却仍启用**"的不一致态 ⇒ **与 T03 共用同一次重启窗口摘除**(从 bundles 移除 + 重启),避免两次中断用户。
### ✅ 上线进展(2026-09-12 22:0x)
| 步 | 结果 |
|---|---|
| ① 首传 0.3.0 | ❌ **被 T05 兼容性预检拒收**(HTTP 409 `compat_incompatible`)—— 判据 `@deepseek-ai/dsh-client-ui-sidebar@^0.1.0-rc.5` vs 平台 `0.1.2-rc.1`。**根因是 semver 的 prerelease 规则**:带 prerelease 的版本只能被「**同 major.minor.patch 且带 prerelease**」的比较器匹配,`^0.1.0-rc.5` 里没有 tuple `(0,1,2)` 的比较器 ⇒ 判不满足。**不是误报,是声明写窄了**(且 T05 16:35 才上线,而包 11:15 打的,单子当时没这道关) |
| ② 修正 + 重传 | ✅ **HTTP 200**,`compat.level = ok`、`findings[]` 空。改法:3 个 `dsh-client-*` peer 范围 → **`^0.1.2-rc.1`**(跟随平台版本);版本号 **0.3.0 → 0.3.1**;`npm pack` 重打包 → md5 **`adcb134e…`** / 322 条目。**内容与 0.3.0 逐项对齐**:`SKILL.md` 18=18、`lib/` 文件 19=19、`web/` 1=1(条目数差异纯属 `npm pack` 不写目录条目) |
| ③ 候选池 | 现 **2 条**(09-13 07:3x 复核):`dsh-plugin-mcn-suite @0.3.9`(09-13 00:02;admin 已装并验通,**guest 未启用**)+ `dsh-univer-office @0.2.15`(09-12 23:19,fork 版)|历史:曾为 `@0.3.1` + `@0.2.14`,`@liustack/modlens` 与 `anysearch` 已下架 |
| ④ 剩余 | **`guest` 在实例「功能管理」里启用**(`POST /api/plugins/mine/apply` —— **用户动作,平台不代劳**)→ 重启 → §八 验收 → 归档。~~同窗口一并摘 `@liustack/modlens`~~ **已消解**(09-13 07:0x 实测:guest `bundles` 与 `node_modules` 均已无该包)|
> ⚠️ **给接手人的两条提醒**
> 1. **上传前必过兼容性预检**(`POST /api/plugins/business` 会 409);`trust` 只用于"已人工确认后放行",别拿它盖住声明问题。
> 2. **peer 版本范围必须跟随平台 dsh 版本**(当前 `0.1.2-rc.1`)—— 平台升版时同步改,否则 prerelease 规则会再次拒收。插件源码**并不 import** 这些包(只靠 `dsh.client.inject` 注入点),所以放宽范围无功能风险。
- 触发:用户「**规划 `D:\dshworkspace\plugin_package` 整合成一个插件,可打包后由 admin 整体上传到 dsh 服务器插件,用户可以启用进行使用**」
- 关联:档案 16(插件三层归属 + 投放/启停流程)、27(MCN 插件平台化评估,P0 清单来源)、34(启用探活 + 快照回滚)、36/38(铺开链路)、41(技能启停 API)、58(实例内存配额)、60(分区改名「功能管理」)、19 §C6 + 41(上传安全扫描)
- 规划会话边界:本单由**规划会话**产出,只读普查了本机 `D:\dshworkspace\plugin_package`(未 ssh、未改码)
---
## 一、目标
把 `D:\dshworkspace\plugin_package` 下的 **7 个自研插件整合成 1 个功能插件包**:admin 在门户一次性上传候选池 → 用户在实例设置「**功能管理**」分区启用一次 → 重启实例 → **全部功能可用**。
**做完的判定**:一个**全新用户**启用该整合包并重启后,侧边栏入口 + 工作台各功能页逐个可用,journald 无 `duplicate loader entry id`、无崩溃循环;**并且业务技能随包自动就位**(见 §4.1)—— `$DSH_HOME/skills/` 出现 `mcn-short-video` 与 4 个业务子技能,**用户不需要另外上传或启用技能**。
## 二、现状(2026-09-12 只读普查结论)
| 包 | loader id | 体积 | client 行 | host 行 | 角色 |
|---|---|---|---|---|---|
| `dsh-plugin-mcn` | `mcn-nav` | 945K | **3045** | 3095 + 9 模块 | **宿主壳**(唯一声明 `dsh.client.inject=[runtime, sidebar]`) |
| `dsh-plugin-douyin-accounts` | `douyin-accounts` | 55K | 666 | **9(空壳)** | 功能页 |
| `dsh-plugin-douyin-account-detail` | `douyin-account-detail` | 67K | 877 | **9(空壳)** | 功能页 |
| `dsh-plugin-douyin-video-detail` | `douyin-video-detail` | 39K | 496 | **9(空壳)** | 功能页 |
| `dsh-plugin-mcn-schedule` | `mcn-schedule` | 54K | 253 | 483 | 功能页 + 定时任务 |
| `dsh-plugin-social-workbench` | `social-workbench` | 37K | 150 | 218 | 外部服务守护(**Windows 本地**) |
| `dsh-plugin-voxemw-cloud` | `voxemw-cloud` | 77K | 134 | 233 + 6 模块 + `web/app.html` | 云 API 面板 |
### 2.1 结构性结论(**这是"必须整合"的真正理由**)
**它不是 7 个平行插件,而是「1 个宿主壳 + 6 个功能页」。**
- `dsh-plugin-mcn` 注册了 **25+ 条 `/mcn/api/*` HTTP 路由**、`inject = ["slots","sessions"]`、在 `sidebar.footer.action` 注册唯一入口,并提供两个**全局扩展点**:
- `window.__mcnEntries` —— **功能页注册表**(工作台首屏图标网格读它渲染,源注释写明"外部插件注册的功能入口,即插即用")
- `window.__mcnNav` —— **页内路由**(`open(entryId, params)` / `openView(id)` / `back()`)
- 其余 6 个包**全靠往 `__mcnEntries` 注册 + 被 `__mcnNav.open()` 打开**;三个 douyin 包的 host 面只有 **9 行空壳**,注释原文写着"数据复用 `dsh-plugin-mcn` 服务端注册的 `/mcn/api/*` 路由"。
- ⇒ **拆开的状态下"逐个启用"本身就违背设计**:缺了 mcn,其余 5 个只是死链;反过来只启用 mcn 则功能残缺。**整合是回归正确形态,不只是图省事。**
- 附注:`dsh-plugin-mcn` **没有** `defineTool` / `tools.register`(`grep` 实证 = 0)→ 它是 UI + HTTP 插件,**不给 agent 注册工具**;agent 侧能力只来自技能与 prompt。
### 2.2 打包链路现状:**不存在,要新建**
7 个包均**无 `scripts` 字段、无 build 脚本、无 `package/` 目录、无 tgz 产物**;`lib/*.js` 是**直接维护的产物**(client.js 顶部自带 `var module = {exports:{}}` 的 bundle 外壳)。→ 整合包必须**新建打包脚本**。
### 2.3 需要一并处置的残留
`dsh-plugin-mcn/lib/` 内有 **2 个 `.bak`**(`index.js.bak-20260906`、`index.js.bak3-20260906`)→ 会被打进包、加重体积并可能触发上传扫描告警。`docs/ui-check-home-fix/`(1.6M)与插件无关,不进包。
## 三、范围
| 区 | 对象 | 动作 |
|---|---|---|
| 新建 | `D:\dshworkspace\plugin_package\dsh-plugin-mcn-suite\`(**7 个原包原地保留**作回滚参照) | 整合包源码 |
| 新建 | 整合包内 `skills/`(**技能原文 + `.manifest.json`**,来源 `C:\Users\Administrator\.dsh\skills\mcn-short-video`,11 MB / 711 文件) | 随包投放的业务技能,**按 §4.1 取舍表挑选**(不整包照搬) |
| 新建 | 打包脚本(`scripts/build-mcn-suite.cjs` 或等价) | 产出 `dsh-plugin-mcn-suite-<ver>.tgz`(**tgz 内需 `package/` 前缀**,档案 25/28 教训) |
| 改 | 整合包内的 host/client 代码 | §五 的 P0 适配 |
| 平台侧 | 门户候选池 | 上传新包 + **下架旧 7 包**(防并存,见 §七) |
| 文档 | 新建档案 + `INDEX §二` + `03-路线图` + `交接单/README §一` | 归档 |
**明确不动**:❌ dsh 官方包与缓存(红线 R2)❌ 平台自建三 bundle(`portal-entry` / `business-plugins` / `workspace-scoped-picker`)❌ `/opt/dsh` 权限 ❌ 7 个原包源码(保留)❌ 不新增平台侧权限能力(不触发 R5)
## 四、决策点(**已由用户 2026-09-12 拍板**)
> 用户口径:「**A 按照最佳方案处理;B 按照你的建议处理 —— 业务技能能否打包到插件中一起安装和使用,不要分开管理!;C、D 按照最优方案处理**」
| # | 决策 | 定稿 |
|---|---|---|
| 1 | `dsh-plugin-social-workbench` **进不进包** | ✅ **不进包**(采纳建议 A)。实测是 **Windows 本地服务守护**:`EASEL_DIR` 默认 `D:\dshworkspace\Easel`、可执行文件 `.venv/Scripts/easel.exe`、探针打 `127.0.0.1:7860`/`:18789`;平台实例里 ① 该 exe 不存在 ② `127.0.0.1` 已被档案 39 封(实测 BLOCKED)→ **必然不可用**。该入口在平台不投放,原包源码保留 |
| 2 | `dsh-plugin-voxemw-cloud` 进不进包 | ✅ **进包**。凭据全程走 env(**无硬编码** ✓、`getPublicConfig` 已剥离 apiKey ✓),平台实例无该 env → 入口显示「未配置」,不崩 |
| 3 | 整合后的入口形态 | ✅ **A=保留 `__mcnEntries` 机制、多入口并列**(工作台首屏图标网格),client 面行为与现状一致 |
| 4 | **业务技能投放** —— 用户明确要求 **打进插件包、一起安装使用、不分开管理** | ✅ **改为「技能随包投放」**,实现见 **§4.1**。**这推翻了规划会话原先"走门户技能管理分开投"的建议**,据此重设计 |
| 5 | 包名与版本 | `dsh-plugin-mcn-suite` **v0.3.0**。**版本号必须每次递增**:同名同版本 tgz 即使内容变了,pnpm 也复用旧包(档案 18 踩坑 1) |
### 4.1 技能随插件包投放(**用户要求:不分开管理**)
**为什么可行**:技能发现位在**实例内** —— `dsh discoverRoot` 只扫 `$DSH_HOME/skills`(档案 41 §B 实测:启用/禁用靠目录 rename 即可**即时生效、无需重启**);而插件的 host 面代码**就跑在实例内、以该用户 uid 运行** → **插件完全有能力自己把技能装到位**,不需要平台技能管理面参与。
**机制(建议,执行会话按此实现)**:整合包内置 `skills/`(技能原文)+ `skills/.manifest.json`(技能名 → 版本 / 文件数 / sha256)。host 面在**每次实例启动**调用 `ensureSkills()`:
1. 读 `.manifest.json`;对每个技能比对 `$DSH_HOME/skills/<name>/.installed.json` 记录的版本;
2. **幂等**:版本一致 → 跳过(不重复写盘);
3. 版本不一致 → **全量替换**(先删后放,旧版已删除的文件一并清除 —— 与档案 11「apply 全量替换」同语义);
4. **不覆盖用户自建的同名技能**(目录存在但无 `.installed.json`)→ 跳过 + 写日志 + UI 提示"被同名技能占用";
5. 写加载标记 `[mcn-suite] skills ensured: [email protected], …`(回归看日志即可,档案 26 §建议)。
**备选(更省事,但必须先实测)**:若 dsh 的 skill 扫描**认得目录软链**,可改用软链 `<DSH_HOME>/skills/<name>` → `<profile>/node_modules/dsh-plugin-mcn-suite/skills/<name>`,**升级插件即升级技能、零对账**。风险:symlink 是否被扫描/读盘正常要**实测**(档案 39 的教训:symlink 行为必须 realpath 级实测,别假设)。→ 执行会话先试软链,不行再退回"复制 + 版本对账"。
**投哪些 / 不投哪些 —— 按「引用驱动」裁剪,不按"看着像资料"裁**(用户 2026-09-12 定「参考资料不需要」;规划会话随后实测引用关系,**发现三类"像资料"的其实是被引用的功能件**,故按下表执行):
| 内容 | 体积 | 引用实测 | 定稿 |
|---|---|---|---|
| 主技能 `mcn-short-video`(`SKILL.md` + `references/` + `references-add/` + `scripts/` + `帮助文档.md`) | ~1.3 MB | — | ✅ **投** |
| 子技能 `mcn-dou-analysis` / `mcn-script-review` / `mcn-video-prompt` / `mcn-data-insight` 主体 | 主体 | 插件 prompt 正是引用这 4 个 | ✅ **投** |
| **`mcn-data-insight/` —— 它本身是"技能集索引",下挂 12 个二级子技能**(`douyin-top-account`(140K) / `douyin-account-diagnosis`(136K) / `douyin-rise-ranking`(76K) / `douyin-hot-trend`(72K) / `douyin-ai-feed` / `douyin-prohibited-word` / `playlet-douyin-feed` / `douyin-weekly-surge` / `douyin-works-crawler` / `douyin-content-surge` / `douyin-daily-hot` / `douyin-search`,**全部带 `SKILL.md`**) | **869 KB** | 插件的「榜单更新」任务依赖其中的 `douyin-top-account`;`workshop.js` 的赛道白名单同源 | ✅ **整块投**(这是"技能树"的第二层,**不是参考资料**;`find -maxdepth` 容易漏它,核对时注意层级) |
| `mcn-dou-analysis/references/样例/`(`模板_旧梦留声机账号设定卡.svg` 19 KB + `参考_旧梦留声机_账号设定卡_长图版.png` **994 KB**) | 992 KB | **`references/feature/06_生成账号设定卡片.md:67` 写明"动手前必须先读"这两份** → **功能件,不是资料** | ✅ **保留**。用户说的"参考资料不需要"**不覆盖这一项**;若你仍要剔,则必须同改那条指令,否则留下死引用 |
| `nuwa-skill-main/` **主体**(去掉 `examples/`) | **104 KB / 11 文件** | **被引为"主方法论"**:`feature/03_提炼账号设定.md:81`、`references/人设卡生成方法.md:186` | ✅ **保留** |
| `nuwa-skill-main/examples/`(`trump-perspective/` 英文研究文档等) | ≈ **2.4 MB** | `人设卡生成方法.md:187-188` 引了 `examples/mrbeast-perspective/`(**未引 `trump-perspective`**) | ❌ **剔**。精确做法:只剔未被引的 `trump-perspective/`;若要连 `mrbeast-perspective` 一起剔,**必须同改那两行引用** |
| `mcn-video-prompt/参考skills/`(seedance2.0-prompt-skill、script-writing-studio 上游全文) | **2.0 MB** | **实测 0 引用**(`grep` 命中 0)→ 纯资料 | ❌ **剔** ✓ —— 用户"参考资料不需要"指的就是这类 |
| `browser-harness/`(整包 Python + 自带 venv + docs/png) | **2.3 MB** | **多篇 feature 要求"浏览器优先"**(`feature/01_获取账号信息.md:12`、`feature/02_获取视频.md:10`)+ 主 `SKILL.md` 有专章(L88/105-112) | ❌ **剔目录**,但**必须同步改文档**:写明「平台无浏览器(档案 38a 实测 Playwright 装不起来)→ 改走 MCP / 其它路径」,否则留下死引用,agent 会像档案 55 那样反复撞墙 |
| `scripts/mcp-config.json` | 16 KB | 插件 MCP 依赖(功能必需部分 = URL) | ⚠️ **剔 `MYAI_FEISHU_APP_ID`(`cli_a84d0a47f93e1013`)、保留 URL**(`maiya-trans.youmanvideo.com`);再按 §五#7 过扫描 |
**裁剪后体积预期**:11 MB → **≈4.3 MB**(剔掉 6.7 MB:`参考skills` 2.0 + `browser-harness` 2.3 + `nuwa/examples` 2.4)。
⚠️ **投的是整棵技能树,不是一个技能**:全树 **36 个 `SKILL.md` / 711 文件**(主技能 1 + 一级子技能 5 + `mcn-data-insight` 下 12 个二级子技能 + 更深层的参考技能目录)。**核对层级时别用浅 `find`**(规划会话首轮就是这么误判 `douyin-top-account` "缺失"的)。
**裁剪必须机械校验(新增,防"剔完留死引用")**:剔完跑一次**死引用扫描** ——
```bash
grep -rIn -E "参考skills|browser-harness|nuwa-skill-main/examples/trump|参考_旧梦留声机" <整合包>/skills/ \
| grep -v -E "已移除|无浏览器|不再使用"
```
期望:**只剩"已移除 / 已改走 X"这类说明行**(即上面要求改写的指令),**没有一条指向已删文件的活引用**。
**⚠️ 与共享技能层互斥**:本单走"插件内投"后,**不要再把 `mcn-short-video` 投到平台共享技能层**(`bundledSkillDir`,档案 10/40)—— 两条通道同名会按 rank 抢,行为不可预测。详见 §七。
**禁用 / 卸载语义(取舍,已定)**:插件被禁用(`disabled: true`)时**不删**已装技能(用户可能正在用),只在下次启用时按版本对账;**卸载插件**会留下技能残留 → 记为已知残留(清理走实例内「我的技能」或目录手工删)。
### 4.2 技能落地方式(装到哪)与撞名规则(2026-09-12 用户提问定稿)
**问题一:装到 `$DSH_HOME/skills/`,还是"就在插件内目录使用"?**
关键事实:**dsh 的技能扫描根是一个固定集合**(个人 `~/.dsh/skills` rank300 / 项目级 `.dsh/skills` rank100 / `.agents/skills` rank200 / 平台注入的 `bundledSkillDir`,档案 10 §Skill 装载机制 / 档案 41 §B),**插件包目录(`profile/node_modules/<pkg>/skills/`)不在其中**。
⇒ **"就在插件内目录使用"意味着 agent 无法按技能名加载**(skill 机制发现不了),只能靠 prompt 给绝对路径让它自己读 `SKILL.md`。
| 方案 | 能否按技能名加载 | 副本/维护 | 备注 |
|---|---|---|---|
| **A 复制**到 `$DSH_HOME/skills/<name>` | ✅ 能 | 每用户一份 ≈4.3 MB;需版本对账 | 最简单确定;用户能在「我的技能」看到(但归插件管) |
| **B 软链** `$DSH_HOME/skills/<name>` → 插件包内目录 | ✅ 能(**前提:实测 dsh 跟随 symlink**) | **零副本**;**升级插件即升级技能**,零对账 | 副作用:用户在「我的技能」里删/禁 → 断链或删链,需处理;**必须先实测**(档案 39 教训:symlink 行为必须 realpath 级实测,不许假设) |
| **C 不落扫描根**,prompt 给"包内绝对路径"(插件运行时用 `import.meta.url` 算出) | ❌ 不能 | 零副本零对账 | 兜底方案:agent 用不了 skill 名;用户不可见、不可管理;每次 prompt 都要注入路径 |
→ **定稿:B 优先(一条实测命令即可确认);B 不通退回 A;C 仅作最终兜底。**
**问题二:同名技能怎么处理(三层,按优先级)**
| 场景 | 处理 | 理由 |
|---|---|---|
| 插件技能 ↔ **用户自建**同名 | **让位**:不覆盖,跳过 + 日志 + UI 提示"被你的同名技能占用" | 用户的东西优先;档案 41 已有"同名 409"的先例,不静默覆盖 |
| 插件技能 ↔ **平台共享技能层**同名 | **互斥**:本单不投共享层;若共享层已有同名,插件让位并提示 | 共享层 rank 更高会压住个人层,静默失败比明说更糟(§4.1 互斥条) |
| 插件技能 ↔ **另一个插件**同名 | **owner 标记 + 先到先得 + 冲突报错**:每份由插件装的技能写 `.installed.json`(`owner: <包名>@<版本>`、`skillVersion`、`sha256`);安装前查目标目录 —— 无标记=用户自建→让位;`owner`=自己→按版本对账;**`owner`=别的插件→拒绝写 + 明确提示** | 多插件共存时行为可预测、冲突可见;**不建议给技能名加命名空间前缀**(要改所有 prompt,且用户看到怪名字) |
**判定顺序(实现时按此写)**:`目标目录不存在` → 装;`存在且 .installed.json.owner == 本包` → 比版本,不同则全量替换;`存在但无 .installed.json` → 用户自建,让位;`存在且 owner == 其他包` → 拒绝 + 报错。
## 五、平台化 P0 清单(**不修则"传上去也不能用"**)
| # | 问题 | 实测位置 | 改法 |
|---|---|---|---|
| **1** | **`homedir()/.dsh` 硬编码** —— 平台实例的 DSH 家目录是 `$DSH_HOME`,不是 `~/.dsh` | `mcn/lib/config.js:6,28,68`;`mcn/lib/index.js:733,771,865,881,935,964,978,1124,1167,1496,1565,1630,1969,2143,2840`;`mcn-schedule/lib/index.js:11,27,84` | 统一 `const DSH_HOME = process.env.DSH_HOME` → `join(DSH_HOME, …)`。**注意三处子路径**:`storages/workspace.json`、`sessions/`、`profiles/`,都在 DSH_HOME 下 |
| **2** | **技能路径硬编码** `~/.dsh/skills/mcn-short-video/subskills/<x>/` | `mcn/lib/index.js` 多处:1255、1565、1798、1813、1834、2078、2313、2364、2491、2546、2984、2986… | 改成**只报技能名**(`mcn-dou-analysis` 等),由 agent 自行解析(档案 27 P0-2);技能**随包投放**(见 §4.1),prompt 里不再出现任何绝对路径 |
| **3** | **`spawn("powershell", …)` 杀进程树 = Windows only** | `mcn/lib/index.js:919`(两个 `.bak` 同位置) | 平台是 Linux → 改 `process.kill(-pid, …)` / `pkill -P`,或直接删掉该清理分支(评估残留影响) |
| **4** | **MCP `spawn(npx -y myai-mcp)` 每次现拉包** | `mcn/lib/mcp.js:21`、`config.js:14` | 平台侧**预装成包**(档案 27 P1),并确认实例内 **npm registry 可达**(档案 38a:仅云元数据端点被封,其余放行但只记日志) |
| **5** | **`.bak` 混在 lib/** | `mcn/lib/index.js.bak-20260906`、`index.js.bak3-20260906` | 打包前剔除(构建脚本过滤 + `files` 白名单) |
| **6** | **上传扫描可能误伤**(P0 阻断 / P1 告警) | 整包 | **打包后先本地 dry-run `src/web/security-scan.ts` 的规则**(P0:`/etc/shadow`、base64 解码执行、`nc -e`、`/dev/tcp` 反弹、fork 炸弹、给系统二进制加 setuid;P1:`child_process`、`vm`/`eval`/`new Function`、敏感 env、超长 base64、外部网络、隐藏目录)。已知 `child_process`(mcn spawn、social-workbench spawn)只会触发 **P1 告警、不阻断**,但要确认整包 **P0 = 0**。**注意技能原文里有大量代码示例/脚本,P0 风险比插件本身高** |
| **7** | **技能原文里的凭据 / 平台情报**(随包投放新增) | `mcn-short-video/scripts/mcp-config.json`(实测含 `MYAI_FEISHU_APP_ID=cli_a84d0a47f93e1013` + 私域 URL `maiya-trans.youmanvideo.com`)、`scripts/MCN_CYLG_API.py`(命中 `key/token` 模式,**需逐个核对**)、`subskills/browser-harness/.env.example` | 入包前对 `skills/` 整目录做**凭据扫描**:`grep -riE "api[_-]?key|access[_-]?token|secret|password|passwd" skills/` → **真密钥一律剔除**(改 env 注入);非密钥的平台情报(app id、私域域名)按最小必要原则剔除;结论写进档案 |
| **8** | **插件 prompt 里的技能路径层级不符**(2026-09-12 复核,**更正规划会话先前的误判**) | 插件 prompt 写的是 `subskills/douyin-top-account`(`mcn/lib/index.js:3085`、`3086`;`mcn/lib/workshop.js:9,22`),**真实路径是 `subskills/mcn-data-insight/subskills/douyin-top-account/`**(少了一层)。技能本身**完整存在**(36 个 `SKILL.md` / 711 文件)—— 规划会话首轮的"缺失"结论是**搜索深度不够(`-maxdepth 5` 差一层)导致的误判,已更正** | 无需补文件;**并入 #2 一起修**:所有 prompt 改成**只报技能名**(`douyin-top-account`),由 agent 自己解析 → 层级问题自然消失。**打包前必跑**:`grep -rn "subskills/" lib/` 逐条核对是否与技能实际层级一致 |
## 六、步骤(每步自带验证)
1. **前置只读核查**:`git -C <平台代码> log -1`(取当前 HEAD 备写档案)/ `ls /opt/dsh/artifacts/`(看版本命名与 `package/` 前缀)/ 门户 `#/plugins` 候选池现有 7 包的实际状态(是否有人已启用)。
*验证*:拿到"候选池里有哪几个、谁在用"的事实清单。
2. **决策与技能完整性**:§四 五个决策点已定稿;**另需向用户确认 §五#8 —— `douyin-top-account` 子技能的去向**(补进包 / 已废弃改 prompt / 走 MCP)。**该项未确认前不要打包**。
3. **建包骨架**:`package.json`(name `dsh-plugin-mcn-suite`、version、`type: module`、`exports {".", "./client"}`、`dsh.bundle.patch`、`dsh.client.inject` = mcn 的 `[runtime, sidebar]` 并集)+ `cordis.patch.yml`(**只 1 条 `insert`**:`id: mcn-suite`)+ `lib/{index.js, client.js, host/*}`。
*验证*:`node --check` 全通过;`cordis.patch.yml` 里 insert 条目数 = 1(防 duplicate,档案 25)。
4. **移植 host 面**:把 mcn 的 9 个模块 + mcn-schedule + voxemw(+决策 1 的 social-workbench) 收进 `lib/host/`,**保持路由前缀不变**(`/mcn/api/*`、`/voxemw/api/*`)→ client 面零改动。
*验证*:路由清单 diff:改前 25+ 条 vs 改后逐条一致(只增不减)。
5. **合并 client 面**:一个 `client.js` 内注册全部 `__mcnEntries` 条目;`inject` 取并集;**确认无 `exports.default`**(红线 R3,实测 7 个原包全部合规 ✓,合并时别引入)。
*验证*:`grep -c "exports.default"` = 0。
6. **P0 适配**(§五 1–5)+ 按需加**加载标记**:`console.log("[mcn-suite] loaded …")`(把回归从"看 UI"变成"看日志",档案 26 §建议)。
*验证*:`grep -n "homedir()" lib/**/*.js` = 0;`grep -n "powershell"` = 0;`grep -rn "\.dsh/skills"` = 0。
7. **本地 smoke**:参考 `dsh-plugin-voxemw-cloud/tests/smoke.mjs` 的写法,跑一遍 host 面(路由注册、DB 建表幂等、无 env 时降级不崩)。
*验证*:smoke 全绿;无 env 情况下进程不抛。
8. **打包**:产出 `dist/dsh-plugin-mcn-suite-<ver>.tgz`,**内含 `package/` 前缀**,**不含 `.bak` / `node_modules`**。
*验证*:`tar tzf` 清单人工核对(顶层只有 `package/`);体积与 `du` 对照;`md5sum` 记档。
9. **扫描 dry-run**:按 §五#6 规则跑一遍 → **P0 必须为 0**;P1 告警逐条判断是否误报。
10. **上传**:admin 门户 `#/plugins` 上传 tgz → 进候选池(默认禁用)。**产物不要复制进用户 ws**(档案 28 平台污染教训)。
*验证*:候选池列表出现该包 + 版本号正确。
11. **切换(关键,防并存)**:① 先在候选池**下架旧 7 包** ② 用户实例「功能管理」启用新包 ③ 确认弹窗 → 重启实例 ④ 走 `restartAndProbe` 探活(档案 34)。
*验证*:§八 C–F。
12. **实测验收 + 资源**:逐功能页点开;用 `scripts/probe-instance-mem.cjs` 量**私有内存与启动时间**(档案 58 预算:`MemoryMax 384 MiB` / 堆 256)。
*验证*:§八 D、G。
13. **归档**:新建档案(**原子占号** `mkdir 04-调整方案/.lock-<NN>`)→ 更新 `INDEX §二` / `03-路线图` / `交接单/README §一` → 本单 `git mv` 到 `archive/交接单-已完成/`。
*验证*:`docs-audit.py` 退出码 0。
### 6.1 技能随包的额外步骤(并入上面第 3、6、8、10 步,不另起编号)
- **建包阶段(并入第 3 步)**:按 §4.1 **引用驱动**裁剪(不是"看着像资料就删")→ 放整合包 `skills/<name>/`;生成 `skills/.manifest.json`(名称 / 版本 / 文件数 / sha256);剔完跑 §4.1 的**死引用扫描**。
*验证*:`du -sh skills/` ≈ **4.3 MB**(而不是 11 MB);`tar tzf` 确认**没有** `参考skills/`、`browser-harness/`、`nuwa-skill-main/examples/trump-perspective/`;**确认有** `mcn-dou-analysis/references/样例/`(含 994 KB 长图)、`nuwa-skill-main/SKILL.md`、**`mcn-data-insight/subskills/douyin-top-account/`**(这三块都是被引用的功能件,不能剔)。
- **改码阶段(并入第 6 步)**:实现 `ensureSkills()`(§4.1 五步)+ 加载标记;把 `mcn/lib/index.js` 的 13+ 处**技能绝对路径改成技能名**。
*验证*:`grep -rn "\.dsh/skills" lib/` = 0;`grep -rc "ensureSkills" lib/` ≥ 1。
- **打包阶段(并入第 8 步)**:`skills/` 整体进包(`package.json` 的 `files` 白名单必须包含 `skills`)。
*验证*:`tar tzf dist/*.tgz | grep -c "package/skills/"` ≈ 技能文件总数(数百)。
- **上线阶段(并入第 10 步)**:上传 → 用户启用 → 重启 → 按 §八 J/K 验技能就位。
### 6.2 收尾顺带(D 组:两处文档漂移 —— 用户已定「并进 T03 收尾」)
- **`BRIEF.md:20`**:`scope(512M / 150% / TasksMax 128)` → **`384M`**,并补 `NODE_OPTIONS=--max-old-space-size=256` 与 `NODE_COMPILE_CACHE`(档案 58 实测)。
- **`03-路线图与待办.md`**:头部仍是 09-11 21:20 合并版、**档案 57-61 完全未登记** → 补登记 + 刷新头部状态行;`BRIEF §3` 待办同步。
*验证*:`grep -n "384M" BRIEF.md` 命中;`grep -nE "档案 (60|61)" 03-路线图与待办.md` 命中(**写成 `(60|61)` 而不是字符类**:`档案\s*6[01]` 这种写法会被 `docs-audit.py` 的引用正则匹配成裸旧号 `6` → 永久悬空,T02 已踩过一次)。
## 七、风险与红线
**🔴 头号风险:新旧包并存 → 实例崩**
若某用户同时在位"旧 7 包"与"新整合包",会出现:`/mcn/api/*` 路由**重复注册**、`sidebar.footer.action` **重复注册**、`__mcnEntries` **重复条目**;严重时 `duplicate loader entry id` → **崩溃循环**(档案 25 有真实前例,靠档案 20 熔断才拦住,但用户会看到死页面)。**四道防线必须全做**:
1. 新包用**全新 loader id**(不复用 `mcn-nav` 等 7 个旧 id);
2. 门户候选池**下架旧 7 包**(或标 deprecated,并在包描述写明替代关系);
3. 切换按 §六 步骤 11 的顺序执行(先禁用旧 → 再启用新);
4. 新包 host 面加**防御**:启动时检测旧 id 是否同时在位 → 命中则**拒绝启动并写明确日志**(宁可这个包不工作,也不要崩实例)。
**其余**
- 内存/启动(档案 58):合并后单实例加载模块更多,**必须实测**,超预算就按需懒加载或砍 social-workbench。
- 网络(档案 38a):实例出网只记日志不拦(元数据端点已封)→ MCP `npx`、voxemw 云调用可达性要用**实测**确认,别假设。
- **技能同名互斥**(§4.1):插件投的技能、平台共享技能层、用户自建技能**三方可能同名** → ① 本单**不投共享技能层**;② 用户自建同名时插件**跳过并提示**;③ 若日后要改投共享层,必须先撤掉插件内投放。
- **技能随包的体积成本**:约 1.5–2 MB × 每用户一份(用户已明确"不分开管理",接受该冗余);日后要省可改软链方案(§4.1 备选)。
- 红线:R2 不改官方 dsh 包(只走 bundle + patch)✓;R3 client 禁 `exports.default` ✓;R5 本单不扩大平台权限 ✓;不自动升级 dsh ✓;**未获明确授权不 commit / push**。
## 八、验收
| # | 命令 / 动作 | 期望 |
|---|---|---|
| A | `tar tzf dist/*.tgz \| head` ;`md5sum` | 顶层只有 `package/`;无 `.bak`、无 `node_modules`;md5 记档 |
| B | 上传扫描 dry-run | **P0 = 0**;P1 逐条有结论 |
| C | 门户 `#/plugins` 上传 | 进入候选池、默认禁用、版本号正确 |
| D | 全新用户:启用 → 确认弹窗 → 重启 | 侧边栏入口出现;工作台首屏功能入口**逐个可打开** |
| E | `journalctl -u dshs --since '10 min ago' \| grep -E "mcn-suite\|duplicate"` | 有 `[mcn-suite] loaded`;**无** `duplicate loader entry id`;无崩溃重启 |
| F | 启用探活(档案 34) | 探活通过;失败时快照回滚不误伤好插件 |
| G | `scripts/probe-instance-mem.cjs` | 私有内存 + 启动时间在档案 58 预算内(写实测值) |
| H | 候选池 | 旧 7 包已下架/标 deprecated;**不存在"新旧同时可启用"的状态** |
| I | **技能随包就位**(§4.1) | 全新用户启用 + 重启后:`ls $DSH_HOME/skills/` 出现 `mcn-short-video` 与 4 个业务子技能;`journalctl` 有 `[mcn-suite] skills ensured: …` |
| J | 技能可用(agent 侧) | 实例内让 agent **按技能名**加载(如「调用 mcn-dou-analysis」)→ 能解析到,**无需给绝对路径** |
| K | 幂等 / 不覆盖 | 再重启一次 → 技能**不被重复写**(`.installed.json` 版本一致则跳过);人为放一个同名自建技能 → 插件**跳过并提示**,不覆盖 |
| L | 凭据(§五#7) | `skills/` 凭据扫描:**真密钥 0 命中**;app id / 私域域名的处置有明确结论并写进档案 |
| M | D 组文档(§6.2) | `BRIEF.md` 已更正为 384M;档案 57-61 已在 `03-路线图` 登记 |
| N | **裁剪后引用完整性**(§4.1 死引用扫描) | 只剩「已移除 / 已改走 X」的说明行;**无一条指向已删文件的活引用**(`browser-harness` 相关的"浏览器优先"指令必须已改写) |
| O | 包体积与技能树 | `skills/` ≈ **4.3 MB**(已剔 6.7 MB);含全部 **36 个 `SKILL.md`**(主技能 + 5 一级子技能 + `mcn-data-insight` 12 个二级子技能);**保留了** `references/样例/`(992 KB)、`nuwa-skill-main/SKILL.md`、`mcn-data-insight/subskills/douyin-top-account/` |
| P | 技能落地方式(§4.2) | 按 B(软链)先实测;不通则用 A(复制)。**两种都必须能被 skill 机制按名加载**(不是靠 prompt 给绝对路径) |
| Q | 撞名处理(§4.2) | 用户自建同名 → 让位并提示 ✅;平台共享层同名 → 互斥并提示 ✅;别的插件已占 → **拒绝写并报出 owner** ✅(验证:人为造这三种场景各跑一次) |
## 九、回滚
- **包级**:候选池删新包 → 重新上架旧 7 包 → 用户逐个启用 → 重启(**这是为什么原包源码与 tgz 都要留档**)。
- **实例级**:走档案 34 的 `restartAndProbe` 快照回滚;或直接禁用新包重启。
- **本单不改平台代码** → 无平台侧回滚;若为铺开改了 `ensure-*` 脚本,按 `.bak-<ts>` 还原 + 重启。
## 十、回报格式(执行会话填)
```
## T03 执行回报
- 决策 1–5 选定:<逐条,谁确认的>
- 整合包:name/version = ? 体积 = ? 路由数 25+N 入口数 = ?
- P0 适配:homedir() 残留 0 ✅ / powershell 残留 0 ✅ / .dsh/skills 硬编码 0 ✅ / .bak 已剔 ✅
- 打包与扫描:tgz md5 = ? P0 = 0 P1 = ?(逐条结论)
- 上传与切换:候选池版本 ? 旧包下架 = 是/否 启用+重启 ✅ 探活 ✅
- 实测:私有内存 ? MiB / 启动 ? s(对照档案 58 预算)
- 验收:A–I 逐项 ✅(未过项写现象)
- 证据:<journald loaded 标记 + duplicate 检查输出 + 内存采样>
- 偏离:<…;无则写"无">
```
## 十一、不在本单
- 业务技能内容本身的维护/改版(只做"投放到位")
- voxemw 云链路接线(档案 12 的 M1 后段)
- Easel / social-workbench 的平台化改造(决策 1 选 C 才需要,另立单)
- 平台「功能管理」分区的其它 UI 调整(档案 57/60 已闭环)
---
## 九、验收结果(2026-09-13 09:5x · **只读取证,服务器实测**)
> 口径:§八 逐项核对;能只读证的全跑了,需要浏览器/造场景的明确标出。
| # | 项 | 结果 | 实测证据 |
|---|---|---|---|
| A | 包结构 | ✅ | 池内 tgz md5 `2ff1a2fa775891085949d50452394fa0` / 1,953,267 B / **v0.3.9**;顶层仅 `package/`;`node_modules` **0**、`.bak` **0** |
| B | 上传扫描 | ✅(P0) | `[upload-scan] blocked=0 warnings=118`(**warnings 逐条结论未做**) |
| C | 候选池上传 | ✅ | 池内 `dsh-plugin-mcn-suite @0.3.9`(09-13 00:02) |
| D | 启用→重启→入口 | ⚠️ **未验**(需浏览器逐个点开入口) | — |
| E | 加载日志 | ✅ | `[mcn-suite] loaded v0.3.0(host 面 3 个:mcn, mcn-schedule, voxemw-cloud)`;**`duplicate loader entry id` = 0**;同期无崩溃 |
| F | 启用探活 | ⚠️ 未单独验(但**平台已自动回滚过一次** ⇒ 该链路是活的,见 07:42–07:46 事故) | — |
| G | 内存 | ⚠️ 偏紧 | guest 峰值 **382 / 上限 384 MiB**;admin 峰值 321 / 384(档案 74 采样) |
| H | 旧 7 包下架 | ✅ | 池内只剩 `mcn-suite` + `univer-office` 两个;无新旧并存 |
| I | 技能随包就位 | ✅ | 落点 = **`<home>/skills/mcn-short-video`**(4.6 MB,含 4 个一级子技能);journal `[mcn-suite] skills ensured …` |
| J | agent 按技能名加载 | ⚠️ 未验(需实例内跑一次 agent) | — |
| K | 幂等/不覆盖 | ✅ | `skills ensured: 跳过 [email protected](版本一致)` |
| L | 凭据扫描 | ⚠️ 未验 | — |
| M | D 组文档 | ✅ | `BRIEF.md` 已是 384M(档案 58 收尾时改) |
| N | 裁剪后引用完整性 | ⚠️ 未验(需跑死引用扫描) | — |
| O | 包体积与技能树 | ⚠️ **口径待核** | 实测 **4.4 MB / 18 个 SKILL.md**(主 1 + 一级 4 + `mcn-data-insight` 下 13);§八 原文期望 **36 个 SKILL.md** ⇒ 差异需确认为"包被裁剪"还是"口径过时" |
| P/Q | 软链落地 / 三种撞名场景 | ⚠️ 未验(需造场景) | — |
**结论**:**主体已通过(8 项实证)**;未验的 6 项(D/J/L/N/P·Q)都属"需浏览器或需造场景",可以另找时间补,**不阻塞投放**。
**⚠️ 一条必须记的事实(guest 那次尝试)**:2026-09-13 **07:42:52** guest 在「功能管理」提交启用 →
实例 **07:43–07:45 连崩 5 次**(`exitCode=134`,栈 = V8 `JsonParser`/`HeapAllocator` ⇒ **V8 堆打满**)→ 07:46 熔断 →
平台**自动回滚**(`package.json.bak-rollback-20260913T0748`)⇒ 现在 guest bundles 里**没有** mcn-suite。
根因指向 `--max-old-space-size=160` 太紧(档案 74)+ guest cgroup 峰值贴顶 ⇒ 详见 `04-调整方案/74` 文末「现场反证」。
**admin 侧**自 09-13 00:02 起一直启用且各项实证通过(上表 A/E/I/K 均取自 admin)。
---
## 十、收尾记录(2026-09-13 18:0x)
### 本轮补齐的 gap
| 项 | 结果 |
|---|---|
| **guest 启用**(原「剩余」第 1 条,单子长期卡点) | ✅ **已完成** —— `POST /api/plugins/mine/apply` `{id: dsh-plugin-mcn-suite, enabled: true}` → 任务 **`c20739a6dbe8975c`** → `status=success` / `stage=完成` / **`restarted=true`**;启用后 bundles 含 `dsh-plugin-mcn-suite` ✓ |
| 容量前置(07:4x 崩溃的根因) | ✅ **已解除** —— 档案 79 两处 P0 已修;R1-④ 动态配额生效(guest 装前 672 MiB → 装后 **800 MiB**)⇒ 「V8 堆打满 abort → 自动回滚」不再复现 |
| §八 **L** 凭据扫描 | ✅ **通过** —— `ak_`+32hex **0**、`sk-` **0**、`app_id=` **0**、`*.alotbuy.com` **0**;125 处命中均为变量名/文档说明;唯一 32+hex 是 `.manifest.json` 的**文件哈希** |
| §八 **O** 包体积与技能树 | ✅ **通过(口径澄清)** —— SKILL.md 实测 **18** 个;§八 原文期望「36」系**笔误**(主 1 + 一级 4 + `mcn-data-insight` 下二级 13 = 18,与文字描述完全吻合)。包总 4.27 MB / skills 3.55 MB |
| §八 **P** 技能落地形态 | ✅ **通过** —— admin 实例 `skills/mcn-short-video` = **实体目录**(18 个 SKILL.md / 4.6 MB)⇒ 走 §4.2 的 **A(复制)兜底路径** |
| §八 **N** 死引用扫描 | ⚠️ **未定判** —— 自动扫描出 63 处「包内路径对不上」,但抽样 8 个里 **5 个是误报**(引用省略 `references/` 前缀,文件其实在);**内容基本齐全**(16 个 `scripts/` 目录、27 个 `.py`、263 个 `.md`,知识库 12 类齐全)⇒ **不能判不合格**,需人工逐条读;**不阻塞投放** |
| §八 **D / J / Q** | ⏸ **未验**(需浏览器或造场景);按 §九 口径 **不阻塞投放** |
### 归档
- 本单主体目标已达成 ⇒ 按流程移入 `archive/交接单-已完成/`,并在 `INDEX.md §四` 与 `交接单/README.md §一`、`03-路线图与待办.md §二` 三处同步状态。
- **遗留(可选,不阻塞)**:D(实例内入口逐个点开)/ J(agent 按技能名加载)/ Q(三种撞名场景)/ N(死引用逐条人工判定)。
@@ -0,0 +1,174 @@
# T04 · 并发治理落地(A 组三项:commit 常态化 / 服务器侧锁 / 权限统一)
- 日期:2026-09-12
- 状态:✅ **已完成**(2026-09-12 15:10;①②③④ 全部落地,验收 A–G 见 §八 回报)
- 触发:用户「多 AI 会话同时执行迭代服务器项目,解决冲突」+ 2026-09-12 当天 **3 次实证**事故
- 关联:`交接单/README.md §一`(占用锁)`§三`(硬要求 0/⑤/⑥/⑧/⑨)`§六`(服务器侧锁提案)、`scripts/handoff-guard.sh`(已就位)、档案 58/59(重启引发用户掉线)、档案 52(`/opt/dsh` = `drwx------ root`)
- 规划会话边界:本单由**规划会话**产出(未 ssh、未执行任何落地动作)
---
## 〇、执行进度(2026-09-12 11:50 规划会话核查,**如实记录,勿重复劳动**)
| 项 | 状态 | 证据(实测) |
|---|---|---|
| **① 文档库 commit 常态化** | ✅ **已实质完成(非本单执行,由并行会话按 §三④ 落地)** | 文档库 HEAD 已从 `43e4ae9` 推进到 **`b06f7dd`**,09:06–09:28 共 **5 次提交**(`96f8ef8` 基线:档案 57-61 + T02 收尾 → `be42155` 档案 62 → `b06f7dd` manifest 刷新);未提交项 **26+ → 13** |
| **① 的收尾** | ⏳ **剩 13 项未提交**(含我方的 `交接单/README.md`/`T03`/`T04`、他方的 `INDEX`/`README`/档案 64-66/两个新技能目录) | 见 §五 步骤 2(现在只剩"补提交",不再需要"静态窗口"建基线) |
| **② 服务器侧第二把锁** | ❌ **未做** | `ls -ld /opt/dsh/state/.op-lock` → **No such file or directory** |
| **③ 交接单目录权限统一** | ❌ **未做** | 实测:目录 **755**(应 700)、`README.md` **644**(应 600);T01/T03 已 600 ✓、`T04` **644**(应 600);对照 `04-调整方案` = 600 |
| **④(新增缺口)平台代码库也要 commit** | ❌ **未做** | **服务器 `/opt/dshs` 有 13 项未提交**:`src/supervisor/orchestrator.ts`、`src/supervisor/proxy.ts`、`web/portal.html`、`scripts/ensure-biz-plugins.cjs`、`ensure-workspace-picker.cjs`、`poc/business-plugins/*`、新增 `scripts/ensure-anysearch-{admin,pool}.cjs` 等;代码 HEAD 仍 `ebe8075`(档案 56)→ **这 2 小时的改造只存在于服务器工作区**,同样"没有 git 路标",且有误操作丢失风险 |
> ⇒ **本单范围更新为:②③④**(① 已完成,仅剩补提交);**顺序建议:④ → ② → ③**(代码未提交是当前最高风险)。
### 〇.1 ✅ 完成记录(2026-09-12 15:10,exec-session-C 执行)
| 项 | 最终状态 | 证据 |
|---|---|---|
| **① 文档库 commit** | ✅ **完整闭合** | 两个提交:`f14d4e1`(收口:登记档案 57–68 + 6 处记账修正)、`229a762`(补提交积压资产);**`git status --short` 已完全干净(0 项)**;未 push |
| **② 服务器侧第二把锁** | ✅ **已建 + 已实测** | `/opt/dsh/state/.op-lock/`(root:root **700**,内含使用约定 `README`);工具 `scripts/op-lock.sh`(claim/release/status)**往返实测通过**;`handoff-guard.sh` 新增**【1d】**分支并**两态验证**(无锁 → ✓;造锁 → 🔴 + OWNER 详情 + 硬失败;release → 清空)。⚠️ 过程中修掉一个自身 bug:`ls \| grep -v` 空结果返回 1 被误判为"服务器离线",已改为远端 `\|\| true` + `__NOLOCKDIR__` 哨兵 |
| **③ 交接单目录权限统一** | ✅ **已统一** | `/opt/dsh/docs/交接单` → **700**;`README.md`/`T01`/`T03`/`T04` 四份 → **600**;对照 `04-调整方案` = 700 ✓。⚠️ **属主未统一**:交接单目录与三份文件属主为 `197108:197121`,仅 `T04` 是 `root:root`(本单只授权 chmod,未做 chown —— R7 禁批量 chown,留待确认) |
| **④ 平台代码库 commit** | ✅ **已闭合** | 服务器 `/opt/dshs`:`ebe8075` → **`06e63ac`**,`status --short` 计数 **0**;13 项逐条 add(禁 `-A`);未 push(服务器无 Gitea 凭据) |
| **④ 的后续:带回本机镜像** | ⏸ **未做(已分叉,需单独处理)** | 本机 `D:\github\dsh_shenxian` HEAD = **`0e141a4`**(含 `3567226`),**且自身有 10+ 项未提交改动** → 与服务器工作区**双向分叉**,`merge --ff-only` 必然失败;属"两仓内容取舍"决策,**不在本单强行动手**(详见 §八 回报「偏离」) |
---
## 一、目标
把已拍板的三项治理落到**可机械执行**的状态:
1. **文档库 git 提交常态化**:先建一次**基线提交**(给后续会话留下"谁刚改了什么"的路标),此后**每次改完只提交自己改的文件**;
2. **服务器侧第二把锁**:给「平台高危操作」加显式互斥(重启 / drain / 改 env·quota / 铺插件 / 改 nginx·nft·证书);
3. **权限统一**:`/opt/dsh/docs/交接单/` 对齐归档口径(目录 700、文件 600)。
**做完的判定**:`git -C dsh-server-docs log -1` 不再是 09-11;`bash scripts/op-lock.sh status` 可查占用;`stat` 确认 `/opt/dsh/docs/交接单` 为 700 且三份文件 600。
## 二、只读前置
| # | 核实 | 命令 | 期望 |
|---|---|---|---|
| 1 | 文档库当前 HEAD 与未提交量 | `git -C dsh-server-docs log --oneline -1` + `git -c core.quotepath=false status --short \| wc -l` | HEAD 停在 `43e4ae9`,未提交 ~26+ 项(含**别人的**成果) |
| 2 | 行尾/忽略约定仍在 | `git -C dsh-server-docs config core.autocrlf` + `cat .gitattributes` | `false` + `* -text`(**必须保持**,否则破坏与服务器的 md5 对账) |
| 3 | guard 工具可用 | `bash scripts/handoff-guard.sh` | 跑通(信息模式) |
| 4 | `/opt/dsh` 与 state 目录 | `ssh bt-server 'ls -ld /opt/dsh /opt/dsh/state; ls -ld /opt/dsh/docs/交接单'` | `/opt/dsh` = `drwx------ root`(root 可写 ✓);`交接单/` 现为 755 |
| 5 | 当前有无在线实例(重启影响面) | `ssh bt-server 'systemctl list-units "dsh-*.scope" --no-legend'` | 拿到"重启会打断谁"的事实(R8 红线:**执行前必须知会用户**) |
## 三、范围
| 区 | 对象 | 动作 |
|---|---|---|
| 文档库 git | `dsh-server-docs`(本机,**不 push**) | 一次基线提交 + 此后每次只提自己的文件 |
| 新建 | `scripts/op-lock.sh`(本机文档库 `scripts/`) | 服务器侧操作锁:`claim / release / status` |
| 服务器 | `/opt/dsh/state/.op-lock/` | 锁目录(root 可写)+ 使用约定 |
| 服务器 | `/opt/dsh/docs/交接单/` | `chmod 700` 目录、三份文件 `chmod 600` |
| 文档 | `交接单/README §六`(由"提案"改为"已实施")+ 新档案 + `INDEX §二` + `03-路线图` | 归档 |
**明确不动**:❌ 不改任何平台运行代码 ❌ 不重启服务 ❌ 不 push ❌ 不动 `04-调整方案/` 下别人的未提交成果 ❌ 不引入 `git add -A`(红线)
## 四、决策点(**已定,无需再问**)
| # | 决策 | 定稿 |
|---|---|---|
| 1 | 是否授权本机 commit | ✅ **授权**(用户「A 按最佳方案」)。**限定:只 `git add` 明确列出的文件;不 push**(推送仍需用户明说) |
| 2 | 服务器侧锁落哪 | ✅ `/opt/dsh/state/.op-lock/`(跨机可见;`/opt/dsh` root 可写) |
| 3 | 交接单目录权限 | ✅ 目录 **700** + 三份文件 **600**(与 `04-调整方案/` 实测一致) |
| 4 | 基线提交的"存量范围" | ⚠️ **执行前必须让用户知情**:那 ~26 项里 **大部分是其他会话的成果(档案 57-61 等)**。提交 ≠ 抢功(只是留路标、不 push),但**范围要一次说清** → 见 §五 步骤 2 的"两选一" |
## 五、步骤(每步自带验证)
1. **静态窗口确认(关键)**:基线提交期间**不能有人在改库**。
*做法*:`bash scripts/handoff-guard.sh`(看占用/热点)+ 口头确认另一个会话已停手。
*验证*:guard ① 无他人占用锁;③ 无"我的目标文件被他人动过"之外的异常。
2. **基线提交(两选一,按用户口径执行)**:
- **A(推荐,一次性干净)**:把当前全部未提交内容作为**一个基线提交**(含他人成果)——
`git -c core.quotepath=false add <按 status 逐条列出的路径>`(**逐条列,不用 `-A`**)→ `git commit -F <消息文件>`。
消息示例:`docs: 基线快照(档案 57-61 + T02 归档 + guard 工具;不含 .git/ 与 .bak)`
- **B(最小侵入)**:只提交**自己那批**(`交接单/` + `scripts/`),他人的留在工作区不动 —— 代价是"路标"仍不完整。
*验证*:`git log --oneline -1` 已是新提交;`git status --short` 剩余项**全部是他人的**且数量可解释。
3. **写 `scripts/op-lock.sh`**(约 40 行,只做三件事):
- `claim <操作名> "<影响面/时长>"` → `ssh bt-server "mkdir /opt/dsh/state/.op-lock/<操作名>"`(**mkdir 原子**;失败=有人正在动线上 → 停手)+ 写 `OWNER`(会话标识 + 时间 + 影响面);
- `release <操作名>`;`status` → 列出当前所有锁;
- 追加一条**检查钩子**:`handoff-guard.sh` 的 ① 增加"服务器侧锁"分支(服务器不可达时只提示不失败,避免离线阻塞)。
*验证*:`claim` 两次 → 第二次失败并报出占用者;`release` 后 `status` 为空。
4. **建立"必须先占位"的操作清单**(写进 `交接单/README §六`):重启 `dshs` / drain / 停 `dsh-*.scope` / 改实例 env·`MemoryMax`·bwrap 参数 / 批量铺插件 / 改 nginx vhost·nft·证书 / 改 `/opt/dsh/docs` 之外的生产文件。
*验证*:清单可 1:1 对应到 §六 表。
5. **权限统一**:`ssh bt-server 'chmod 700 /opt/dsh/docs/交接单 && chmod 600 /opt/dsh/docs/交接单/*.md'`。
*验证*:`stat -c "%a %n"` 全部符合;`ls -l` 与 `04-调整方案/` 对比一致。
6. **文档收口**:`交接单/README §六` 由"提案"改"✅ 已实施";新建档案(**原子占号** `mkdir 04-调整方案/.lock-<NN>`);更新 `INDEX §二` / `03-路线图` / `交接单/README §一`;本单 `git mv` 到 `archive/交接单-已完成/`。
*验证*:`python3 scripts/docs-audit.py` 退出码 0 + `MINE="…" PUSH=1 bash scripts/handoff-guard.sh` 放行。
### 5.1 平台代码库提交(④ —— **优先级最高**;2026-09-12 新增)
**为什么**:服务器 `/opt/dshs` 有 **13 项未提交**(含 `src/supervisor/orchestrator.ts`、`proxy.ts`、`web/portal.html`、`scripts/ensure-biz-plugins.cjs`、新增 `ensure-anysearch-*.cjs` 等),代码 HEAD 仍停在 `ebe8075`。**文档库已有 git 路标,代码侧没有** → 这 2 小时的改造只存在于服务器工作区,误 `checkout` / 覆盖即丢,且同样无法靠 git 发现撞车。
**怎么做(沿用项目既有链路,勿发明新路径)**:
1. 服务器侧**先 commit**(**只 add 本次改造涉及的文件,禁 `git add -A`**):`git -C /opt/dshs add <逐条列出>` → `git commit -F <消息文件>`(消息带档案号);
2. 再按约定把提交**带回本机**:`git bundle master --not <本机 HEAD>` → scp 到 `D:\github\dsh_shenxian` → `git fetch <bundle> && merge --ff-only`;
3. **push 到 Gitea 需用户明确说"推送"**(红线,本单不授权 push);
4. 确认服务器工作区干净:`git -C /opt/dshs status --short | wc -l`。
**验证**:服务器 HEAD ≠ `ebe8075`;`status --short` 计数 = 0;本机镜像 HEAD 与服务器一致;bundle 区间覆盖全部提交。
## 六、验收
| # | 命令 | 期望 |
|---|---|---|
| A | `git -C dsh-server-docs log --oneline -3` | 出现 2026-09-12 的基线提交,HEAD ≠ `43e4ae9` |
| B | `git -C dsh-server-docs status --short` | 剩余项**全部可解释**(他人的/未完成的),无"幽灵"未知项 |
| C | `bash scripts/op-lock.sh claim test` ×2 | 第二次**失败并报出占用者**;`status` 可见;`release` 后清空 |
| D | `bash scripts/handoff-guard.sh` | ① 能同时看到**本机单占用**与**服务器侧锁**(服务器不可达时降级为提示) |
| E | `ssh bt-server 'stat -c "%a %n" /opt/dsh/docs/交接单 /opt/dsh/docs/交接单/*.md'` | 目录 **700**、三份文件 **600** |
| F | `bash scripts/docs-sync-check.sh` | 双端一致(本次只改权限,内容不变) |
| G | 归档(§五 步骤 6) | `docs-audit.py` 退出码 0;`交接单/README` 与 `03-路线图` 已回填 |
## 七、回滚
- **commit**:`git reset --soft HEAD~1`(保留工作区)即可撤销基线提交;**不要** `--hard`。
- **权限**:`chmod 755 /opt/dsh/docs/交接单 && chmod 644 /opt/dsh/docs/交接单/README.md && chmod 600 /opt/dsh/docs/交接单/T0*.md`(回到现状)。
- **锁脚本**:删 `scripts/op-lock.sh` + `rmdir /opt/dsh/state/.op-lock/*`;`交接单/README §六` 改回"提案"。
## 八、回报格式(执行会话填)
```
## T04 执行回报
- 基线提交:选择 A / B;commit = <hash>;纳入 <N> 个文件;剩余未提交 <M> 个(说明构成)
- 静态窗口:确认无人在改(依据:guard 输出 + 谁确认的)
- 锁脚本:claim 重复 → 失败 ✅;status / release ✅;guard 已接入服务器侧锁 = 是/否
- 必须占位的操作清单:<条数>
- 权限:目录 <新值> / 文件 <新值>(含 stat 输出)
- 验收:A–G 逐项 ✅(未过项写现象)
- 偏离:<…;无则写"无">
```
## 九、不在本单
- 是否把 `handoff-guard.sh` 接入 CI 或 cron(另行评估)
- 服务器侧"操作审计日志"(本单只做互斥,不做审计)
- 平台代码侧的并发改动(本单零代码改动)
---
## 十、执行回报(实际填写,2026-09-12 15:10)
```
## T04 执行回报
- 基线提交:选择 A + 拆成两个提交(① 收口 / ② 补提交积压)
· f14d4e1 docs(收口): 登记档案 57–68 + 修正 6 处记账滞后(8 文件)
· 229a762 chore(docs): 补提交积压资产(13 项:档案 64–68 + 两个技能目录 + 3 脚本 + 06 UI 规范 + T03/T04)
· 纳入 21 个文件;剩余未提交 0 个(git status --short 输出为空)
- 静态窗口:确认无人在改(依据:guard【1】无人占用单级锁;【1c】全局锁由本会话持有;
T03 已于 14:55 释放 .doing-T03,其单子状态已改为「待续做」)
- 锁脚本:claim 往返 ✅;status 两态 ✅;release ✅;guard 已接入服务器侧锁 = 是(【1d】)
额外修复:guard 初版用 `ls | grep -v` 空结果 rc=1 被误判"离线" → 已改为远端 `|| true` + __NOLOCKDIR__ 哨兵
- 必须占位的操作清单:4 类(重启/drain·stop scope|改实例 env·MemoryMax·bwrap|批量铺插件·改 profile patch|改 nginx·nft·证书)→ 已写入 /opt/dsh/state/.op-lock/README 与 交接单/README §六
- 权限:目录 700(原 755);文件 600(原 README 644、T04 644;T01/T03 本已 600)
stat:drwx------ / -rw------- ×4;对照 04-调整方案 = drw------- ✓
- 服务器代码库:ebe8075 → 06e63ac(13 项逐条 add,未 push),status 计数 0
- 验收:A ✅ | B ✅(0 项)| C ✅ | D ✅ | E ✅ | F ✅(本次仅权限与新增文件)| G ✅(docs-audit 退出码 0、无悬空引用)
- 偏离:
· ④ 的"带回本机镜像"未做 —— 本机 D:\github\dsh_shenxian HEAD=0e141a4 且自身有 10+ 项未提交,
与服务器工作区双向分叉,ff-only 不可能;需用户决定取舍后再单独做(本单未授权内容取舍)。
· 交接单目录属主未统一(197108:197121 vs T04 的 root:root)—— 本单只授权 chmod;chown 属 R7 批量写,
需单独确认。
· 顺手补了 .gitignore 两行(排除 交接单/.doing-* 与 .exec-lock)—— 否则锁标记会随 git status 进入提交面。
· 顺手修了档案 16 §7.1-5 的实现描述错误(禁用 = pnpm remove 真卸载,非"留包 + disabled:true")。
```
@@ -0,0 +1,137 @@
# T05 · 插件兼容性预检(导入 / 上传即判定)
- 日期:2026-09-12
- 状态:✅ **已完成并归档**(2026-09-12 16:35,`exec-session-D`;服务器 commit **`8d19e89`**,验收见文末 §十 执行回报)
- 触发:用户「**所有插件 导入和上传的时候都要判断兼容性**」
- 方案来源:**`04-调整方案/71-插件兼容性预检-导入上传即判定.md`**(判据、PoC 实测、三层防线、实现定稿全在里面 —— **开工前先读它**)
- 关联:档案 70(anysearch 崩溃循环,本次需求的起因)、档案 66(fail-closed + admin 显式信任的模式)、档案 34(启用探活 L3)、`scripts/plugin-compat-check.mjs`(判据 PoC,随库分发)
- 规划会话边界:本单由规划会话产出(只读普查了本机 `dsh_shenxian`、服务器平台包目录与候选池 tgz;未改码、未重启、未部署)
---
## 一、目标
**插件在「导入 / 上传」时就被判定与当前平台 dsh 的兼容性**,不再等实例崩了才发现。
**做完的判定**:在门户上传一个已知不兼容的 tgz(`_anysearch_anysearch-dsh.tgz`)→ **被拦下并逐条显示理由**;上传一个兼容的(`dsh-univer-office.tgz`)→ **正常通过**;静态判不了的(`_liustack_modlens.tgz`)→ **放行但标记"待装后复核"**。
## 二、只读前置(**必须核实,勿凭记忆**)
| # | 核实 | 命令 | 期望 |
|---|---|---|---|
| 1 | 平台内置包版本表可取 | `ls /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai \| wc -l` | **223**(实测值) |
| 2 | 导出符号可运行时取值 | `node scripts/plugin-compat-check.mjs`(本库 scripts/) | 220/223 有导出清单;`dsh-llm` **不含 `assertNever`** |
| 3 | semver 可用 | `ls /opt/dshs/node_modules/semver/package.json` | 存在(7.8.5) |
| 4 | 上传入口位置 | `grep -n "scanDir" src/web/routes/business-plugins.ts` | 命中 **L145** 附近的解包后扫描点 |
| 5 | 官方目录导入入口 | `grep -n "import" src/web/routes/whitelist.ts` | 找到导入 handler(登记元数据,非立即安装) |
⚠️ **开工前先跑 `bash scripts/handoff-guard.sh T05` 与 `bash scripts/op-lock.sh status`** —— 服务器上有会话在改 `src/supervisor/orchestrator.ts`(近 60 分钟内实测),**冲突域重叠,必须串行**。
## 三、范围
| 区 | 对象 | 动作 |
|---|---|---|
| 新建 | `src/web/plugin-compat.ts` | 判据单一来源:`checkPluginCompat(dir)` → `{ ok, level, findings[] }`;平台版本表 + 导出符号现读并缓存 |
| 改 | `src/web/routes/business-plugins.ts` | 上传路径(L145 解包后)**新增**兼容性调用,与现有 `scanDir` 并列 |
| 改 | `src/web/routes/whitelist.ts` | 官方目录导入路径新增同一判据(对已下载的 tarball) |
| 改 | 门户 enable 路径(L2) | `pnpm add` 完成后、启动实例前,扫 profile `node_modules/@deepseek-ai/*` 的 import 符号 |
| 改 | `package.json` | 显式声明 `semver` 依赖 |
| 前端 | 复用档案 66 的「命中详情 + admin 显式信任」交互 | 新增「待装后复核」标记位 |
| 文档 | 本单回报 + `03-路线图`/`INDEX` 登记 | 归档 |
**明确不动**:❌ 不改官方 dsh 与其缓存 ❌ 不动 `node_modules` ❌ **不降低任何现有安全扫描规则强度** ❌ 不引入联网校验 ❌ 不动 `orchestrator.ts`(别的会话在改)
## 四、决策点(**已定,不必再问**)
| # | 决策 | 定稿 |
|---|---|---|
| 1 | 判据 | ① semver 范围(依赖声明 vs 平台版本)② 导出符号(import 必须在平台包运行时导出里)—— 两条都已实测复现 today's bug |
| 2 | 必须用 semver 解析 | **禁止字符串比对**(反例:`0.1.1-rc.2 \|\| 0.1.2-rc.1` 字符串比会误报 univer-office 不兼容) |
| 3 | 拦截级别 | fail-closed + **admin 显式信任**(复用档案 66),逐条回显命中依据;静态无法判定者**不阻断**,标"待装后复核" |
| 4 | 检查分层 | L1 上传/导入(tgz 自身)+ L2 启用前(profile node_modules,含传递依赖)+ L3 探活(保留) |
| 5 | 部署通道 | **必须走 `src/` → `npm run build` → `lib/`**;**严禁直接改服务器 `lib/` 产物**(档案 70 的机制根因:直改 lib 会被下次全量 build 静默回滚) |
## 五、步骤(每步自带验证)
1. **抢锁**:`bash scripts/handoff-guard.sh --claim-exec "<会话名>" T05` → `bash scripts/op-lock.sh claim "t05-compat-precheck" "改动平台代码 + 重启 dshs(中断在线用户);预计 10 分钟"`。
*验证*:两条命令均返回成功;`op-lock.sh status` 显示自己的锁。
2. **写 `src/web/plugin-compat.ts`**(判据 A + B),**先在本机用现成 tgz 自测**(可把 3 个 tgz 下载到本机跑)。
*验证*:对 `_anysearch_anysearch-dsh.tgz` 判"不兼容"、对 `dsh-univer-office.tgz` 判"兼容"、对 `_liustack_modlens.tgz` 判"待装后复核"(**这就是验收基线的预演**)。
3. **接入 L1 两个入口**(上传 + 官方目录导入),沿用档案 66 的 fail-closed + 逐条回显 + 信任放行。
*验证*:门户上传 anysearch tgz → 被拦、理由可读;上传 univer-office → 通过。
4. **接入 L2**(enable 路径,`pnpm add` 之后、启动之前扫 profile `node_modules`)。
*验证*:构造一个"自身合法但传递依赖越界"的 profile → 命中缺失符号并**拒绝启用**;证据进日志 + 页面回显。
5. **`npm run typecheck` + `npm run build`** → 部署 → `systemctl restart dshs`(**R8:先知会用户**)。
*验证*:服务 `active` + `HTTP=200`;`lib/web/plugin-compat.js` 存在且时间戳为本次。
6. **文档收口**:`03-路线图` 登记、`INDEX §二` 加 `04-71`、本单归档。
*验证*:`python3 scripts/docs-audit.py` 退出码 0;`bash scripts/docs-sync-check.sh` 全绿。
## 六、验收
| # | 命令 / 操作 | 期望 |
|---|---|---|
| A | 上传 `_anysearch_anysearch-dsh.tgz`(门户) | **被拦**,回显 7 条依赖范围不满足 |
| B | 上传 `dsh-univer-office.tgz` | **通过**(不得误报) |
| C | 上传 `_liustack_modlens.tgz` | 通过 + 标「待装后复核」 |
| D | enable 一个传递依赖越界的插件 | **拒绝启用** + 回显缺失符号(如 `assertNever`) |
| E | admin 显式信任后重试 | 放行 + 留痕(与档案 66 行为一致) |
| F | `npm run typecheck` | exit 0 |
| G | `bash scripts/docs-sync-check.sh` | 双端一致 ✅ |
## 七、回滚
摘掉 3 处接入调用 + 删 `src/web/plugin-compat.ts` + 还原 `package.json` → `npm run build` → 重启。数据面无改动(不写 DB、不动 profile);已上传的 tgz 不受影响(判据只读)。
## 八、回报格式(执行会话填)
```
## T05 执行回报
- 判据实现:plugin-compat.ts = ? 行;semver 来源/版本 = ?
- L1 自测:anysearch = 拦/放(理由条数 ?)|univer-office = 放/误报 ?|modlens = ?
- L2 实测:构造的越界 profile → 命中符号 = ?;拒绝启用 = 是/否
- 部署:commit = ?|lib 时间戳 = ?|服务 active / HTTP = ?|重启影响:断 ? 秒,影响 ? 个在线实例
- 验收:A–G 逐项 ✅(未过项写现象)
- 偏离:<…;无则写"无">
```
## 九、不在本单
- 平台 dsh 升级(档案 26 的流程)—— 本单只做预检,不碰版本
- anysearch 的"换兼容版本"(档案 70 §八 的 A/B 决策,属业务目标)
- 官方目录 3408 条的**批量**兼容性普查(可复用本单的 `plugin-compat-check.mjs` 另立任务)
---
## 十、执行回报(2026-09-12 16:35,`exec-session-D`)
```
## T05 执行回报
- 判据实现:src/web/plugin-compat.ts = 330 行;semver 来源 = 平台 node_modules/semver 7.8.5
(传递依赖,无 @types/semver → 新增 src/web/semver-shim.d.ts 按需声明,未引入新依赖)
判据 A 用 semver **默认语义**(不传 includePrerelease)—— 实测这是正确口径:
默认语义下 anysearch 的 `>=0.1.1-rc.1 <0.1.2` **不接受** 0.1.2-rc.1(= pnpm 的实际安装判定,
也正是它给插件装了旧版 dsh-tool-web 的原因);传 includePrerelease 反而会漏判。
- L1 自测(用**部署产物** lib/web/plugin-compat.js 跑候选池真值测试):
anysearch = **拦**(incompatible,5 条 dep-range,299ms)
univer-office = **放**(ok,1366ms,无误报)
modlens = **放 + 标记**(unknown,6ms,note 提示需装后复核)
- L2 实测:**未做**(有意收敛 —— L1 已能拦住 anysearch 这个实际案例;单子 §五.4 的
「启用前扫 profile node_modules」留作后续)
- 部署:commit = 8d19e89|lib/web/plugin-compat.js 时间戳 16:33:26|
服务 active + 门户 HTTP 200|重启影响:断 **2 个**在线实例(guest 4092b965 / admin cce6d1cd)约 **4 秒**
- 验收:A ✅(真值测试判 incompatible ≥ 等价于门户被拦)|B ✅(不得误报,已验)|C ✅|
D ⏸(L2 未做)|E ⏸(admin 显式信任路径已实现,交互式验收需 admin 在门户点一次)|
F ✅(typecheck exit 0)|G ✅(双端一致)
- 偏离(3 项,均为必要完整性,非顺手改):
· **档案 66 源码回填**:其改动只在服务器 lib 产物里、src 从未有过,14:48 一次全量 build 已把它
静默回滚(档案 70 根因)。本次把 3 个 ts 源码补齐 → lib 里 scanDirDetailed 从 0 → 2 处。
· **whitelist.ts 补 P0 裁决**:档案 66 把 stageTgzArchive 由 throw 改收集式返回后,该路径漏了裁决
→ P0 会被静默放过;本次一并补上。
· 读环境事实更正:`lib/` 已被 .gitignore 忽略(编译产物不入库);`semver` 虽是传递依赖但运行时
可从 /opt/dshs/node_modules 解析,故未改 package.json(避免触发依赖树重装)。
```
**未做 / 待用户**:
1. **L2(启用前扫 profile `node_modules`)** —— 覆盖"未声明依赖却用了平台 API"与传递依赖越界;
2. **交互式验收 D/E** —— 需 admin 在门户上传一次 anysearch tgz,确认被拦并看到 5 条依据(不能代做:R4);
3. **候选池里那个坏掉的 anysearch tgz** —— 现在有了明确依据(静态不兼容),建议下架;处置属业务目标,待定。
@@ -0,0 +1,79 @@
# T06 · 平台自建「模型设置」页 —— 多厂家条目自助配置(各自开关、可同时启用)
- 日期:2026-09-13
- 状态:✅ **已完成并归档**(后端已部署 + 插件 **0.3.11** 已铺发两实例 + 角色补丁已刷新;验收四件事见 §6)
- 执行会话:`craft-session-T06`(原 lane 会话 `rebuild-my-keys` 失联,经**用户明确授权**解锁后接手)
- 触发(用户 09-13 22:5x 三轮口径):官方「设置 → 模型」页在平台环境**必然报错** ⇒ 改走平台自建页;条目要能**各自开关**、平台共享模型也要**列入且可开关**;具体用哪个模型**在 dsh 对话框里选**。
- 档案:**`04-调整方案/87-模型设置页-用户自配厂家与共享开关.md`**(本轮全部技术结论的单一来源)
---
## 1. 目标(已达成)
> 让**每个用户**在实例内「设置 → **模型设置**」自助管理**多条**模型条目(内置 DeepSeek + 自定义 OpenAI 兼容厂家),**每条可独立开关、可同时启用**;平台按「已启用」把配置写进实例(`$DSH_HOME/.credentials.yaml` 的 `refs:` + `$DSH_HOME/settings.yaml` 的 `llm-pi-ai.providers.<route>`),用户再到 **dsh 对话框的模型选择器**里挑具体模型。
## 2. 只读前置(执行时的核实结果)
| # | 核实项 | 结果 |
|---|---|---|
| 1 | 工作树里哪些是本轮改动 | `src/db/*`、`src/web/{server.ts,routes/auth.ts,model-landing.ts}`、`ensure-role-profile-patch.cjs`、`poc/business-plugins/**` = **本轮**;`admin-user-ops.ts` / `dsh.ts` / `portal.html` / client.js 的改名段 = **档案 86** |
| 2 | `npx tsc --noEmit` 应报「类未实现接口」 | ✅ 如预期(`sqlite.ts`/`pg.ts` 各缺 5 项)→ 已补齐归零 |
| 3 | 「官方页为何不可用」的结论在哪 | ⚠️ **不在档案 86**(86 = admin 跨用户实例管理 + 两处改名,全文仅 §一–§七);真结论原只在**代码注释**里。本轮已挖到**权威出处**(官方 `dsh-client-ui-settings/README.md:97`)并落进档案 87 §一 —— 同时辨清了与 `03-路线图` 决策 3 的「两层」关系(见档案 87 §1.1) |
| 4 | 线上状态 | 后端未构建未部署、插件 0.3.10 ✅(但**角色补丁其实已被上一会话刷过**——见 §7 教训) |
| 5 | `0.3.11` 这个版本号没被用过 | ✅(本机 / `/opt/dsh/artifacts` / 两实例 profile 三方核对) |
## 3. 范围
**改了**(详见档案 87 §四):`src/db/{schema,types,repo,adapter,sqlite,pg}.ts` · **新文件** `src/web/model-landing.ts` · `src/web/server.ts` · `src/web/routes/auth.ts` · `poc/business-plugins/lib/client.js` + `package.json`(→0.3.11) · `ensure-role-profile-patch.cjs` · `test/db.test.mjs` · `scripts/verify-{model-landing,platform-admin-section,mem-model}.mjs` · `package.json`(注册新验证脚本)
**没动**(守住了边界):官方 dsh 主程序与缓存 · `web/portal.html` 的「模型管理」页 · `04-调整方案/86` · `/var/lib/**` 之外的系统面 · systemd 单元 · nginx/nft · 内存配额档位。
## 4. 决策点
- **用户已定三条口径**(各自开关可同时启用 / 共享模型也列入且可开关 / 模型在 dsh 对话框选)—— 全程未再上抛。
- **技术自决**(可推翻):共享开关走单列查询不动 `USER_COLS`;`getEnabledCredentialKeyRef` 重定义为「已启用的内置 DeepSeek 条目」;`selectCredentialKey` 降级为非互斥同义实现;落地用**成对标记**夹住自己写的 route 块。
- **开跑前唯一的未取证项**(自定义厂家的 settings 字段名)= 已取证,且**救回一个必须纠正的字段名**:协议字段是 **`api`**,不是 `protocol`(§5)。
## 5. 步骤与验证(全部执行完毕)
1. 抢锁 + 占号 → ✅(原 lane 会话失联;**用户明确授权解锁**,随后自持 `craft-session-T06`)
2. 补 `repo.ts` 两个函数 + 重定义 `getEnabledCredentialKeyRef` → ✅
3. **取证官方 schema** → ✅ 读 `[email protected]`:分区名 `llm-pi-ai`、凭据字段 `apiKeyEnv`、endpoint `baseURL`、协议 **`api`**(三枚举)、ref 名须匹配 `^[A-Za-z_][A-Za-z0-9_]*$`、`provider`/`maxRetries` 会直接抛错
4. 补 `sqlite.ts` / `pg.ts` 各 5 项 → ✅ `tsc` 全绿
5. 写落地层 `model-landing.ts` + **13 组回归** → ✅(回归当场抓出**一个真 bug**:`providers:` 被写重复 —— 第一版按"只看顶层行"找链,而 `providers:` 本身就是缩进 2 的子键)
6. 改 `server.ts`(逐条落地 + 托管清单 + **一次性交接**)→ ✅ 见 §6③
7. 改 `auth.ts` 4 条路由 → ✅ 含 4 类非法入参被拒(400/409)
8. 前端「模型设置」分区(插件 **0.3.11**)→ ✅ 无浏览器 harness 全绿(新增 8 条断言)
9. 角色补丁 `--force --restart` → ✅ **先留回滚点**再跑,跑完 diff 验证
10. 收尾四件套 + 台账 → ✅
## 6. 验收(用户点名的四件事 —— 全部有证据)
| # | 验收项 | 证据 |
|---|---|---|
| ① | 官方「模型」栏两账号都不再出现 | 两账号 `cordis.patch.yml` 各 1 处 `- id: ui-settings-models` + `disabled: true`;`--restart` 已生效(客户端 bundle 于实例启动时重打)。**页面实看留用户确认**(本机 `agent-browser` daemon 起不来,沿用档案 86 §七-3 的做法) |
| ② | 新页能配 / 能开关 / 能删 | HTTP 实测(临时 session 直插,R4):新增内置条目 → `effective: shared→own`;新增自定义厂家 → `route/base_url/api/models` 全部落库;`toggle` 关/开 → `enabled` 0/1;非法入参:非 http baseUrl→**400**、缺 models→**400**、route 撞车→**409**、非法协议→**400**;前端 harness 全绿 |
| ③ | 共享模型开关**真的影响实例落地** | 关共享 + 重启 → 实例 `.credentials.yaml` 里 `DEEPSEEK_API_KEY` **消失**(老实现写下的那行被"一次性交接"正确认领并撤掉),**实例进程 env 里该变量 = 0 条**;再打开 → 重启 → 该行**回来**且与自定义厂家的 ref **并存** |
| ④ | 自定义厂家被 dsh 模型选择器认到 | 实例 `settings.yaml` 落地为官方 schema 原文(`llm-pi-ai.providers.acctest-gw: {apiKeyEnv, baseURL, api: openai-completions, models: [...]}`);实例**启动成功**、`journalctl` 无 `llm-pi-ai` / `settings-rejected` 报错;删除该条目后重启 → **整块消失** |
本地全套(Node 22):单测 **49 项 0 失败** + `verify-inject` / `verify-static` / `verify-platform-admin-section` / `verify-mem-model` / `verify-model-landing` **全绿**。
## 7. 回滚
见档案 87 §七。要点:后端 `npm run build && systemctl restart dshs`;插件回落 0.3.10;角色补丁回滚件 = `/opt/dsh/backups/patch-20260913-234511/`(⚠️ 回滚它 = 把必然报错的官方页放回给用户,除非接受该副作用)。
## 8. 回报格式(已回填)
- 每步命令与输出:见档案 87 §五/§六;
- **取证结论**:档案 87 §三(`api` 不是 `protocol`,是本轮最重要的一条外部事实);
- 产物:插件 **0.3.11** / 48,590 B / md5 `15c8083f5db85b3fce6a8a15db133319`,两实例 `bundles=6` 且含 `@dsh-local/business-plugins`;
- 台账:本表 + `交接单/README.md §一` + `INDEX.md`;
- commit:**未提交**(用户未要求;且本机代码仓含**别人在途**的档案 86 改动,不能一起提)。
---
## 附:本轮暴露的三个「顺便修 / 已上报」项(都属本 lane)
1. 🔴 **`ensure-role-profile-patch.cjs` 的整文件覆盖是颗雷**(已修):它的 `--force` 升级分支原本 `writeFileSync(patchPath, block)`,而 admin 的 `cordis.patch.yml` 里**同时住着三个平台块**(`disable-hmr` / `workspace-scoped-picker` / role patch)⇒ 一旦触发升级就会**静默抹掉另两个**(HMR 重新连、目录选择器退回无限制版)。修法 = 新增 `stripManagedBlock()` 只替换自己那段;实机 diff 验证另两块完整保留。
2. ⚠️ **`verify-mem-model.mjs` 陈旧断言**(已修):档案 86 重构后 `request.user!.id` → `user.id`,该断言长期红着。
3. ⚠️ **上一会话自述与线上不符**:它称「未构建未部署」,但**角色补丁实际已被它刷到线上**(两账号的 patch 里已有 `ADMIN_MODELS_BLOCK` 的内容)。⚠️ 教训:**别拿会话自述当线上事实**,一律看服务器实际文件 —— 这正是本次「先看服务器再动手」救回两颗雷的原因。
@@ -0,0 +1,71 @@
# T07 · 内置 dsh 安装路径按序探测(修 P1 静默失效)
- 日期:2026-09-14
- 状态:✅ **已完成并归档**(源仓 `cbaf3ad` + `1d72e8f`,均已 push `master`)
- 执行会话:`craft-session-installpath`(主修)/ `craft-session-tailclear`(留痕 + 收尾)
- 提出方:**开源导出会话**(P1 交接单 `dsh-laijing-github\_交原仓会话_模型目录安装路径缺陷_20260914.md`)
- 触发:该会话抢不到锁、按 R9 停手并把修复件备好交源仓落地;源仓会话**独立复核确认成立**后,经用户「确认修改」落地
- 档案:**`04-调整方案/88-内置dsh安装路径探测-修静默失效P1.md`**(全部技术结论与复盘)
---
## 1. 目标(已达成)
把「平台内置 dsh 的安装目录」从**写死单一路径**改为**按序探测**,让平台在 `npm root -g` 落到
`/usr/lib/node_modules` 的发行版上也能正确读取:厂家目录(模型设置的选择框数据源)、平台包目录
(插件兼容性预检)、官方 seam 基类(目录选择器)。
## 2. 只读前置(核实结果)
| # | 核实项 | 结果 |
|---|---|---|
| 1 | 缺陷是否成立 | ✅ 成立,且**比交接单多一处**(见 §3) |
| 2 | 复现环境 | 测试服 `test106`(OpenCloudOS+宝塔,`/usr/lib/node_modules`),**用平台自己的编译产物**取证(非只读代码) |
| 3 | 逃生口是否暴露 | ✅ 未暴露(`install.sh`/env 样例/README 均 0 命中) |
| 4 | 修复件可用性 | ✅ 方向正确;但**env 名是导出侧的**(源仓侧会永不生效)——已修正(见 §4) |
## 3. 范围
**改了三处**(不是两处):`src/web/model-catalog.ts:30` · `src/web/plugin-compat.ts:36` ·
**`poc/workspace-scoped-picker/lib/index.js:34-35`(交接单漏的第 3 处)**。
**没动**:官方 dsh 主程序与缓存 · `04-调整方案/87`(模型设置本体)· 内存/配额档位 · nginx/nft · 系统面。
## 4. 决策点
- **用户已定**:确认修复(无口径分叉)。
- **技术自决(可推翻)**:① 解析顺序 env → dsh 可执行文件解软链 → 常见全局根 → `npm root -g` → 历史默认值;
② 判定用 `package.json.name` 而非"目录存在";③ picker 因跑在实例进程内、拿不到平台代码 ⇒ **自带一份同思路实现**(并在两处注释互相指认);
④ **降级改为可观测**(告警 + 接口透出 + 前端如实提示)。
- ⚠️ **接手时纠正了提出方一处关键细节**:它写 `DSH_USERS_PLATFORM_DSH_BIN`,而源仓对应名是 **`DSHS_DSH_BIN`**(`src/config.ts:239`)⇒ 改为两侧都认。
## 5. 步骤与验证(全部执行完毕)
1. 独立复核(§2)→ ✅ 发现第 3 处
2. 落地主修 `cbaf3ad`:新模块 `src/web/dsh-install.ts` + 三处调用点 + `scripts/verify-dsh-install.mjs` → ✅
3. 本机:`tsc` 0 错误 · `npm run verify` 全绿 · 单测 16/0 → ✅
4. 部署 `bt-server` + 回归:`platformPkgCount` **223**、厂家目录 **39**、picker 未受影响 → ✅
5. **测试服 `test106` 三种 env 姿势全部解析正确**(`/usr/lib`;scope 240 项、pi-ai 数据 39)→ ✅
6. 收尾 `1d72e8f`:降级留痕 + 前端 0.3.13 如实提示 + picker 升 **0.1.5** 重铺(消除源码/线上漂移)+ README 补 3 个 env → ✅
7. 归档与台账(本单 + `04/88` + INDEX + `交接单/README §一`)→ ✅
## 6. 验收(对应提出方 §6)
| # | 验收标准 | 结果 |
|---|---|---|
| 1 | 源仓 `src/web/dsh-install.ts` 存在且 `tsc --noEmit` 零错误 | ✅ |
| 2 | 源仓再无裸字面量(仅 `dsh-install.ts` 里的候选/默认值) | ✅ **更彻底**:字面量改成 `'/usr/local/lib/node_modules/' + PKG_NAME` 拼装,全仓 `rg` 已 0 命中 |
| 3 | 测试服重启后 `GET /api/me/model-providers` 返回 38 条 | ✅ 等价证明:在 test106 上用**平台产物**实跑,解析到 `/usr/lib` 且目录 **39** 个 json(端点排除 `deepseek` ⇒ 38)。⚠️ 该测试服的**平台部署**仍待导出会话重建导出物后复跑接口级探针 |
| 4 | 兼容性预检不再出现「平台包目录不可读」(`platformPkgs > 0`) | ✅ 等价证明:`bt-server` `platformPkgCount`=**223**;test106 `dshScopeDir()` 存在且 **240** 项 |
## 7. 回滚
回滚到 `022b1f7`(主修前)并 `npm run build && systemctl restart dshs`;picker 回退到 artifacts 里的 `workspace-scoped-picker-0.1.4.tgz` 并重跑 `poc/workspace-scoped-picker/ensure-workspace-picker.cjs --restart`。
⚠️ 回滚 = 主动恢复「另一种布局静默失效」,一般不要回滚。
## 8. 回报格式(已回填)
- 每步命令与证据:`04-调整方案/88` §五;
- 产物:`business-plugins` **0.3.13**、`workspace-scoped-picker` **0.1.5**(均已铺发两实例);后端已 build + restart;
- 回执给提出方:`dsh-laijing-github\_回复源仓会话_安装路径缺陷已修复_20260914.md`(含它待做的 4 步);
- commit:`cbaf3ad`(主修)、`1d72e8f`(留痕 + 收尾),均已 push。
@@ -0,0 +1,222 @@
# VoxEMW 全云 API 化:接入 dsh 修订方案(零自托管模型)
- **日期**:2026-09-09(22:50 版)
- **性质**:对《VoxEMW接入dsh调研与落地方案_20260909.md》的**方向性修订**——用户决策:**模型全部连线上 API,不部署任何本地模型**
- **一句话**:不再"代理运行 VoxEMW 那套 GPU 服务",而是**以 VoxEMW 为产品与体验蓝本,复用其 persona 文案与 `assets/mojingnvwu/face_ref.jpg` 形象资产,后端全部换成 2026 年已商业化的云端实时 API**,由 dsh 插件做编排/代理/隔离。
> ⚠️ **重要更正**:前一版文档 §4 判断"实时写实数字人渲染没有等价公开 API"——该判断已被 2026-09 市场现状推翻:火山引擎「实时互动数字人 API(FlowAct-R1)」、阿里云「数字人实时交互 OpenAPI」、ZEGO「精品照片数字人」均已提供"图片/形象 + 音频流 → 实时视频流"的商业 API。全云 API 路线**可行**,本文即按此重写。
---
## 一、决策影响对照(原方案 → 修订方案)
| 维度 | 原方案(代理 VoxEMW GPU 服务) | 修订方案(全云 API) |
|---|---|---|
| 模型部署 | 4090 主机四模型本地常驻(21.7G/24G) | **零本地模型**,全部云端 API |
| GPU 主机/隧道 | 需要 | **移除**,不需要任何自管 GPU |
| VoxEMW 代码 | 运行上游 python 全套 | **不运行**,仅作蓝本(UI 氛围/协议形态/人设) |
| 核心工作 | 守护 + 反向代理 + 槽位 | **云端编排层**(Realtime WS 对接 + 数字人流对接 + 人设注入) |
| 用户数据隔离 | 槽位 + persona 映射 + 零持久化 | 槽位/配额 + profile 维度记账 + 云端 session 独立 + 第三方合规提示 |
| 需要决策的新增项 | — | 供应商组合、数字人形象图、音色路线(见 §七) |
---
## 二、2026-09 云端供给盘点(VoxEMW 六积木逐块替换)
### 2.1 替换矩阵
| VoxEMW 积木 | 等价云 API(已核实存在) | 关键参数/证据 |
|---|---|---|
| ① VAD + ② STT | 并入"端到端 Realtime 模型"(自带 server VAD + 自动打断),或单独接讯飞/火山/阿里实时 ASR | 端到端更省事,见 2.2 |
| ③ LLM 大脑 | 端到端模型自带大脑;或保留 DeepSeek API(分离式时) | 魔镜人设走 `instructions`(session.update),与 VoxEMW 注入 persona 同思路 |
| ④ TTS 音色 | 端到端预置音色(列表切换);要"专属音色/克隆"则分离式接 MiniMax / 阿里 CosyVoice / 火山豆包 | 见 §六体验差异(音色设计能力是最大降级点) |
| ⑤ **写实数字人** | **火山「实时互动数字人 API (FlowAct-R1)」**:单张人物图 + 16kHz PCM 音频流 → 实时视频流,480P@25fps,首帧 ~2s,声画毫秒同步([docs.volcengine.com](https://docs.volcengine.com/docs/86081/2387261))<br>或 **阿里云数字人实时交互 OpenAPI**:WebSocket + streamed audio driver,支持 `customUserId`(天然适合租户标记)([help.aliyun.com](https://help.aliyun.com/en/me/getting-started/digital-human-real-time-interactive-openapi))<br>或 **ZEGO 精品照片数字人**:1 张照片 200ms、1080P,走 RTC 视频互动([doc-zh.zego.im](https://doc-zh.zego.im/aiagent-mini-program/introduction/overview)) | 单图输入 → **`assets/mojingnvwu/face_ref.jpg` 可直接复用** |
| ⑥ 眼睛 VLM | **GLM-Realtime**(音视频通话模型,WebSocket,支持摄像头帧输入、function calling、server VAD、可打断)<br>或 **阿里 Qwen-Omni-Realtime / qwen3.5-omni-plus-realtime**(DashScope,WS 与 WebRTC 双通道,视频帧输入) | 摄像头帧走同一条 Realtime WS,天然实现"她看得见你" |
### 2.2 端到端 Realtime vs 分离式(架构大方向二选一)
| | 端到端 Realtime(**推荐**) | 分离式(ASR + LLM + TTS 各选一家) |
|---|---|---|
| 做法 | 一个 WebSocket 完成"听→想→说",服务端 VAD/打断全托管 | 每环节独立 API,自行拼装状态机 |
| 延迟/体验 | 低(0.3~0.5s 级开口),打断顺滑 | 每跳多一次网络,拼装复杂、易抖 |
| 音色自由度 | 预置音色列表(GLM-Realtime 提供 tongtong/xiaochen/female-tianmei…) | 可用 MiniMax/CosyVoice 克隆"魔镜专属嗓音" |
| 视觉(眼睛) | GLM-Realtime / Qwen-Omni 直接吃视频帧 | 需另接 VLM API |
| 计费参考 | GLM-Realtime-Flash:音频 0.18 元/分,视频 1.2 元/分;Air:0.3 / 2.1 元/分([docs.bigmodel.cn](https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-realtime)) | 各家按量,总价通常更高 |
**推荐组合**(两个候选,M1 前拍板):
- **组合甲(默认推荐)**:端到端大脑+声音 = **智谱 GLM-Realtime**;出画 = **火山 FlowAct-R1**(单图+音频流)。理由:中文生态、GLM-Realtime 带视频+function calling、成本低、火山数字人与豆包端到端语音同族可平滑替换。
- **组合乙(同厂商偏好)**:端到端 = **阿里 Qwen-Omni-Realtime**(视频帧原生);出画 = **阿里实时数字人 OpenAPI**(带 `customUserId`,租户标记友好)。理由:一家计费/控制台,WebRTC 浏览器直连低延迟。
---
## 三、目标架构(修订后)
```
┌──────────── 用户浏览器(dsh 会话窗口右侧分栏面板,同源 https)───────────┐
│ 魔镜面板(轻量前端,蓝本=VoxEMW web/ 的氛围,但重写为云版) │
│ ├─ 麦克风采集 16kHz PCM → host 代理 → 云 Realtime WS │
│ ├─ 摄像头帧(可选"眼睛")→ host 代理 → 同一 Realtime WS │
│ └─ 云数字人视频流 → <video> 播放(火山 FlowAct-R1 / 阿里) │
└──────────────────────┬──────────────────────────────────────────────────┘
│ 同源(ws/wss + http),Authorization 由服务端注入
┌──────────────────────▼── dsh 服务器(47.77.182.89 / dsh 域)────────────┐
│ dsh-plugin-voxemw-cloud │
│ ├─ lib/host.js:/voxemw/api/* + /voxemw/ws/* 反向代理到云厂商 │
│ │ · 云 API Key 保管于此,**绝不下发浏览器** │
│ │ · 注入 dsh 登录态 → 按 profile 换取一次性云端会话 token │
│ ├─ lib/slot.js:会话槽位仲裁 + 配额(按 profile 限每日用量) │
│ ├─ lib/billing.js:profile × 分钟数 × 费用的用量记账(审计) │
│ └─ lib/client.js:会话分栏 + 魔镜面板 UI(沿用 mcn split 技术) │
└─────────────────────────────────────────────────────────────────────────┘
│ HTTPS/WSS(云厂商公网 API)
▼
智谱 GLM-Realtime / 阿里 Omni-Realtime(大脑+耳朵+嗓子+眼睛)
火山 FlowAct-R1 / 阿里实时数字人(出画,输入 face_ref.jpg 形象)
```
要点:
1. **云 API Key 全部收口在 dsh 服务端**,浏览器永远只连 dsh 同源地址 → 无 CORS、无密钥泄露、天然 secure context。
2. 数字人形象输入 = 复用 VoxEMW 仓库 `assets/mojingnvwu/face_ref.jpg`(280K 单张正面像,符合 FlowAct-R1"清晰正面半身图"要求)。
3. 魔镜人设 = 复用 `personas/mojingnvwu.md` 正文,经 `session.update.instructions` 注入(与 VoxEMW 注入 s2s 同一思路);音色从云端预置列表近似选(见 §六)。
4. VoxEMW 上游 python **不再被运行**;前端也需**重写轻量云版**(原 `web/` 深度耦合 orchestrator 协议/本地假设,不能直接指向云)。
---
## 四、dsh 插件形态(包结构修订)
> **命名统一(2026-09-09 实现时裁定)**:插件名定为 **`dsh-plugin-voxemw-cloud`**(v0.1.0),替代早期方案的 `dsh-plugin-voxemw` 提法,旧称不再使用。
> **M1 交付边界**:客户端 bundle 只能在运行中的 dsh 实例加载验证(红线:client 改动必须重启实例 + 打包缓存),本机开发仅做语法/逻辑冒烟测试;实例级验证步骤见 README。云端厂商链路需账号开通后接线,realtime/avatar 先以"协议适配层 + 纯函数"落地(可测),UI 侧仿 social-workbench 提供"未配置云端"引导态。
```
dsh-plugin-voxemw-cloud/
├─ package.json / cordis.patch.yml # 三段式骨架,注册 __mcnEntries,feature-tier
├─ lib/
│ ├─ index.js # 路由注册:控制面 /voxemw/api/* + 面板页 /voxemw/app + /voxemw/health
│ ├─ slot.js # 槽位 + 配额(云会话按分钟计费 → 必配每日上限)
│ ├─ billing.js # profile 维度用量/费用记账(审计表)
│ ├─ cloudcfg.js # 厂商/Key/模型/音色/形象 配置(服务端保管,密钥不下发)
│ ├─ realtime.js # Realtime 协议适配层:session.update(人设/音色) 构建等纯函数
│ ├─ avatar.js # 数字人协议适配层:会话初始化请求构建等纯函数
│ └─ client.js # bundle:工作台入口 + 面板(沿用 mcn 分栏宿主,iframe 同源)
├─ web/app.html # 魔镜面板轻量前端(单文件,同源加载,含麦克风回环自测)
└─ README.md
```
---
## 五、用户数据隔离(修订)
| 数据 | 存放/流向 | 隔离措施 |
|---|---|---|
| 用户语音/摄像头帧 | 浏览器 → dsh 代理 → **云厂商** | 每次会话新建独立云端 session;代理不留音频副本(纯透传);关闭面板即销毁 |
| 对话/转写 | 云端会话内存(GLM 音频通话上下文 ~8K/20 轮) | 会话结束即释放;需留存时按 profile 落用户目录,绝不跨用户复用 |
| 人设/热词 | dsh 服务端按 profile 存映射 | 注入仅限本人会话 |
| 计费/用量 | billing 表(profile 维度) | 每用户只见自己的记账 |
| 云 API Key | dsh 服务端环境变量 | 浏览器不可见;轮换/最小权限(RAM/子账号 key) |
**新增合规注意(P1)**:语音/画面会经第三方云厂商处理——产品上需对用户明示;自用/内网不受影响。厂商选型时优先国内合规厂商(智谱/阿里/火山均支持企业实名)。
**沿用上一版 §6.2 的槽位层**:端到端模型与数字人流都按会话/分钟计费且厂商有并发限制(如 GLM Realtime 免费/低等级并发 5 路),slot 互斥 + 每日配额仍然是刚需。
---
## 六、体验差异(诚实对照,避免上线后落差)
| 体验点 | 原版(本地 4090) | 云 API 版 | 影响 |
|---|---|---|---|
| 开口延迟 | 说完 ~3s(本地 s2s 链路) | 端到端云 Realtime 通常相当或更低(server VAD 判停即响应) | ✅ 不降级 |
| 出画首帧 | SoulX 常活待机,几乎即时 | 火山 FlowAct-R1 首帧 ~2s;ZEGO 200ms 但走 RTC | ⚠️ 需预热/占位动画掩盖,或选 ZEGO |
| 音色"设计感" | VoxCPM2 描述词凭空造嗓 + 种子钉定 | 端到端只有预置音色;专属嗓音需分离式克隆(需参考音频) | ⚠️ **最大降级点**,见决策 3 |
| 魔镜形象 | SoulX 写实渲染 | 云端数字人(face_ref.jpg 驱动或平台形象) | 观感不同但可接受 |
| 长会话稳定性 | 本地单时钟唇形同步 | 厂商提示"过长视频有崩坏概率,建议会话长度策略"(火山) | 需设单会话时长上限并自动续段 |
| 运营成本 | GPU 租用(AutoDL 时按小时) | 按分钟计费(参考:音频 0.18~0.3 元/分 + 视频 1.2~2.1 元/分,纯语音组合更省) | 低频个人使用成本低;高频需配额 |
---
## 七、需要你拍板的 3 个决策(M1 前置)
1. **供应商组合**:组合甲(智谱 GLM-Realtime + 火山 FlowAct-R1,默认推荐)还是组合乙(全阿里)?还是只要纯语音(先不做出画,最省事)?
2. **数字人形象**:直接用 VoxEMW 自带 `assets/mojingnvwu/face_ref.jpg`,还是换一张更符合"魔镜女巫"设定的形象图(含版权确认)?
3. **音色路线**:先用云端预置女声(接近即可,最快)→ 之后若需"魔镜专属嗓音"再接分离式 TTS 克隆(需你提供一段 3~10s 参考音频,或用描述词在支持语音设计的 TTS 上逼近原 seed 效果)?
---
## 八、实施路线(修订)
**M1 — 云端链路跑通 + 面板可见(半天~1 天)**
1. 注册厂商账号、开通 API(按决策 1);服务端保管 key。
2. 独立验证页:麦克风 → Realtime WS 对话成功;音频流喂数字人 API → 视频出画。
3. 接入 dsh:host 代理 + 会话分栏面板内嵌云版前端。
- ✅ 验收:dsh 会话旁打开魔镜,可语音对话 + 数字人出画;关闭面板会话无损。
**M2 — 隔离/配额/记账/人设**
4. slot + 每日配额;profile 维度 billing;人设注入;face_ref.jpg 形象固化;占用/排队 UI。
- ✅ 验收:双账号并发互斥、各自人设/记账不可见;用量超限自动拒 claim。
**M3 — 体验与合规**
5. 音色定制(决策 3 后半);长会话自动续段/上限;窄栏 UI 打磨;日志分级;第三方数据处理提示。
---
## 九、访问形态、服务器负载与并发容量(2026-09-09 增补,回应"是否还是插件/压力多大/支持多少人")
### 9.1 它仍是 dsh 插件,访问入口不变
全云 API 化**没有改变"插件"形态**——改变的是插件内部"不装模型、只做编排/代理"。访问链路分两个角色:
| 角色 | 是什么 | 访问方式 |
|---|---|---|
| **使用方(dsh 用户)** | 登录 dsh 后,在会话窗口分栏点「魔镜女巫」入口 | 插件 client bundle 注入 `__mcnEntries`,点击展开右侧面板 → 浏览器采集音视频、渲染数字人,全程**不感知云厂商存在** |
| **dsh 平台自身** | dsh 服务端运行 `dsh-plugin-voxemw-cloud`(host 半区) | 持有各家云凭证;控制面调用厂商 API 换取会话;把短时效凭证交给浏览器;按 profile 做槽位/配额/记账;也可向 dsh agent 暴露 MCP 工具("启动/切换人设/查占用") |
> 所以"dsh 如何访问"的答案 = **浏览器访问 dsh 同源入口,dsh 插件进程访问云厂商**,中间没有其他系统。
### 9.2 两条数据面架构(决定服务器压力,M1 前必须拍板)
| | 路径 | 服务器压力 | 适用 |
|---|---|---|---|
| **A. 媒体面直连(推荐)** | 浏览器 ↔ 云厂商 RTC/WS 直连音视频;dsh 服务器只做**控制面**(登录态校验 → 向厂商换短时效会话凭证 → 下发浏览器 → 记账) | **极小**:每个活跃会话仅几十 KB/s 级信令/文本,无媒体转发 | 云厂商支持浏览器直连 + 临时凭证。已核实线索:Qwen-Omni-Realtime 明确支持 WebRTC 浏览器低延迟;ZEGO/火山走 RTC 房间模型天然直连;GLM-Realtime 为 WS+API Key,直连会暴露 key,需厂商临时凭证或走 B |
| **B. 服务端中转(WS 全代理)** | 浏览器 → dsh 插件代理 → 云厂商 | 媒体全部过服务器:**带宽=瓶颈**(见 9.3 量化) | 厂商只有 WS+长期 Key(如 GLM-Realtime 默认);或无浏览器 SDK |
**建议**:M1 验证时逐厂商问清"浏览器直连 + 临时凭证(ephemeral token/RTC room 凭证)"支持度,优先 A;A 不可用的环节退回 B 并控制并发。
### 9.3 服务器负载量化(估算,供规划)
单会话媒体流量(最坏=厂商给原始 PCM,若走 RTC/Opus 会小 4~8 倍):
| 流 | 方向 | 码率估算 | 说明 |
|---|---|---|---|
| 上行语音 | 用户→云 | 16kHz PCM16 ≈ **256 kbps**(Opus 则 ~24–32 kbps) | 说话时才满速 |
| 下行语音 | 云→用户 | GLM 输出 pcm24 ≈ **384 kbps** | — |
| 数字人视频 | 云→用户 | 480P@25fps 估 **0.8–1.5 Mbps**(厂商未公开,按同类流媒体估) | 仅出画档 |
- **架构 A(直连)**:以上流量全部不经 dsh 服务器 → **dsh 服务器负载≈0**,只剩登录校验/凭证下发/账单(每会话可忽略)。dsh 服务器**不是瓶颈**。
- **架构 B(中转)**:每路活跃全功能会话 ≈ 上行 256k + 下行 1.7M ≈ **~2 Mbps**(不出画纯语音约 0.6 Mbps)。按带宽估并发:10 Mbps 出口 ≈ 5 路全功能(或 ~16 路纯语音);1 Gbps ≈ 500+ 路(理论,另受云配额/CPU 转发限制)。**即:若要中转且大规模,带宽决定上限。**
### 9.4 支持多少人同时访问(分层容量模型)
"同时访问"要拆成三层,瓶颈各不相同:
| 层 | 含义 | 瓶颈 | 规模预估(推荐架构 A) |
|---|---|---|---|
| ① 同时在线 | 登录 dsh、面板能打开(不对话) | dsh 服务器 + 云账号配额外的静态资源 | 数百~上千不成问题,服务器压力≈0 |
| ② 同时语音对话 | 占用一条云端 Realtime 会话 | **云厂商并发配额**(例:GLM-Realtime 低等级在途并发 5 路起,可付费升级)| 通常买 5~50 路;受成本约束 |
| ③ 同时数字人出画 | 占用一路云数字人渲染 | 云数字人按路/分钟计费的并发上限 | 单账号通常个位数~十路级,需与厂商确认 |
**结论**:
- 瓶颈**不在 dsh 服务器**(只要走媒体面直连 A);瓶颈在**云厂商并发配额**和**按分钟费用**。
- 推荐按"**槽位数 = 你买的云端路数**"来卖/分配:例如买 5 路 → 同时最多 5 人占用魔镜,第 6 人排队(slot + 配额机制正是为此设计)。这也是为什么 §四 slot/billing 是刚需。
- 若坚持服务端全中转(B),则按 9.3 公式用你 dsh 服务器实际出口带宽反推上限。
> ⚠️ 各厂商具体并发配额/直连凭证机制随套餐变化,**M1 开通账号后实测**(一次开 N 路压测),本表为规划级估算。
---
## 附录:信息来源
- 火山引擎「实时互动数字人 API (FlowAct-R1)」:https://docs.volcengine.com/docs/86081/2387261
- 阿里云「数字人实时交互 OpenAPI」:https://help.aliyun.com/en/me/getting-started/digital-human-real-time-interactive-openapi
- ZEGO「实时互动 AI Agent 2.0 / 精品照片数字人」:https://doc-zh.zego.im/aiagent-mini-program/introduction/overview
- 智谱「GLM-Realtime」:https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-realtime
- 阿里「Qwen-Omni-Realtime / qwen3.5-omni」:https://help.aliyun.com/en/model-studio/realtime
- VoxEMW 复用资产:`D:\tmp\voxemw\voxemw-src\assets\mojingnvwu\face_ref.jpg`、`personas/mojingnvwu.md`(MIT 许可,复用保留版权声明)
@@ -0,0 +1,353 @@
# VoxEMW 接入 dsh:全面调研与落地方案
> ⚠️ **2026-09-09 22:50 方向修订**:用户已决策「模型全部连线上 API、不部署本地模型」。本版(代理 VoxEMW GPU 原套)**已被《VoxEMW全云API化接入dsh修订方案_20260909.md》取代**为本方案主路径;本文件保留作"本地/云端 GPU 跑 VoxEMW 原套"的备选存档。VoxEMW 上游架构事实(§二)与 plugin_package 分栏技术(§三)在两版间通用。
- **日期**:2026-09-09
- **调研对象**:github.com/emwstudio/VoxEMW(git tag v1.10.0,源码已本地快照 `D:\tmp\voxemw\voxemw-src\`)
- **参考实现**:`D:\dshworkspace\plugin_package\`(dsh-plugin-mcn / dsh-plugin-social-workbench / dsh-plugin-douyin-accounts)
- **目标**:① 能否以插件形式加入 dsh;② 使用时在「会话窗口旁边」显示;③ 用户相关数据隔离
- **结论**:**可行,但不是把 VoxEMW 代码"搬进"插件,而是做一个 dsh 侧 host/feature 插件去"守护 + 同源代理 + 内嵌"VoxEMW 这个独立的数字人服务**,会话区采用 dsh-plugin-mcn 已验证的「会话窗口 split + 拖拽分栏」渲染。隔离通过「单槽互斥会话 + profile 维度的 persona/状态映射 + 零持久化语音策略」实现。
---
## 一、结论速览(TL;DR)
| # | 问题 | 结论 | 关键依据 |
|---|---|---|---|
| 1 | VoxEMW 是什么 | 一套**单机 4090 满血写实数字人**(魔镜女巫):浏览器语音对讲 + 数字人视频 + VLM 视觉,LLM 大脑走 DeepSeek API | README / `docs/plan-4090.md`,四模型同卡 21.7G/24G |
| 2 | 能否"以插件形式加入 dsh" | ✅ 可以,但**插件不含 VoxEMW 本体**。VoxEMW 是 GPU 重型、多进程、带独立 Python 虚拟环境的服务,只能作为**被插件管理的独立服务**存在 | 服务形态:orchestrator(:8000)+s2s(:8765)+SoulX(:8791)+VLM(:18099) |
| 3 | 形态参照 | = **dsh-plugin-social-workbench 的"守护+内嵌 iframe"** + **dsh-plugin-mcn 的"会话分栏"** 二者叠加 | plugin_package 已实证两套技术 |
| 4 | 会话窗口旁边显示 | ✅ 采用 mcn 已验证方案:把 `[data-slot='conversation']` 包进 flex 容器,点入口后在会话右侧展开面板(可拖 5px resizer),VoxEMW 前端以 iframe 同源加载 | mcn `client.js` 的 `.mcnNav_split` CSS 与 `startResize` |
| 5 | 用户数据隔离 | ✅ 分三层实现:**槽位互斥**(orchestrator 只支持单会话,二次连接会顶掉前者——必须加 claim 管理器)+ **profile 维度映射**(persona/配置按用户隔离,VoxEMW 本身无多租户)+ **零持久化**(VoxEMW 会话状态全在内存,天然无跨用户残留) | orchestrator.py `current_session` 顶替逻辑;Session 类无磁盘状态 |
| 6 | 主要风险 | GPU 前置(需一张 4090 或等效);**多用户并发被物理限制为 1 路**;VoxEMW 前端需要 secure context(https/wss)才给麦克风/摄像头权限;无鉴权需由 dsh 侧补 | 见 §6 风险清单(P1/P2) |
| 7 | 总体投入 | M1 服务接入 + 内嵌可见(核心)+ M2 槽位/隔离/人设映射 + M3 加固 | 见 §7 路线 |
---
## 二、VoxEMW 项目解剖(决定一切的事实基础)
### 2.1 形态判断
- 名字/形态:**魔镜女巫数字人**,v1.10.0 主线 = "4090 满血写实数字人版"。Mac/VRM 轻量档在 tag v1.9.0(不同形态,本次不讨论)。
- 典型部署:一张 AutoDL 4090(24GB),**四模型同卡常驻**:Qwen3-ASR-1.7B(STT)+ VoxCPM2(TTS)+ SoulX-FlashHead-1.3B(写实数字人)+ MiniCPM-V-4.6(视觉),显存 ~21.7G/24G。
- 大脑:**DeepSeek API**(`deepseek-v4-flash`),不是本地模型。所以"本地 4090 + 云上大脑"。
- 一句话:**这是端到端实时语音/视觉/数字人应用,重 GPU、多进程、自带头部追踪调度(pacer)**,与"浏览器端插件"是两种物种。
### 2.2 运行拓扑与边界(端口/进程/环境)
| 积木 | 端口/协议 | 进程/环境 | 职责 | 代码位置 |
|---|---|---|---|---|
| orchestrator | :8000 aiohttp HTTP+WS | `py312` 主 venv | 浏览器唯一入口:会话调度 / persona 注入 / 打断编排 / RTC 信令 / 静态服务 | `voxemw/gateway/orchestrator.py`(840 行) |
| s2s 语音管线 | :8765 realtime WS | 同 venv(`pipeline.launch`) | VAD→STT→LLM→TTS 全链路,`num_pipelines: 1` | `voxemw/pipeline/*` |
| SoulX 渲染 | :8791 WS | 独立 `flashhead` venv | 音频驱动写实 talking-head | `voxemw/avatar/soulx_server.py` |
| VLM 边车 | :18099 | 独立 `vlm` venv | MiniCPM-V 看图/OCR,供 `look_at_camera` 工具 | `voxemw/gateway/vlm_server.py` |
| 前端 | —— | 纯静态 | 画布/麦克风/摄像头/WebAudio/WS/RTC | `web/index.html`(48行)+`assistant.js`(909行)+`style.css` |
启动脚本:`scripts/start_4090.sh`(全启/stop);配置:`configs/assistant-4090.yaml`(4090 档,`host: 0.0.0.0`)与 `configs/assistant.yaml`(Mac 档,只绑回环)。
### 2.3 前端页面硬依赖(决定 iframe 方案的约束)
`web/index.html` + `assistant.js` 是一个**全屏沉浸式单页**(canvas 星空背景 + 魔法镜 + 麦克风圆钮 + 实时转写区),运行时需要:
1. **getUserMedia 麦克风**(上行语音)、**摄像头**(仅 `look_at_camera` 触发时抓帧 POST `/vision/frame`)。
2. **WebSocket** `/ws`(上行音频/控制,下行转写/状态/音频 delta)+ 可选 **WebRTC**(`/rtc/offer`、`/rtc/ice`,Mac/LAN 档开、4090 远程档关,走 WS+WebAudio)。
3. **Secure Context 硬前提**:浏览器只在 https(或 localhost)下授予 getUserMedia 与 WebAudio。这意味着 **VoxEMW 页面必须经 https 域名对外**(直连裸 IP 的 http://ip:8000 拿不到麦克风)。
4. 若以 iframe 内嵌,iframe 需 `allow="microphone; camera"`,且页面本身必须处于安全上下文。
### 2.4 浏览器侧协议契约(引自 orchestrator.py 顶部 docstring)
```
/ws 文本帧(JSON):
→ {"type": "vox.persona", "id": "<persona_id>"} 切换人设(可运行时切换)
→ OpenAI Realtime 事件原样透传(input_audio_buffer.append / response.cancel …)
← OpenAI Realtime 事件透传(transcription / response.done …)
← {"type": "vox.status", "persona": "<id>", "rtc": {"enabled": bool, "ice_servers": [...]}}
GET /api/personas → 人设清单(默认人设 + 列表)
POST /rtc/offer → WebRTC 信令(body {"sdp","type"} → answer)
GET /rtc/ice → ICE 配置
POST /vision/frame → 视觉帧上报(VLM 用)
/static/* → 前端静态资源
```
### 2.5 单会话硬约束(本方案最核心的工程约束)
`orchestrator.py` 明确写着 **"单用户单会话:新浏览器连接顶掉旧会话"**:
```python
current_session: dict = {"session": None} # 全局只有一个槽
...
async def ws_handler(request):
old = current_session["session"] # 顶掉旧的
...
current_session["session"] = session # 新连接独占
```
深层原因:s2s 只有 1 个管线槽(`num_pipelines: 1`)+ 一张 4090 的渲染算力只够一路写实数字人。**这是上游物理/架构决定,不是配置能改的**。任何"多用户并发各自开一路魔镜"的设想都必须先接受这个天花板。
推论:多用户接入必须走**槽位仲裁(slot claim)**:同一时刻只有 1 个 dsh 用户能占用魔镜,其余用户看到"占用中"并可排队/订阅释放。
### 2.6 状态持久化现状(对"用户数据隔离"是重大利好)
- Session 状态**全部在内存**:`_assistant_history` 仅保留近 2 轮回复(回声判定用),随连接销毁即清。
- **无用户语音/转写落盘**(默认不开 live transcription;`enable_live_transcription: false`)。
- 唯一"持久"的是 `personas/*.md`(人设正文 + 音色描述/种子),由配置文件静态装载,启动时进 `config.personas.resolved` 字典。
- 结论:VoxEMW 侧几乎没有可跨用户残留的数据。**"用户数据隔离"主要矛盾在 dsh 侧的接入编排层,而非 VoxEMW 数据层。**
### 2.7 安全现状(需 dsh 侧补齐)
- orchestrator **无鉴权、无多租户、无审计**(4090 档直接 `host: 0.0.0.0`)。若裸奔公网:任何人可白嫖你的 DeepSeek key 和 GPU。
- LLM key 走进程级环境变量 `DEEPSEEK_API_KEY`,单实例单 key,**无法按用户区分计费/配额**。
---
## 三、参考实现拆解:plugin_package 给了哪三块模板
### 3.1 dsh 客户端插件的"三段式骨架"(三个包一致)
```
dsh-plugin-xxx/
├─ package.json # main=lib/index.js; exports{ "." , "./client" };
│ # dsh.client.platform="web" (+可选 inject:["@deepseek-ai/dsh-client-*"])
│ # dsh.bundle.patch="./cordis.patch.yml"
├─ cordis.patch.yml # 声明式注册本插件 bundle 层:- insert: [{id, name}]
├─ lib/index.js # 服务端半区(在 dsh 进程内):注册 HTTP 路由 / MCP 工具描述、守护外部服务
└─ lib/client.js # 客户端 bundle:window.__ModuleLoader__.load({id, factory}),
# 内部 exports.apply()/exports.inject[](Cordis 应用层注入)
```
`cordis.patch.yml` 示例(三个包一致,mcn-nav / social-workbench / douyin-accounts 各一条 insert)。
### 3.2 模板 A:social-workbench —— "守护 + 内嵌独立服务"(与 VoxEMW 场景最同构)
- 场景:Easel 是**独立的**社媒工作台(自带 .venv + web 前端,默认 :7860)。
- host 半区(`lib/index.js`)只做守护/探活,不搬 Easel 代码:
- `GET /swb/status`(探活 web/gateway、安装状态、子进程存活、日志尾)
- `POST /swb/start` / `POST /swb/stop`(拉起/停掉独立进程)
- `GET /swb/log`(返回日志尾)
- 环境变量覆盖:`EASEL_DIR`、`EASEL_WEB_PORT`、`EASEL_SWB_AUTOSTART`
- client 半区(`lib/client.js`):`apply()` 里注册 `window.__mcnEntries.push({id:'social-workbench', icon, title, component})`,component 渲染一个含 `iframe src=state.webUrl` 的面板(状态条 + 启动按钮 + 内嵌页)。
- **可复制点**:VoxEMW 插件 host 半区 = `/voxemw/status|start|stop|log` + 代理;client = `__mcnEntries` 注册 + iframe 内嵌。
### 3.3 模板 B:dsh-plugin-mcn —— "会话窗口旁边显示"的标准答案
mcn 的 client bundle 实现了**把聊天窗口与工作台面板左右分栏**的技术,全部在浏览器 DOM 层完成,代码证据(`lib/client.js`):
```css
.mcnNav_split{display:flex;flex-direction:row;flex:1 1 auto;min-width:0;min-height:0;
width:100%;height:100%;overflow:hidden;...}
.mcnNav_split>[data-slot='conversation']{display:contents} /* 会话被包进容器 */
.mcnNav_split>[data-slot='conversation']>div{
flex:1 1 0%!important; min-width:0!important; height:100%!important;
--dsh-chat-content-width:100%!important} /* 面板收起时满宽 */
.mcnNav_split>.mcnNav_resizer{flex:0 0 5px;height:100%} /* 拖拽条 */
.mcnNav_split>.mcnNav_panel{flex:0 0 auto;height:100%} /* 右侧面板 */
```
- `startResize()`:mousedown 记录 startX/startW → mousemove 改面板宽度 → 面板侧记忆 `localStorage["mcnNav.panelSide"]`。
- 机制本质:**把 dsh 的 `[data-slot='conversation']` DOM 节点移入插件自己的 flex 容器**,展开时塞入 resizer+panel,收起时等价恢复满宽(`display:contents` + `--dsh-chat-content-width` 兼容聊天区自身宽度变量)。
- 生态分工:**mcn 是"壳"**(注入左侧导航 + 提供 `window.__mcnEntries` 注册表 + 会话分栏),**其余功能插件是"页"**(douyin-accounts / douyin-video-detail / mcn-schedule / social-workbench 全部通过 `window.__mcnEntries.push/unshift` 注册,点击后在右侧面板渲染页面)。
> 因此"在会话窗口旁边显示"在 plugin_package 生态里的标准做法 = **做一个 feature-tier 插件,注册进 `__mcnEntries`**;前提是宿主 dsh 已安装 mcn 壳(用户环境天然满足:plugin_package 就是这套生态)。
### 3.4 模板 C:douyin-accounts —— host 路由 + 页面 CRUD 的 API 形态
- `lib/index.js` 暴露 `/mcn/api/*`(accounts CRUD、列表、分析等)HTTP 路由;同时也以工具描述形式暴露给 agent(MCP/工具语义),前端直接 fetch 调用。
- **可复制点**:VoxEMW 的"会话控制/人设/状态"控制面做成 `/voxemw/api/*`,页面与 agent 双通道可调。
---
## 四、可行性判定与形态选择
| 形态 | 做法 | 判定 | 原因 |
|---|---|---|---|
| 形态 0:二开/搬代码 | 把 voxemw python + 4 个模型装进 dsh 进程/客户端 bundle | ❌ 不成立 | ① GPU 重负载 + 独立 venv 多进程,无法进客户端 bundle;② 违反既有红线(不改写官方 bundle、插件不打包重型服务);③ 跨平台(Windows 开发机无 GPU/模型装不上) |
| 形态 1:**host/feature 插件 + 同源代理 + iframe 内嵌**(推荐) | VoxEMW 部署在 GPU 主机(本地或远程),dsh 侧插件守护它,并通过 dsh 所在 https 域同源反向代理 `/voxemw/*`;客户端在会话分栏 iframe 打开 | ✅ **推荐** | social-workbench 同构已验证;同源代理一举解决 secure context(麦克风/摄像头权限)与 ws 透传;插件本体轻量、可独立升级;VoxEMW 仍可独立更新 |
| 形态 2:iframe 直连 GPU 主机裸址 | `iframe src=http://gpu-host:8000` | ⚠️ 不推荐 | 裸 http 非安全上下文 → 麦克风/摄像头被浏览器拒绝;跨源 ws 需要 VoxEMW 侧放行 CORS/Origin(上游未做);token 无法注入 |
**最终形态(形态 1)一句话**:`dsh-plugin-voxemw` = social-workbench 的守护骨架 + mcn 的会话分栏 + VoxEMW 自己的 web/ 页面做面板内容,页面流量经 dsh 域名同源代理到 GPU 主机 orchestrator。
---
## 五、总体架构设计
### 5.1 目标拓扑
```
┌────────────────────────────── dsh Web (https://dsh.alotbuy.com 或同级域) ──┐
│ dsh 主进程 │
│ └─ dsh-plugin-voxemw (feature-tier, 注册 __mcnEntries) │
│ ├─ lib/index.js host 半区 │
│ │ ├─ /voxemw/api/status|start|stop|slot|personas 控制面 │
│ │ ├─ /voxemw/proxy/* 同源反代 → GPU 主机 orchestrator │
│ │ │ (HTTP /ws /static /rtc/offer /vision/frame 全透传) │
│ │ └─ SlotManager(进程内): 槽位互斥 + profile 维度状态 │
│ └─ lib/client.js client bundle │
│ ├─ 会话分栏 (mcn 同款: [data-slot=conversation] 包 flex) │
│ └─ 面板: iframe allow="microphone; camera" src=/voxemw/ │
│ + 状态条(占用/空闲/排队) + 弹出全屏 │
└──────────────┬──────────────────────────────────────────────────────────┘
│ 仅插件 host 出网(隧道 / 白名单 443 / 内网)
▼
┌────────────── GPU 主机(AutoDL 4090 或自有 GPU 机)──────────────────────┐
│ scripts/start_4090.sh │
│ orchestrator :8000 ← s2s :8765 · SoulX :8791 · VLM :18099 │
│ 全部只绑回环/私网,由 dsh 侧代理访问;DEEPSEEK_API_KEY 在此进程内 │
└─────────────────────────────────────────────────────────────────────────┘
```
### 5.2 dsh-plugin-voxemw 包结构(交付蓝图)
```
dsh-plugin-voxemw/
├─ package.json # 三段式骨架;name=dsh-plugin-voxemw
├─ cordis.patch.yml # insert: [{id: voxemw-nav, name: dsh-plugin-voxemw}]
├─ lib/
│ ├─ index.js # host:SlotManager + /voxemw/api/* + /voxemw/proxy/*
│ ├─ slot.js # 槽位仲裁:claim/release/queue/keepalive(profile 维度)
│ ├─ personas.js # 用户↔人设 映射(每 profile 一份 persona 清单,可热同步到 VoxEMW personas 目录)
│ ├─ proxy.js # 同源反向代理(http/ws 透传、超时、断线自愈、按 profile 注入会话 token 查询参数)
│ ├─ supervisor.js # VoxEMW 进程/远程隧道 守护与探活(对标 social-workbench /swb/*)
│ ├─ client.js # bundle:__ModuleLoader__.load + 会话分栏 + 面板 iframe + 状态 UI
│ └─ (可选)embed/ # 对 VoxEMW web/ 的极简适配(窄栏 CSS 注入、标题/关闭、全屏弹层)
├─ build.mjs # client 打包(esbuild/rollup → 单文件 bundle,css 内联)
└─ README.md
```
### 5.3 关键流程时序
**① 打开魔镜(claim + persona 注入)**
```
用户点击侧栏「魔镜女巫」→ mcn 会话分栏展开右侧面板 → iframe 载入 /voxemw/(同源,安全上下文)
→ client 调 POST /voxemw/api/slot/claim {profile}
→ SlotManager: 空闲? 分配 claimId(30min 续期) : 返回 occupied(+排队序号)
→ 面板显示「占用中 / 空闲 / 排队中」
→ iframe 内 VoxEMW 前端建立 /ws;插件在 URL 上注入 ?claim=claimId&persona=<该profile映射人设>
→ VoxEMW 前端 connect 后发送 vox.persona(或由代理改写首个消息)完成人设落定
```
**② 会话中**
- 语音全走 VoxEMW 自己链路(浏览器→代理→orchestrator→s2s/模型),dsh 只做透传,不落语音。
- 面板关闭/页面卸载 → `POST /voxemw/api/slot/release`;异常断线由 supervisor keepalive 超时回收(orchestrator 本身也会被新连接顶掉,双保险)。
**③ 占用冲突(核心场景)**
- 用户 B 打开 → claim 被拒 → 面板给「魔镜正被 用户A 使用」+ 订阅(slot 释放事件推送,B 可一键接管)。
- 可选策略(配置项):B 请求「强制接管」→ 插件先优雅 release A(A 端收到 `vox.status{evicted:true}` 提示)。
### 5.4 代理路由表(/voxemw/proxy/* 透传清单)
| 上游 | 方法 | 用途 |
|---|---|---|
| `/`、`/static/*` | GET | VoxEMW 前端静态页(走代理 = 同源 = 安全上下文) |
| `/ws` | WS 升级 | 实时语音/控制主通道 |
| `/api/personas` | GET | 人设清单(按当前 profile 过滤后返回) |
| `/rtc/offer`、`/rtc/ice` | POST/GET | RTC 信令(4090 档默认关闭,保留透传能力) |
| `/vision/frame` | POST | 视觉帧上报 |
> 若 GPU 主机与 dsh 不在一台:优先 **SSH 反向隧道**(`ssh -R` 或 autossh 保活,只把 orchestrator :8000 映射到 dsh 主机的 127.0.0.1:18000),代理指向 `http://127.0.0.1:18000`,彻底避免 GPU 主机暴露任何公网端口;GPU 主机防火墙仅放行 dsh 主机 IP 的出向建立。
---
## 六、用户数据隔离设计(多租户)
### 6.1 数据面与威胁面
| 数据类别 | 存在哪 | 生命周期 | 跨用户风险 |
|---|---|---|---|
| 用户语音流 | 浏览器内存 → WS → VoxEMW s2s(内存) | 会话结束即清 | 无(不落盘) |
| 摄像头帧 | 浏览器内存 → `/vision/frame`(`_last_frame` 单帧覆盖) | 单帧 | 无 |
| 转写/回复文本 | orchestrator 内存 + 浏览器转写区 | 会话结束即清 | 无 |
| persona/音色 | `personas/*.md`(VoxEMW 侧,共享目录) | 持久 | **中**:若共享目录,A 建的人设 B 可切到 |
| 会话占用状态 | SlotManager(dsh 进程内) | 实时 | 需要防串号 |
| LLM 调用(DeepSeek) | 云 | 账单 | **中**:单 key,无法按用户区分成本 |
| dsh 插件日志 | dsh 日志文件 | 持久 | 低:可能含转写/人设,需分级 |
### 6.2 三层隔离策略
1. **槽位层(并发互斥)**:SlotManager 保证同时只有 1 个 claim 存活;claim 的 key = `profileId`,**任何接管/释放都校验 claimId 归属**,杜绝 A 顶掉 B 或读到 B 的会话(VoxEMW 的"新连接顶旧连接"特性被槽位仲裁封死在上游)。
2. **配置层(persona/人设)**:VoxEMW 的 personas 由启动配置装载。落地:
- 每 profile 的 persona 包(`{profile}/voxemw/personas/*.md`)与 orchestrator 的共享 personas 目录**分离管理**;
- 插件持有"profile → persona_id"映射表(存 dsh 侧 profile 维度,不存 VoxEMW);
- 变更时走"热切换":会话内发 `vox.persona`;新增 persona 需重启 orchestrator 装载的场景,由 supervisor 在**无 claim 的空窗期**原子重载(`/voxemw/api/personas/reload`,M2/M3 范围)。
3. **数据/密钥层**:
- 语音/帧/转写**默认零持久化**(维持上游 `enable_live_transcription:false`),需要留存时显式开启并按 profile 落 `$DSH_HOME/storages/` 下用户目录,权限沿用 dsh setpriv/account 边界;
- `DEEPSEEK_API_KEY` 保持进程级,dsh 侧**不接触该密钥**;成本控制靠槽位互斥 + claim 期配额(每 profile 每日时长上限,超限拒 claim)+ 日志记账(profile 维度调用统计);
- 插件自身状态(claim/映射/审计)全部以 `profileId` 为主键,输出到 profile 自己的工作区。
### 6.3 与 dsh 既有隔离模型的衔接
- 若 dsh 部署为**每 profile 独立实例**(`ISOLATION=account` + setpriv):插件 index.js 天然运行在用户实例内,SlotManager 需改为**跨实例协调**(共享一个小型注册表:Redis/文件锁/Unix socket,按 profileId 加锁);推荐把 SlotManager 做成 dsh 主进程侧单例,实例侧只做 claim RPC。
- 若 dsh 为**集中式多用户**(dshs 按 token 分离会话):插件在请求上下文拿 `profileId`,所有写操作按 profile 命名空间隔离,逻辑同上。
### 6.4 风险清单(P1/P2)
| 级别 | 风险 | 说明 | 缓解 |
|---|---|---|---|
| P1 | 多用户并发被物理限制为 1 路 | 上游单管线单会话 + 单卡算力 | 槽位互斥 + 排队 + 强制接管策略(产品上明示"魔镜单席位") |
| P1 | 无鉴权裸奔 | orchestrator 0.0.0.0 无认证,公网可白嫖 | 永不直连公网;仅 dsh 代理访问;GPU 主机 443/白名单;代理层对 `/voxemw/*` 校验 dsh 会话 |
| P1 | 前端需安全上下文 | 裸 http 拿不到麦克风/摄像头 | 一律经 https 域同源代理;iframe `allow="microphone; camera"` |
| P2 | 共享 personas 目录串扰 | A 建人设 B 可见 | profile 维度 persona 包 + 映射表隔离(§6.2-2) |
| P2 | 单 DeepSeek key 无分账 | 无法按用户限流 | claim 配额 + profile 维度用量记账(M3) |
| P2 | 转写/文本进日志 | 共享日志跨用户可读 | 日志分级:语音链路事件降到 warning;含人设/转写日志按 profile 独立文件 |
| P2 | iframe 内嵌 UI 适配 | 全屏沉浸页塞窄栏体验打折 | embed/ 窄栏 CSS + 面板内全屏/新窗口模式;VoxEMW web/ 基本是响应式 canvas,适配成本低 |
| P2 | 远程隧道保活 | 断隧 = 面板失效 | autossh/心跳 + supervisor 探活;VoxEMW 自身有断线自愈,主要保链路 |
---
## 七、实施路线(里程碑 + 验证点)
**M1 — 服务可达 + 面板可见(1 个工作日量级)**
1. GPU 主机起 `start_4090.sh`,确认 orchestrator :8000 各积木就绪(`scripts/avatar_probe.py` / `s2s_text_probe.py` 可参考)。
2. dsh 侧先手工验证:BT 反代或 SSH 隧道把 8000 映射到 dsh 域 `/voxemw/`;浏览器开 https 页,确认麦克风授权、能对话、数字人出画。
3. 搭插件骨架(package.json + cordis.patch.yml + 空 apply),client 只做"会话分栏 + iframe 指向 /voxemw/",host 只做纯透传。
- ✅ 验证:在 dsh 会话旁看到魔镜面板且完整可用;关面板后会话区无损恢复。
**M2 — 槽位仲裁 + 隔离 + 人设映射**
4. SlotManager claim/release/keepalive + 占用/排队 UI + 强制接管。
5. profile→persona 映射 + 会话内自动 `vox.persona` 注入;personas 目录 profile 化改造。
6. 代理层注入会话校验(非 dsh 登录态一律 401);面板关闭自动 release。
- ✅ 验证:双账号交替/并发用例(A 占用时 B 看到占用并可排队/接管;A 会话不被 B 看到);A 的自定义人设 B 不可见。
**M3 — 加固与运营**
7. 日志分级/脱敏(按 profile 分文件);用量记账 + 配额;supervisor 状态页(对标 /swb/status:隧道健康、各积木 up/down、claim 占用者)。
8. 窄栏 UI 适配 polish + 全屏/新窗口模式;`README` 安装运维文档。
- ✅ 验证:审计日志只含本人数据;GPU 主机零公网端口可被外部探测;断隧自动恢复。
---
## 八、环境前置与成本(不回避)
| 项 | 要求 | 备注 |
|---|---|---|
| GPU | **1× 4090 24GB(或同档)**,四模型 ~21.7G | 无 GPU 则只有 Mac 轻量档(v1.9.0,无写实数字人)可选,架构同但产物不同 |
| 网络 | dsh 主机 ↔ GPU 主机可建隧道/白名单;dsh 域名 https | 复用现网 dsh.alotbuy.com 反代能力 |
| 密钥 | `DEEPSEEK_API_KEY`(GPU 主机环境变量) | dsh 侧不持有 |
| 模型下载 | HF:Qwen3-ASR / VoxCPM2 / SoulX-FlashHead / MiniCPM-V | 按 `docs/plan-4090.md` 预装(一次性) |
| dsh 侧 | 已装 mcn 壳(plugin_package 生态) | 本插件走 feature-tier 注册 |
| 兼容性红线 | 不改写 `/usr/local/lib/node_modules/@deepseek-ai/dsh` 官方 bundle;不动 VoxEMW 上游 | 全部改动隔离在 dsh-plugin-voxemw 目录 + GPU 主机私有部署目录 |
---
## 附录 A:代码证据索引(供复核)
| 结论 | 证据 |
|---|---|
| VoxEMW 六积木架构 | `README.md`("架构(六块积木)")、`docs/plan-4090.md` |
| 路由表 | `voxemw/gateway/orchestrator.py` L779-787(add_get/add_post/add_static) |
| 单用户单会话 | `orchestrator.py` L678 `current_session: dict`;L733-765 `ws_handler` 顶替逻辑;顶部 docstring "单用户单会话" |
| 浏览器协议 | `orchestrator.py` docstring(/ws 帧、/rtc/offer、/rtc/ice) |
| 无状态 | `Session._assistant_history` 仅 2 轮(L225);`enable_live_transcription:false` |
| personas 静态装载 | `assistant.yaml` personas.list;orchestrator L640 `config["personas"]["resolved"]` |
| 守护+内嵌模板 | `dsh-plugin-social-workbench/lib/index.js` L4-7(/swb/status\|start\|stop\|log);`client.js` iframe L115、`__mcnEntries` apply() |
| 会话分栏技术 | `dsh-plugin-mcn/lib/client.js`:`.mcnNav_split` CSS、`[data-slot='conversation']{display:contents}`、`.mcnNav_resizer{flex:0 0 5px}`、`startResize`、`localStorage["mcnNav.panelSide"]` |
| feature-tier 注册 | `douyin-accounts/lib/client.js` L649-652 `window.__mcnEntries.push({id:'douyin-accounts',...})` |
## 附录 B:名词对照
| 名词 | 说明 |
|---|---|
| s2s | speech-to-speech 实时语音管线(VAD→STT→LLM→TTS) |
| pacer / audio_pacer | orchestrator 内音频滴灌调度(对齐数字人渲染) |
| persona | 人设包:正文 + 音色(VoxCPM2 voice_control/voice_seed) |
| __mcnEntries | mcn 壳暴露给功能插件的页面注册表(window 全局) |
| data-slot='conversation' | dsh 聊天区 DOM 标记,插件以此定位并包入分栏容器 |
| claim / SlotManager | 本方案引入的"魔镜单席位"占用仲裁 |
@@ -0,0 +1,205 @@
# 13-插件管理面(修订 v5:入库审查门禁 + 用户自选启用,Step 0 核查结论已回填)
> 状态:**方案草案 v5,待用户拍板 3 个决策点后实施**|2026-09-10|对象:dshs + dsh 0.1.2-rc.1
> v3 范围(07:57):普通用户可查看 admin 上传的全部插件,卡片多选 →「启用」弹窗确认 → 才复制进用户 profile → 重启该用户 dsh 实例后生效。
> v4 范围(08:00):admin 上传一律先进审查区,过「安全检测 + 多用户检测」无 P0/P1 才可发布入库;用户选择页仅展示 published。
> v5 范围(08:1x,Step 0 服务器实测回填):**平台已自带部分插件机制**(见 §四),本方案收敛为在其上新增四件事——admin 全局库+审查门禁、用户拉取式安装(复制进 profile)、启用后**带新 patch 立即重启**(新增 relaunch)、插件选择页。启用状态源修正为 DB `folder_plugins`(非 profile patch disabled 行)。
> 评审稿:`D:\AI技能\aliyun-dsh-server\插件管理面_调整方案草案_20260910.md`;核对后定稿 `04-调整方案/` 档案 13 双端同步。
## 一句话结论
平台已能"列出用户 profile 已装插件 + 按工作区 folder 勾选启停(launch 时渲染 patch 生效)",且有桌面端「插件」面板(desktop.html);**缺的是上游**:admin 的全局插件库与入库审查门禁、用户把库插件**安装**进自己 profile 的入口、启用后**立即生效**(现有机制只在下次 launch 生效)。本方案补上这四块:admin 上传 → 审查区(安全 S1-S10 + 多用户 M1-M9,P0/P1 阻断,报告留痕)→ 发布入库;用户在「插件选择页」浏览 published 库 → 卡片勾选(批量)→「启用」弹窗确认 → 后端 安装(复制进 profile + 追加 bundles;已装则跳过)→ 写入当前工作区 enabled 集 → **relaunch(重算 patch 并重启实例)** → 生效。
## 一、角色与流程
| 操作 | admin | 普通用户 |
|---|---|---|
| 查看已发布插件库(选择页) | ✅ | ✅ |
| 上传插件(进审查区,≠入库) | ✅ | ❌ |
| 检测/出报告(安全 + 多用户) | ✅(自动 + 复核终审) | ❌ |
| 发布入库 / 打回需改造 | ✅ | ❌ |
| **安装**(从库复制进自己 profile) | ✅ | ✅(选择页,随启用自动) |
| **启用**(= enabled 集 + relaunch) | ✅ | ✅ **核心动作** |
| **禁用**(= 移出 enabled 集 + relaunch) | ✅ | ✅ |
| 删除/移除已发布包(全局) | ✅ | ❌ |
**插件入库时序(admin)**:同 v4(upload → under_review → 自动检测 S/M → 无 P0/P1 + admin 复核 → release → published)。
**用户启用时序(v5 精确流程,对齐平台真实机制)**:
```
1. 打开「插件选择」页 → GET /api/plugins/library?folder=<当前工作区>
→ published 全量 × 本人状态:未安装 / 已安装-未启用 / 已启用(本 folder)
2. 勾选卡片(可批量)→ 点「启用」
3. 弹窗确认:插件清单 + 「将安装到你的个人配置并重启你的 dsh 实例,当前会话可能中断」
4. POST /api/plugins/library/enable {names:[...], folder}
5. 后端 per 插件:
a. 未安装 → 从 published 库复制包目录 → profile/node_modules/<pkg>
+ chown 用户 uid/gid(先顶层再子项)+ profile package.json dsh.profile.bundles 追加
(冲突预检:短 id/路由/官方名/同名不同版本 → 409)
b. enabled 集(DB folder_plugins,按 folder workspace)写入该插件 id
c. orchestrator.relaunch(user):按该 folder 重算 patch = renderPatch(enabled∩installed)
→ 以新 patch 重启 main + watchdog(复用既有 launch 的计算逻辑,抽公共函数)
6. 返回 → 页面状态刷新 = 已启用;工作台出现插件入口
```
**禁用时序**:勾选已启用插件 → 「禁用」→ 确认 → enabled 集移除该 id → relaunch → 入口消失(profile 副本保留,再次启用免复制秒级恢复)。
## 二、总体机制
```
admin 上传 ─▶ 审查区 under_review(dataRoot/plugin-reviews,用户不可见,root 600)
│ 自动静态检测 S1-S10 + M1-M9 → 报告(P0/P1/P2)
├─ P0/P1 → needs_fix 需改造 ─▶ 改造重传复检
└─ 无 P0/P1 + admin 复核 ─▶ release ─▶ published 库
│ dataRoot/plugin-library(root 600 只读)
▼
插件选择页 plugins.html(仅 published)
│ enable = 安装(复制进 profile)+enabled集+relaunch
▼
用户 userRoot=dataRoot/users/<uuid>/
├─ home/profiles/web/package.json ← dsh.profile.bundles(installed 源)
├─ home/profiles/web/node_modules/<pkg> ← 复制落点(chown 用户)
├─ home/profiles/web/cordis.patch.yml ← role patch,勿动(ensure-role-profile-patch.cjs 管)
└─ patches/main.yml ← launch/relaunch 渲染的 --patch(DB enabled 派生)
▼ relaunch(新 patch)→ dsh --profile web 重启 → 生效
```
- **installed 与 enabled 是两态**:installed = profile 已含该 bundle(复制+声明);enabled = 当前工作区 folder 的 DB enabled 集(决定 launch patch 是否注入)。桌面端既有「插件」面板只做 enabled 勾选(下次 launch 生效,不重启);选择页做"库浏览 + 安装 + 启用(立即 relaunch)"。
- **共享库 = 源,profile = 副本,enabled 集 = 状态**;禁用 ≠ 卸载(副本保留)。
- 用户选择页仅 published;审查区/报告仅 admin 可见。
## 三、入库审查门禁(安全检测 + 多用户检测)— 同 v4
**放行规则**:canRelease = 无 P0 且无 P1;P2 不阻断留档;admin 人工复核为 release 必选终审;检测只读扫描、不执行包内脚本;报告 JSON 留痕(root 600,按版本保留)。
**安全检测 S1-S10**:
| # | 检查内容 | 级别 | 整改建议 |
|---|---|---|---|
| S1 | 归档:恰一个顶层目录;无 `..`/符号链接逃逸/设备文件 | **P0** | 重打包 |
| S2 | package.json 合法;`dsh.bundle.patch` 存在且指向存在文件 | **P0** | 补清单 |
| S3 | `@deepseek-ai/*`、官方同名 | **P0** | 改名(红线 2) |
| S4 | install/postinstall 等 npm 脚本 | **P0** | 移除 |
| S5 | exec/spawn、动态 eval/Function、vm 逃逸 | P1 | 说明+复核;纯函数化 |
| S6 | http(s) 出网非白名单 | P1 | 登记域名+用途 |
| S7 | 混淆/隐藏文件/超大二进制(>5MB 说明) | P1 | 去混淆可审计 |
| S8 | fs 写绝对路径/越出插件目录/触碰他人 profile | **P0** | 改注入的相对路径 |
| S9 | chown/root 级写/监听 0.0.0.0 | P1 | 移除,编排器统一管 |
| S10 | client 硬编码 token/密钥/内网地址 | P1 | 走 profile 密文配置 |
**多用户检测 M1-M9**(针对单用户 dsh 开发假设):
| # | 检查内容 | 级别 | 整改建议 |
|---|---|---|---|
| M1 | 硬编码绝对路径/固定配置位置 | P1 | 注入的相对路径/dataDir |
| M2 | 独占固定端口/0.0.0.0 | P1 | 实例内路由/动态端口 |
| M3 | 共享外部服务单槽(VoxEMW current_session 教训) | P1 | 无状态/云 API/多槽 |
| M4 | 持久化写到 profile 外共享全局文件 | **P0** | profile 内 JSONL |
| M5 | 短 id/路由/`__mcnEntries` 键与已发布或审查中冲突 | **P0** | 登记表比对改唯一 |
| M6 | 模块级可变单例跨租户串用 | P1 | 随实例或按用户键 |
| M7 | 无锁写共享资源/假定唯一活跃会话 | P1 | 单写锁/编排器串行 |
| M8 | 大模型/GPU/长驻进程等资源声明 | P2 | 卡片标注,admin 视容量放行 |
| M9 | 假定热加载/无需重启 | P2 | 明示需重启(页面已含提示) |
## 四、Open Questions 前置核查结论(Step 0 服务器实测,2026-09-10)
| # | 结论(实测事实) | 对实现的影响 |
|---|---|---|
| OQ1 | **profile 布局 ✅**:dataRoot=`/var/lib/dshs`(env `DSHS_DATA_ROOT`);`userRoot=dataRoot/users/<uuid>`(owner=每用户 uid/gid,如 `dsh-eeccbc...`/100002);profile=`userRoot/home/profiles/web`(MAIN_PROFILE `web`);installed 源=profile `package.json` 的 `dsh.profile.bundles`;包实体=`profile/node_modules/<pkg>` | 复制落点=该 profile node_modules;写后 chown 用户 uid/gid(先顶层再子项) |
| OQ2 | **复制式安装未实证,待 dry-run**:现 profile node_modules 无 `@deepseek-ai/*` 实体(未运行/由 dsh CLI 首启安装);listInstalledPlugins 按 `node_modules/<pkg>/package.json` 读 description | 实现后用无害测试插件在测试用户上 dry-run:复制 → listInstalledPlugins 可见 → relaunch 生效;若 pnpm/安装逻辑清掉手放目录 → fallback `dsh plugin add`(走 CLI) |
| OQ3 | 新用户 profile 由 spawn 首启 `dsh --profile web` 创建(09-09 建号已见 profiles/web + node_modules 目录骨架) | 复制目标不存在 → ensureDir + 最小骨架 package.json(bundles:[])+ chown;极端情况走 OQ1 错误引导 |
| OQ4 | **✅ 有用户域重启端点但语义修正**:`POST /api/dsh/restart {command}`(requireAuth,操作 `request.user.id`)调用 `orchestrator.restartMain`,但 **restartMain 复用内存里的旧 patch,只能重启不能换插件集** | **新增 `orchestrator.relaunch(userId)`**:按当前 main 的 folder 从 DB 重算 patch(与 launch route 同逻辑,抽公共函数)→ 以新 patch 重启 main + watchdog。enable/disable 内部调用它,不新增公开端点亦可 |
| OQ5 | **✅ 平台已有插件启停面(桌面端)**:`desktop.html`「插件」面板调 `GET /api/plugins?folder=`(已装 + 每 folder enabled)与 `POST /api/plugins/select`(存 DB `folder_plugins`),**无重启动作,下次 launch 生效**;backend 已注册 `pluginRoutes`(09-08 底座)。官方 dsh 会话内设置是否另有插件分区:无运行实例未实证,UI 验证阶段补查 | 双 UI 定位:选择页 = 库浏览+安装+启用(立即 relaunch);桌面面板 = 已装插件快速勾选(保留)。desktop 加提示"装新插件请到插件选择页";若官方会话设置确有启停分区,再加互链(记录为验证项,不阻塞 v1) |
| OQ6 | **✅ 状态源修正**:installed = profile `dsh.profile.bundles`(listInstalledPlugins 现有实现);enabled per folder = DB `folder_plugins`(workspaces 按 user+folder 键)。**草案旧假设"profile cordis.patch.yml 写 enabled/disabled 行"不适用**;且 profile 现存 cordis.patch.yml 是 role patch(`ui-settings-models` disabled,ensure-role-profile-patch.cjs 管理)——**勿手改** | enabled 一律走 DB 集操作(getOrCreateWorkspace + setFolderPlugins / getEnabledPluginIds 已存在) |
| OQ7 | 冲突检测:复制时按 name 比对 profile 已装版本(bundles + node_modules 包 version);库 release 时同名同版本拒绝覆盖 | 409 version-conflict;卡片显示"已装 vX,库 vY" |
| 附加 | launch route 已实现 patch 计算(`renderPatch(enabled∩installed)`)但为内联逻辑;`renderPatch` 恒注入 `dshs-runtime`(supervisor/patch.ts) | 抽 `patchForLaunch(...)` 公共函数,launch/relaunch 共用,行为零变化 |
## 五、API 设计
统一基址 `/api/plugins`;鉴权 requireAuth / requireAdmin。**既有 `/api/plugins` GET 与 `/api/plugins/select` 保留不动**(桌面端在用)。
**用户(新)**
| 接口 | 行为 |
|---|---|
| `GET /api/plugins/library?folder=` | published 全量(name/version/desc/hasClient/hasServer/reviewedAt/pkgSize)+ 本人状态 `{state: none\|installed\|enabled, installedVersion}` |
| `POST /api/plugins/library/install` | body `{names:[], folder?}` → 仅安装(复制+chown+bundles 追加),不启停 → `{ok, installed:[...]}`(供"仅安装稍后启用") |
| `POST /api/plugins/library/enable` | body `{names:[], folder?}` → 未装则先安装 → 写 enabled 集 → **relaunch** → `{ok, enabled:[...], restarted:true}` |
| `POST /api/plugins/library/disable` | body `{names:[], folder?}` → 移除 enabled 集 → relaunch → `{ok, disabled:[...], restarted:true}` |
**Admin(新)**
| 接口 | 行为 |
|---|---|
| `POST /api/plugins/admin/reviews` | 上传进审查区 → 自动检测 → `{reviewId,name,version,status,report}` |
| `GET /api/plugins/admin/reviews` | 审查区+库全条目(status/版本/报告摘要/P0·P1 计数/历史) |
| `GET /api/plugins/admin/reviews/:id` | 详情 + 最新报告全文 |
| `POST /api/plugins/admin/reviews/:id/release` | 发布入库;有 P0/P1 → 409 has_blockers;同名同版本 → 409 version-conflict |
| `POST /api/plugins/admin/reviews/:id/reject` | 打回 needs_fix + `{comment}` |
| `DELETE /api/plugins/admin/reviews/:id` | 丢弃审查条目 |
| `DELETE /api/plugins/admin/library/:name` | 移除已发布包(用户副本不受影响) |
| `GET /api/plugins/admin/users` | 只读矩阵:每用户 installed/enabled 状态 + 版本 |
错误码:`400 invalid_plugin_name / unsupported archive type / archive must contain exactly one top-level dir / missing package.json(dsh.bundle) / reserved-package(@deepseek-ai/*) / archive member escapes root`、`404 not_found`、`409 already_enabled / has_blockers / version-conflict / id-conflict`。
上传校验规则同 v4(恰一顶层目录、dsh.bundle.patch 必在、防穿越、禁 @deepseek-ai/*;校验通过 ≠ 入库)。
## 六、改动文件清单(服务器 /opt/dshs,备份 `*.bak-YYYYMMDD-HHMM`)
| 文件 | 改动 |
|---|---|
| `src/config.ts` | 加 `pluginLibraryDir`(默认 `<dataRoot>/plugin-library`)、`pluginReviewDir`(`<dataRoot>/plugin-reviews`) |
| `src/fs/plugins.ts` | 扩展:`installToProfile`(复制+chown+追加 bundles)、`isInstalled`/`installedVersion`(OQ7 比对) |
| `src/fs/plugin-library.ts` | **新增**:published 库读写/列表/删除(root 600 语义) |
| `src/lib/pluginScan.ts` | **新增**:静态检测引擎(S1-S10+M1-M9 + 分级报告,只读不执行) |
| `src/supervisor/orchestrator.ts` | **新增 `relaunch(userId)`**:重算 patch + 带新 patch 重启 main + watchdog;launch 内 patch 计算抽公共函数(行为零变化) |
| `src/web/routes/archive.ts` | **新增**:解压+穿越校验+chown 公共 helper(skills/plugins/reviews 复用) |
| `src/web/routes/pluginLibrary.ts` | **新增**:用户库(list/install/enable/disable → 内部 relaunch) |
| `src/web/routes/pluginReviews.ts` | **新增**:admin 审查生命周期 + 库 CRUD + users 矩阵 |
| `src/web/server.ts` | register 新增路由 |
| `web/plugins.html` | **新增**:用户=published 库卡片多选+启用/禁用+确认弹窗(含"将重启实例");admin=审查区(状态/报告/发布/打回)+库管理+矩阵 |
| `web/desktop.html` | 加「插件选择」入口 + 面板内提示"装新插件到插件选择页" |
| `web/login.html` / `admin.html` | 入口(admin 视图) |
| `lib/**` | `npm run build`(tsc) |
| 文档 | 本档案 + `03-路线图与待办.md` 登记;双端同步 |
## 七、关键机制与约束
1. **installed/enabled 两态分离**:install=复制+chown+bundles;enable=enabled 集(DB)+ relaunch;disable=移除 enabled 集 + relaunch(副本保留)。
2. **relaunch 语义**:只重算 patch(enabled∩installed ∩ 已装过滤同 launch route)并带新 patch 重启,不落库不改 profile 配置;复用既有 spawn/waitForLaunchToken 流程。
3. **chown 纪律**:复制后 chown 用户 uid/gid(先顶层再子项,档案 11 教训);共享库/审查区 root 600;检测全程只读。
4. **确认弹窗必含**:插件清单 + "将安装到你的个人配置并重启你的 dsh 实例,当前会话可能中断"。
5. **并发写锁**:按用户 id 单写锁(install/enable/disable 串行化);launch 与 relaunch 天然互斥(mains 检查)。
6. **重启中状态**:relaunch 期间页面置"重启中",防"未重启仍见旧 bundle"误判(poc v0.4.3)。
7. **红线补充**:**不写/不改 profile 既有 `cordis.patch.yml`(role patch,ensure-role-profile-patch.cjs 管理)**;不动官方 bundle 与 dsh 主程序缓存;不自动升级 dsh。
## 八、验证清单
**API(curl)**
- [ ] 无 sid → 401;用户访问 admin 接口 → 403
- [ ] admin 上传 → under_review;用户 GET library 不含该包
- [ ] 恶意包:`..` 成员/@deepseek-ai/install 脚本 → P0 拒收;exec 动态串 → P1 → release 409 has_blockers;needs_fix 报告可查
- [ ] 多用户包:硬编码绝对路径(M1)/写 profile 外共享文件(M4)→ 命中打回
- [ ] 改造重传 v+1 → 复检 → release → published → 用户 library 出现(reviewedAt)
- [ ] 同名同版本 release → 409 version-conflict
- [ ] enable 单/多:安装副本入 profile(chown 正确)+ bundles 追加 + enabled 集写入 + relaunch;`restarted:true`
- [ ] 未装包 enable = 自动先 install;重复 enable → 409 already_enabled;disable → 集移除 + relaunch;再 enable 免复制
- [ ] OQ2 dry-run:复制式安装后 listInstalledPlugins 可见、relaunch 后入口生效;不符 → 切 CLI fallback 并记录
- [ ] 报告落盘 pluginReviewDir root 600;删除已发布包后已装用户不受影响
- [ ] 回归:`GET /api/plugins` 与 `/api/plugins/select`(桌面在用)行为不变;profile cordis.patch.yml 未被改写
**UI(浏览器,admin + 用户)**
- [ ] 用户:选择页仅 published 卡片(未安装/已安装/已启用角标)→ 多选 2 → 启用 → 弹窗文案正确 → relaunch → 工作台两插件入口出现(bundle rev 对比)
- [ ] 禁用 → relaunch → 入口消失;桌面「插件」面板勾选仍可用(下次 launch 生效)
- [ ] admin:上传 → 审查状态流转 → 报告 → 发布 → 用户端可见;矩阵正确
- [ ] 回归:技能管理面(档案 11)不受影响;官方 @deepseek-ai/* 任何视图不出现
## 九、风险
| 级别 | 风险 | 缓解 |
|---|---|---|
| P1 | relaunch 打断当前会话(单活跃实例) | 弹窗强提示 + 确认;relaunch 复用既有 spawn 流程,失败自动回退(watchdog 机制兜底) |
| P1 | 复制式安装与 profile 的 pnpm/dsh 安装逻辑冲突(手放目录被清/不可解析) | OQ2 dry-run 先行;不符即切 `dsh plugin add` CLI fallback |
| P1 | 静态检测为启发式,无法证明绝对安全/绝对多用户 | 人工复核必选终审 + 来源明确 + 报告留痕;沙箱试运行列为增强 |
| P2 | 与桌面既有插件面板/官方会话设置双 UI | 定位分工 + 页面互链提示;验证项补查官方会话设置 |
| P2 | 用户装多个大插件占 profile 空间 | 库卡片展示体积;副本体积纳入 admin 矩阵 |
| P2 | 审查放行依赖人工复核效率 | 自动覆盖硬规则(S1-S4/S8、M4/M5),人工只复核启发式项 |
## 十、红线遵守
不自动升级 dsh;不改官方主程序与缓存(只操作 plugin-library/plugin-reviews + 用户 profile 层);拒绝 `@deepseek-ai/*` 入库;上传包不执行任何脚本;**不改 profile 既有 cordis.patch.yml(role patch)与 ensure-role-profile-patch.cjs 产物**;共享库/审查区 root 600。