Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/11-技能管理面-shared+mine-API与页面.md
T
admin 5ad755116e 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 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

176 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 11-技能管理面(admin 共享 + 普通用户个人)
> 状态:**已实施 + API 全链路验证 + 浏览器 UI 验证**|2026-09-09|对象:dshs + dsh 0.1.2-rc.1
> 需求来源:上一轮档案 10 落地了"全员共享只读技能层"(bundledSkillDir 机制),但无管理入口——上传/更新/删除都要 SSH 手动操作,本轮补完管理面。
> 范围:管理员可管理全员共享技能(上传/列表/删除)+ 每个普通用户可管理自己的个人技能($DSH_HOME/skills)。
> **TL;DR**|**结论**:补完技能管理面:admin 管**全员共享技能** + 每个用户管**自己的个人技能**(`/api/skills/{shared,mine}` + 页面)。
> **关键**:支撑档案 10 的共享只读层:此前上传/更新/删除都要 SSH,本轮给出管理入口。
> **状态**:✅ 已实施(API 全链路 + 浏览器 UI 验证)
## 一句话结论
编排器新增 `/api/skills/shared`(admin)与 `/api/skills/mine`(所有登录用户)三件套(GET/POST/DELETE)+ 静态页 `/skills.html` 统一管理 UI;前端上传 `.tgz/.zip` 包 → 后端走系统 `tar/unzip` 解压到目标根目录;上传即时生效(dsh-skill-filesystem watchManager 监听 addDir/unlinkDir/SKILL.md 变更自动 invalidate,无需重启实例**。
## 改动文件清单(服务器 /opt/dshs)
| 文件 | 改动 | 备份 |
|------|------|------|
| `src/config.ts` | 加 `bundledSkillDir` 字段(ServerConfig/ConfigOverrides/resolveConfig) | `.bak-20260909-1815` |
| `src/supervisor/spawn.ts` | ALLOWED_ENV 加 `'DSH_BUNDLED_SKILL_DIR'` | `.bak-20260909-1815` |
| `src/supervisor/orchestrator.ts` | `baseEnv()` 注入 `DSH_BUNDLED_SKILL_DIR`(来自 config) | `.bak-20260909-1815` |
| `src/web/server.ts` | import + register `skillRoutes` | `.bak-20260909-1825` |
| `src/web/routes/skills.ts` | **新增**(5 路由 + tgz/zip 解压 + 路径穿越校验 + chown/chmod) | — |
| `lib/**` | `npm run build` 产物(tsc) | — |
| `web/skills.html` | **新增**(admin 双区块 / 普通用户单区块,文件上传 base64) | — |
| `web/login.html` | 登录页底部加「技能管理」链接(auth-alt 行) | — |
| `web/admin.html` | 控制台顶部加「技能管理」链接(who 行) | — |
| `web/desktop.html` | 管理台窗口顶部加「技能管理」按钮 | — |
| `/var/lib/dshs/bundled-skills/` | **新建** root:root 0755(共享技能根) | — |
## 关键机制与约束(已验证)
### 1. dsh 技能名硬性规则(前端/后端校验都强制)
`@deepseek-ai/dsh-skill/lib/index.js` SKILL_NAME 正则:
```
/^[a-z0-9]+(?:-[a-z0-9]+)*$/
```
**只允许小写字母数字连字符(kebab-case)**。任何其它字符(包括中文、空格、下划线)都导致 dsh 静默丢弃技能(不报错但 registry 不收录)。
→ **MCN V1.0 等含中文名技能的部署前置**:上传前必须修改 `SKILL.md` frontmatter `name` 为合法 kebab(如 `短视频工作台` → `mcn-workstation`)。后端上传校验给出精确报错信息引导用户改名。
### 2. 上传包结构要求
- 后缀:`.tgz` / `.tar.gz` / `.tar` / `.zip`(其他拒绝)
- 包内必须**恰好一个**顶层目录(包名约定)
- 顶层目录下必须有 `SKILL.md`,且 frontmatter `name` 合法 kebab
- 后端校验路径穿越(拒绝绝对路径/包含 `..` 的成员)
### 3. 落盘策略
| 目标 | 属主 | 权限 | 写入路径 |
|------|------|------|---------|
| 共享层 `/var/lib/dshs/bundled-skills/` | root:root | 0755 | OS 权限兜底只读,用户进程不可写 |
| 个人层 `users/<id>/home/skills/` | `home` 属主(即用户 uid) | 0755 目录 / 0644 文件 | 用户可读可改自己技能(符合 user-dsh 层语义) |
### 4. 即时生效(无需重启实例)
`dsh-skill-filesystem/watchManager.ts` `observeRoots` 对所有 root(含 shared root,即 bundled)创建 watcher;`isRelevantWatchEvent` 对 `addDir` / `unlinkDir` / `SKILL.md` 变更均返回 true → invalidate skill registry → 下次 `discoverRoot` 重新扫描。新技能上传后**下次会话打开或会话内触发技能目录 RPC 即生效**。**理论无需重启实例**(已源码级确认,待最终 UI 验收)。
### 5. 实现细节踩坑记录
| 现象 | 根 | 修 |
|------|------|---|
| 上传后 hello-demo 目录属主 197611:197121(非 guest uid),guest 无法在技能目录内新建文件 | `chownTree()` 最初只 chown 子项,没 chown 顶层 `stagedTop` 自身;tar 解压时保留包内 owner | `chownTree()` 首行加 `chownSync(root, uid, gid)`(**已修复**) |
## API 设计(curl 验证全绿)
| 接口 | 鉴权 | 行为 |
|------|------|------|
| `GET /api/skills/shared` | requireAdmin | 列出 bundled 技能(name/description/size/mtime) |
| `POST /api/skills/shared` | requireAdmin | base64 body `{ file, filename, force? }` → 解压到 bundled-skills |
| `DELETE /api/skills/shared/:name` | requireAdmin | rm 目录 + audit |
| `GET /api/skills/mine` | requireAuth | 列出用户 DSH_HOME/skills(自动 chown 用户) |
| `POST /api/skills/mine` | requireAuth | 同上 + chown/chmod 到用户 uid/gid |
| `DELETE /api/skills/mine/:name` | requireAuth | rm 目录 |
通用 body schema:
```json
{ "file": "<base64 .tgz/.zip>", "filename": "x.tgz", "force": false }
```
错误码:`400 invalid_skill_name`(name 不 kebab)、`400 unsupported archive type`、`400 archive must contain exactly one top-level skill directory`、`400 archive is missing <top>/SKILL.md`、`400 archive member uses an absolute path`、`400 archive member escapes the root`、`400 SKILL.md frontmatter name must be kebab-case`、`409 skill already exists`、`404 not_found`。
## 验证记录
### API 端(curl + admin/guest sid)
| 测试 | 结果 |
|------|------|
| GET shared (无 sid) | 401 unauthorized |
| GET shared (guest sid) | 403 forbidden ✓ |
| GET shared (admin sid) 空 | `{"skills":[]}` ✓ |
| POST shared 合法 hello-demo.tgz | `{"ok":true,"skill":{...}}` ✓ |
| GET shared | 显示 hello-demo ✓ |
| POST shared 中文 name 包 | `{"error":"SKILL.md frontmatter name must be kebab-case [a-z0-9-] (got \"测试技能\")"}` ✓ |
| POST shared 重名 | `{"error":"skill \"hello-demo\" already exists — delete it first or upload with force=true"}` ✓ |
| POST shared force=true 覆盖 | ok ✓ |
| DELETE shared/hello-demo | ok + staging 自动清理 ✓ |
| GET mine (guest) | 200 ✓ |
| POST mine (guest) hello-demo | ok ✓ |
| 落盘检查 | `100002:100002` 755 目录 + `100002:100002` 644 SKILL.md ✓ |
| `sudo -u dsh-eeccbc... touch .../.write-test` | `guest 可写 OK` ✓ |
### UI 端(CDP 截图,存 `/skills.html`)
| 视角 | 截图 | 结论 |
|------|------|------|
| admin | `skills-admin.png`(D:/AI技能/aliyun-work-space/.tmp/skill-test/) | 显示共享技能区块(hello-demo + 删除按钮)+ 我的技能区块;底部提示完整 ✓ |
| 普通用户(guest) | `skills-guest.png` | 仅"我的技能"区块(hello-demo),共享区块隐藏 ✓ |
入口链接:login.html 底部「技能管理」+ admin.html 控制台顶部 + desktop.html 管理台窗口顶部。
### dsh 会话(验证 watch 即时性)
guest dsh 实例已 enter 拉起(`https://guest.dsh.alotbuy.com/?token=...`,主页"选择工作区"正常渲染),但完整"输入 / 触发技能菜单"需多层 dsh UI 自动化(选工作区→开 composer→触发菜单),成本较高。本次实施已完成三层证据(API + 落盘 + UI 截图),**watch 即时生效已源码级确认**,UI 最终验收建议用户亲自登录体验或后续在 portal-entry 设置区块加入口再补。
## v2 改造(2026-09-09,同日二次迭代)
> 需求:覆盖同名(force 直传)不可靠——新技能少了某文件时旧文件可能残留;要求**只支持 zip**、**zip 必须含 dsh 标准技能文件**(SKILL.md + name/description)、**检测通过后提示是否替换**、**确认替换后旧技能多余文件一并删除**。
### 变更(相对 v1)
| 维度 | v1 | v2 |
|------|----|----|
| 上传格式 | tgz/tar.gz/tar/zip | **仅 zip**(后端 `unzip -Z1` 校验成员 + 解压) |
| 校验强度 | name kebab | name kebab **+ description 必填**(dsh parseSkillFile 两者皆必需)+ 单顶层目录 + SKILL.md + 拒多顶层/绝对路径/`..`/空包 |
| 同名处理 | POST 直接 rm+rename(force 参数) | **两阶段**:POST upload 校验并暂存 → 同名则返回 `conflict:true + stagedId`(不动旧技能)→ 前端 confirm → POST `/api/skills/{scope}/apply` |
| 整体替换保证 | rm target 后 rename | 同(apply 内 rm 旧目录全量 + rename 新目录;**新包缺失的旧文件随旧目录整删消失**) |
| 取消/超时 | — | staging 10 分钟 TTL,下次上传时 GC(`collectStaleStages`) |
| 列表字段 | name/description/size/mtime | 增加 **files**(文件数,UI 表格加「文件」列) |
### 验证(curl + UI CDP 全链路)
| 场景 | 结果 |
|------|------|
| 无同名上传 zip | 直接安装 ✓ |
| 同名上传(3 文件 → 2 文件新包) | 返回 conflict + stagedId;**旧目录未被改动** ✓ |
| apply | 旧 `legacy.txt` 被删除(新包缺失文件随旧目录整删)✓、新 note.md 内容更新 ✓ |
| 多顶层 zip | 400 `zip 必须包含且仅包含一个顶层技能目录` ✓ |
| 非 .zip(.tgz) | 400 `仅支持 .zip 文件` ✓ |
| UI 端到端(DOM.setFileInputFiles + confirm 自动 accept) | 弹窗 → 确认 → `✓ 已整体替换技能「hello-demo」(2 个文件)`;表格文件列=2 ✓ |
| staging 残留 | apply/删除后无 `.skill-upload-*` 残留 ✓ |
### 页面/API 形态(v2)
- POST `/api/skills/{shared,mine}` body `{ file(base64), filename }` → 无冲突直接 `{ ok, skill }`;有冲突 `{ ok, conflict:true, stagedId, skill:{name,description,files}, existing:{name,size,files} }`。
- POST `/api/skills/{shared,mine}/apply` body `{ stagedId }` → 全量替换。
- 前端提示文案强调:`⚠️ 替换将删除旧技能的全部文件——新包中已移除的文件也会一并删除,不可恢复`。
## 待办(后续)
- dsh 会话内 `/技能名` 调用的 UI 最终验收(用户亲自测或后续脚本补充)。
- portal-entry 设置区块加「技能管理」入口(admin 在 dsh 会话内直达;需 portal-entry 客户端插件改动 + 实例重启 bundle 刷新)。
- 实施 MCN V1.0 技能上传(如确认):先改 `SKILL.md` frontmatter `name 为 `mcn-workstation`/`lieflat-charts` 等合法名 → 平铺子技能到 bundled 根 → V1.0 裁剪 290MB → ~30MB tar.gz 打包上传。
## 回滚
```bash
cd /opt/dshs
# 1. 还原源码
cp src/config.ts.bak-20260909-1815 src/config.ts
cp src/supervisor/spawn.ts.bak-20260909-1815 src/supervisor/spawn.ts
cp src/supervisor/orchestrator.ts.bak-20260909-1815 src/supervisor/orchestrator.ts
cp src/web/server.ts.bak-20260909-1825 src/web/server.ts
rm -f src/web/routes/skills.ts
# 2. 还原页面(用 git checkout 或手编)
rm -f web/skills.html
# 3. 重编 + 重启
npm run build && systemctl restart dshs
# 4. 实例 env 不再有 DSH_BUNDLED_SKILL_DIR,dsh 侧 bundled 层不再生效
# 5. 共享技能仍留在 /var/lib/dshs/bundled-skills/(不删);用户个人技能不动
```