Files
dsh_shenxian/dsh-server-docs/04-调整方案/86-admin跨用户实例管理-两处改名.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

8.8 KiB
Raw Blame History

86 · admin 跨用户实例/文件管理 + 两处改名(实例管理 / 模型管理)

  • 日期:2026-09-13
  • 状态:✅ 已上线并端到端验证(后端已部署 + 插件 0.3.10 已铺发 admin/guest)
  • 触发(用户原话,三条连续):
    1. 「admin 的 系统管理 中的服务管理只管得了自己的,看不到用户的服务,需要都能管理才行」
    2. 「这个功能名称 改为实例管理」
    3. 「密钥管理 改为模型管理」
  • 落点: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(含改名,但「实例管理」页无用户下拉)

七、未做 / 待观察

  1. 门户侧「实例管理」仍是"管自己"(未给门户加用户切换)—— 需求里指的是实例内的系统管理;门户若也要,另立。
  2. 未加"跨用户删除/改名文件":本轮只放开了浏览 / 新建 / 上传(用户点名的"看得到 + 能启停")。删除类属破坏性操作,要做得先出确认清单。
  3. 下单点验证过、未做浏览器截图验收(本机 agent-browser daemon 起不来),视觉以 harness 断言 + 用户实看为准。