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 一律写「远程服务器」。
11 KiB
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,且 frontmattername合法 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}/applybody{ stagedId }→ 全量替换。 - 前端提示文案强调:
⚠️ 替换将删除旧技能的全部文件——新包中已移除的文件也会一并删除,不可恢复。
待办(后续)
- dsh 会话内
/技能名调用的 UI 最终验收(用户亲自测或后续脚本补充)。 - portal-entry 设置区块加「技能管理」入口(admin 在 dsh 会话内直达;需 portal-entry 客户端插件改动 + 实例重启 bundle 刷新)。
- 实施 MCN V1.0 技能上传(如确认):先改
SKILL.mdfrontmattername 为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/(不删);用户个人技能不动