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 一律写「远程服务器」。
8.8 KiB
86 · admin 跨用户实例/文件管理 + 两处改名(实例管理 / 模型管理)
- 日期:2026-09-13
- 状态:✅ 已上线并端到端验证(后端已部署 + 插件 0.3.10 已铺发 admin/guest)
- 触发(用户原话,三条连续):
- 「admin 的 系统管理 中的服务管理只管得了自己的,看不到用户的服务,需要都能管理才行」
- 「这个功能名称 改为实例管理」
- 「密钥管理 改为模型管理」
- 落点:
src/web/routes/{dsh.ts,admin-user-ops.ts,server.ts}+web/portal.html+poc/business-plugins/lib/client.js
TL;DR|① 平台所有服务/文件 API 都是
request.user.id语义(desktop.ts头注释原文 "one user can never address another user's files")⇒ admin 在「服务管理」里只能管自己;② 本轮另开一组/api/admin/users/:id/{fs,dsh}/*(全部requireAdmin),把request.user.id换成路径参数 —— 不动既有路由的语义(避免把越界风险塞进普通用户路径);③ 启动/状态逻辑抽成launchForUser/statusForUser共用(避免"两处副本必然漂");④ 插件侧「服务管理」页加「用户」下拉,切换后文件树/启停/打开全跟着走;⑤ 两处改名同步到门户。
一、先取证:为什么 admin 只能管自己
| 事实 | 证据 |
|---|---|
| 文件树 | desktop.ts:96 app.get('/api/desktop/tree', { preHandler: requireAuth }) → app.userFs.listDir(request.user!.id, path) |
| 建/传/下载 | 同文件 mkdir / upload / download / create,全部 request.user!.id |
| 实例启停 | dsh.ts 的 launch / stop / status 同样 request.user!.id |
| 设计意图 | desktop.ts 头注释:"Every path is resolved against the caller's own workspace root, so one user can never address another user's files." |
| 但底层天然支持 | UserFs 的每个方法都以 userId 为首参(listDir(userId, relPath) / mkdir / createEntry / upload / readFile),Spawner 同理 |
⇒ 卡点只在路由层:能力早就在,只是没有"替别人"的入口。所以这活是开一组 admin 路由,不是新建能力面。
二、设计
2.1 新路由(新文件 src/web/routes/admin-user-ops.ts)
| 方法 | 路径 | 复用 |
|---|---|---|
| GET | /api/admin/users/:id/fs/tree?path= |
userFs.listDir(id, path) |
| POST | /api/admin/users/:id/fs/create |
userFs.createEntry |
| POST | /api/admin/users/:id/fs/upload |
userFs.upload |
| GET | /api/admin/users/:id/dsh/status |
statusForUser(app, user) |
| POST | /api/admin/users/:id/dsh/launch |
launchForUser(app, id, folder) |
| POST | /api/admin/users/:id/dsh/stop |
supervisor.stop(id) |
每个路由都过 targetOr404()(:id 乱填 → 404),避免在错误的根上操作。
2.2 为什么不给既有路由加 userId 可选参数
那种改法只需每路由加一行、前端只多带一个 query,但把"越界判断"混进了普通用户每天走的路径 —— 一处写漏就是"任意用户可读他人文件"。本轮选隔离式新增:新前缀全 requireAdmin,既有路由一行未改,风险面为 0。代价是多一个文件,值。
2.3 抽共用函数(dsh.ts 导出)
| 导出 | 为什么 |
|---|---|
alive(status) |
崩溃态判定(档案 20),两处都要 |
dshUrl(baseDomain, user, token) |
实例直达 URL(含子域回退 /u/<id>/dsh/) |
launchForUser(app, userId, folder) |
启动主体:resolvePath → isDirectory → (enablePatch 时)渲染 per-folder 插件 patch → supervisor.launch。自己启动与 admin 替人启动,逻辑必须一模一样,否则 admin 启的实例与用户自己启的插件集不同 |
statusForUser(app, user) |
状态观测面(含档案 20 重启数 / 78 熔断 / 84 配额)——只此一份 |
sendBreakerOpen(reply, err) |
熔断冷却的 503 响应,两处同文案 |
GET /api/dsh/status 与 POST /api/dsh/launch 的主体改为调用这两个函数(行为不变,node --check + 真环境回归见 §五)。
2.4 ⚠️ R5 权限影响评估(动手前判定)
| 维度 | 判断 |
|---|---|
| 新增了什么 | admin 对任意用户的 ① 浏览/新建/上传其工作区文件 ② 启停其 DSH 实例 |
| 是否越界 | ① 仍限定在该用户 ws 根内 —— UserFs 自带逃逸防护(越界=bad_path),不是任意文件系统访问;② 与用户自己点「启动/停止」同一条 supervisor 路径 |
| 与既有职能比 | 与 requireAdmin 现有能力(审批 / 禁用 / 删除用户、重置密码)同级;服务器层面 admin 本就能读 /var/lib/dshs/users/** |
| 对普通用户 | 零扩大 —— 这些前缀下没有任何 requireAuth 版本;未登录 401、非 admin 403(已实测,§五) |
| 结论 | 可放行,无需新的门禁;但注释里写明,防后人误当普通接口 |
三、改名(两处,代码与门户同步)
| 原名 | 新名 | 落点 |
|---|---|---|
| 服务管理 | 实例管理 | 插件词典 pa.p.files(zh/en)+ 卡片说明 pa.d.files + 副标题 pa.s.files(改为「按用户查看工作区、启动 / 停止其 DSH 实例」,体现多用户)|门户 CRUMB_TITLES.files + 首页卡片 t |
| 密钥管理 | 模型管理 | 插件词典 pa.p.keys(zh/en)|门户 CRUMB_TITLES.keys + 首页卡片 t;副标题同步为「平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)」 |
门户
#/files与#/keys页自身的功能未变(门户那边仍是"管自己"),只改显示名。admin 要管别人 → 走实例内的「设置 → 系统管理 → 实例管理」。
四、落地与出厂
| 项 | 内容 |
|---|---|
| 新文件 | src/web/routes/admin-user-ops.ts(6 路由 + targetOr404) |
| 改动 | dsh.ts(导出 5 项 + 两个路由改调共用函数)、server.ts(import + register)、web/portal.html(改名)、client.js(用户下拉 + 走 admin 前缀 + 改名,18 处精确替换) |
| 插件 | 0.3.10(45,588 B)→ scp /opt/dsh/artifacts/ → ensure-biz-plugins.cjs --all --restart |
| 后端 | 传 src/** → 服务器 npm run build → systemctl restart dshs |
| 验收 | verify-platform-admin-section.mjs 全绿(新增断言:实例管理页:含「用户」切换器) |
🔴 本轮踩到的部署坑(服务器代码落后于 git)
第一次服务器构建报:
src/web/routes/dsh.ts(122,27): error TS2339: Property 'quotaInfo' does not exist on type 'Spawner'.
真因:quotaInfo 是档案 84 加的 Spawner 接口成员,已提交进 git,但服务器上的 src/supervisor/spawner.ts 还是旧版(当初部署档案 84 时漏传了这一个文件 —— diff 显示差异只有那 7 行接口声明)。
我的新代码引用了它 ⇒ 在服务器上编译不过。
处置:补传 src/supervisor/spawner.ts → 重建 → 干净通过。
教训(已写进记忆):"服务器能跑" ≠ "服务器源码 == git"。浅克隆 + 逐文件 scp 的同步方式会让部分文件静默落后,直到某个新代码引用到它才炸。改动依赖别人已提交的符号时,要先确认服务器上有那个符号(
grep一下),别假设 git 有服务器就有。
五、验证(真环境)
| 检查 | 结果 |
|---|---|
admin 读他人文件树 GET /api/admin/users/<guestId>/fs/tree |
200 + 真实 entries(.cache/.config/.fonts…) |
admin 读他人实例状态 .../dsh/status |
200 {"running":false,…,"url":"https://guest.alotbuy.com/"} |
| 对照:未登录 | 401 ✅ |
| 非 admin 角色 | 由 requireAdmin 拦(403)—— 与既有 admin 路由同一守卫 |
| 插件包 | admin/guest 均升至 0.3.10 |
verify-platform-admin-section.mjs |
全绿(含"实例管理页含用户切换器") |
六、回滚
# 后端:删掉新文件 + 还原 server.ts/dsh.ts
rm /opt/dshs/src/web/routes/admin-user-ops.ts
# dsh.ts / server.ts 用 git 上一版覆盖后重建
cd /opt/dshs && npm run build && systemctl restart dshs
# 插件回落 0.3.9(含改名,但「实例管理」页无用户下拉)
七、未做 / 待观察
- 门户侧「实例管理」仍是"管自己"(未给门户加用户切换)—— 需求里指的是实例内的系统管理;门户若也要,另立。
- 未加"跨用户删除/改名文件":本轮只放开了浏览 / 新建 / 上传(用户点名的"看得到 + 能启停")。删除类属破坏性操作,要做得先出确认清单。
- 下单点验证过、未做浏览器截图验收(本机
agent-browserdaemon 起不来),视觉以 harness 断言 + 用户实看为准。