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

11 KiB
Raw Blame History

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:

{ "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 打包上传。

回滚

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/(不删);用户个人技能不动