Files
dsh_shenxian/dsh-server-docs/04-调整方案/18-目录选择器收敛为仅见自有目录.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

16 KiB
Raw Blame History

18 · 会话内目录选择器收敛为"仅见自有目录"(含 admin)

  • 日期:2026-09-10
  • 触发:档案 17 核查后的修复口径拍板(用户 21:27):"用户应该只能看到这个项目下自己对应的文件夹,包括 admin"
  • 结论一句话:以 bundle 方式挂载自建 host 插件(子类化官方 DirectoryPicker seam),把列举/建目录的根固定在用户自有目录 <userRoot>/ws,所有用户含 admin 一视同仁;不改官方包、不改用户 cordis.patch.yml。
  • 同主线说明(档案 19 §D7):本档案与 档案 17(工作区可见面核查) 属同一条安全主线——17 是"核查",18 是"方案 + 实施 + 验证"。为避免断链,两份文件保持独立编号不物理合并,通过本行互指达成"一份主线的效果"。
  • 状态:✅ 已实施并在 admin/guest 实测通过(2026-09-11 v3 定稿):官方对话框 UI 保留 + host 面受限 + CSS 隐藏「改路径」入口;插件 v0.1.4;平台段已全量写入

TL;DR|结论:目录选择器收敛 v3 定稿:官方对话框 UI 保留 + 自建 host 插件把根钉在 <userRoot>/ws(含 admin)+ CSS 隐藏「改路径」入口。 关键:关键机制:官方 directory-picker 行启动时连带挂载客户端对话框,disable 它会让对话框消失("点不动");故改为单独插回官方 client 面。 状态:✅ 已实施并实测通过(插件 v0.1.4;平台段全量铺开)


一、需求(用户裁定)

项 定稿
可见范围 会话内「添加工作区」目录选择器只能看到自己的目录,不能浏览 /、/etc、/usr、/proc 等系统路径,也不能看到其他租户
适用范围 所有用户,含 admin(admin 在会话内不例外)
不变项 门户后台(portal.html#/files 文件管理)保持现状(管理工具,已由 userFs + fs-guard 限定在自有 ws)

二、现状(档案 17 实测结论的精确定位)

事实 证据
官方组合把 picker 行挂在 id directory-picker dsh-web-app/cordis.patch.yml:83 → name: '@deepseek-ai/dsh-host-directory-picker-auto';注释写明"Mount -native or -browse directly in an overlay to pin the interaction"
当前后端为应用内浏览,全域范围 dsh-host-directory-picker-browse 源码注释自认 whole-filesystem scope;配置只有 maxEntries,无根限制
seam 的官方扩展方式 dsh-host-directory-picker:"Subclass, implement capability(), and load the subclass as a plugin — it registers as ctx.directoryPicker"
patch 行同 id = 修改现有配置项 知识库 02_Cordis教程/06_组合与HMR.md;disabled: true = 卸载不删项
第三方插件在 profile 里以 bundle 注册 实测 admin profile:dsh.profile.bundles = [dsh-base, dsh-web-app, @dsh-local/portal-entry, @dsh-local/business-plugins],dependencies 为 file:<ws>/*.tgz,包内自带 cordis.patch.yml

三、目标行为

list(path?)
  path 省略             → 列举「自有根」
  合法(自有根或其子目录)→ 单层目录列举(按名排序、hidden 标记、只看目录)
  越界(/etc、..、相对路径、他人目录、符号链接逃逸)→ DirectoryPickerError('directory-unreadable')
createDirectory(path, name)
  仅允许自有根内单层子目录(name = 单段,不含分隔符 / . / ..)
  path 越界 → 'directory-create-failed';已存在 → 'directory-exists'
crumbs
  顶层 = 「自有根」(不再暴露文件系统 /)

范围取值(Q1 已定:A):自有根 = <userRoot>/ws

  • 解析优先级:DSH_WORKSPACE_ROOT 环境变量 → process.cwd()
  • 编排器 spawn 已 --chdir <userRoot>/ws,故 cwd 天然正确;env 作双保险
  • 与门户文件管理、dsh 启动目录口径一致;顺带收窄档案 17 的 P1(写边界随 cwd)影响面
  • B(整个 <userRoot>,含 home/)已否决:会把自我提权路径留在范围内

四、实现方案

4.1 自建 host 插件(@dsh-local/workspace-scoped-picker)

项 设计
形态 host 插件(无 client bundle),NPM 包:package.json + cordis.patch.yml + lib/index.js + test/poc.mjs
核心 class WorkspaceScopedDirectoryPicker extends DirectoryPicker,capability() 返回稳定 { kind:'browse', list, createDirectory }
依赖策略 唯一外部物 = seam 基类,用绝对路径动态 import 取得(/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js,可用 DSH_SEAM_DIRECTORY_PICKER 覆盖);不复制、不修改官方包(红线 R2),解析到与核心同一模块实例(ESM 缓存按 realpath 去重)
安全校验 词法前缀归一 + realpath 抗符号链接逃逸;符号链接指向根外时不展示且不可进入
错误码 复用 seam 封闭错误码(directory-unreadable / directory-exists / directory-create-failed)

4.2 生效方式(bundle 路由为主,profile patch 为备)

📌 2026-09-10 方案改进(据档案 19 §C1/C2):原计划的"另建脚本往用户 cordis.patch.yml 注入"已废弃 —— 既有 ensure-role-profile-patch.cjs 是"整文件写 + 见 MARK 即跳过 + 仅非 admin",两个写入方必然冲突,且它不覆盖 admin,与"含 admin"要求直接矛盾。 改用 bundle 路由后,C1/C2 风险整体消失:不动用户 patch 文件,且天然对所有 profile(含 admin)一致生效。

主路径(bundle):

@dsh-local/workspace-scoped-picker 作为 bundle 追加进 profile:
  1) tgz 落 <userRoot>/ws/
  2) profile package.json:
       dsh.profile.bundles 追加 "@dsh-local/workspace-scoped-picker"
       dependencies["@dsh-local/workspace-scoped-picker"] = "file:<ws>/workspace-scoped-picker-0.1.0.tgz"
  3) 解包到 <profile>/node_modules/@dsh-local/workspace-scoped-picker
  4) 重启实例
包内 cordis.patch.yml 以「同 id 覆盖」替换官方组合行(本层追加在 dsh-web-app 之后,故后应用):
  - id: directory-picker
    name: '@dsh-local/workspace-scoped-picker'

备路径(仅当 P0-1 证明 bundle 层 override 不生效):改由 profile cordis.patch.yml 写同一行——但此时必须并入 ensure-role-profile-patch.cjs 单 owner(平台段含 admin、角色段仅非 admin),不得新建并发脚本。

4.3 改动清单

对象 改动 状态
/opt/dshs/poc/workspace-scoped-picker/ 插件源码(package.json / cordis.patch.yml / lib/index.js / test/poc.mjs) ✅ 已落盘(root-only,700 目录)
安装方式(实测结论) 必须走 pnpm:cd <profile> && HOME=<userRoot>/ws pnpm add file:<ws>/<pkg>.tgz → 更新 pnpm-lock.yaml + 在 node_modules/@dsh-local/ 建符号链接(指向 .pnpm/...)。手放目录无效(lockfile 不认,bundle 不被加载) ✅ 配方已实测
<profile>/package.json dsh.profile.bundles 追加(pnpm 加依赖后需 reconcile:保留 @deepseek-ai/* + 已在 dependencies 的项) ✅ admin 已装
<userRoot>/ws/*.tgz 包体落点(与 portal-entry / business-plugins 同惯例) ✅ admin 已放
<profile>/cordis.patch.yml 主路径:写入「同 id 覆盖」行(档案 09 已实证的 override 形态) ✅ admin 已写(含备份)
bundle 内 cordis.patch.yml 双保险:同一覆盖行也放在包内(若 bundle 层 override 生效则两者等效) ✅ 已含
实例重启 kill main → 自愈 scheduleRestart 自动拉起(实测 ≈6 s,含 waitForLaunchToken) ✅ admin 已重启
幂等安装脚本 install-workspace-picker.cjs 打包 + 逐 profile pnpm 安装 + reconcile + 写 platform 段 + 可选 --restart;非 admin 用户需并入 ensure-role-profile-patch.cjs 单 owner ⏳ 待写(等 admin UI 验证通过)
文档 本档案 + INDEX/README 登记;双端同步 🔄 进行中

五、Step 0 前置验证

# 验证项 结果
P0-2 自建插件能否解析官方 seam 基类 ✅ 通过(绝对路径动态 import 可用;/usr/local/lib/node_modules/@deepseek-ai/dsh 为 755 可读)
P0-4 越界拒绝是否可靠 ✅ 通过:/etc、/usr、相对路径、..、符号链接逃逸(列举不展示 + 不可进入 + 不可建目录)全部被拒
P0-5 装包后实例能否正常启动 ✅ 通过(admin 实例重启后正常启动,打印 launch token,无插件加载错误 → 至少不会搞坏实例)
P0-1 「同 id 覆盖」是否生效 ⏳ 待 UI 验证。⚠️ 见下方"判定口径"实测教训:--dump-config 不能用作口径
P0-3 客户端入口是否仍在 / 是否只剩自有目录 ⏳ 待 UI 验证(客户端经 cordis remote 取能力,无法手工 curl 复现)

⚠️ 判定口径实测教训(重要,勿再踩)

  1. --dump-config 不是 bundle 生效的判定口径:实测 dump 中 directory-picker 行仍是官方 -auto,但同一 dump 里连已确认生效的第三方 bundle 行(portal-entry / business-plugins)也完全不出现(grep 计数均为 0)。⇒ dump-config 不反映第三方 bundle 层 patch,不能用它判定覆盖是否生效;正确口径 = 实例启动日志 + 浏览器行为。
  2. 手放目录 ≠ 安装:pnpm-lock.yaml 是安装的权威账本。手工解包到 node_modules 而不经 pnpm,lockfile 不登记 → bundle 不被加载。必须 pnpm add file:<tgz>(并设 HOME=<userRoot>/ws 以命中同一 store:<ws>/.local/share/pnpm/store,否则报 ERR_PNPM_UNEXPECTED_STORE)。
  3. Windows→ssh 传参不得含反斜杠:写 YAML 的实验脚本里 \n 被吞成 /n,把 patch 文件写坏。远程写文件一律本地写好再 scp,或用不含反斜杠的写法。
  4. 判断"实例是否自动重启"要留足时间:kill 后自愈含退避 + waitForLaunchToken,实测 ≈6 s(轮询过早会误判为"没起来")。

PoC 自检记录(test/poc.mjs,2026-09-10)

  • 26 项断言全部通过(进程内、真实 userId 114801、临时根 /tmp/picker-scope-poc/home)
  • 覆盖:基类解析、能力形态、根内列举/建目录、隐藏标记、子目录 crumbs、重复建目录、非法段名、6 类越界拒绝、2 类符号链接逃逸
  • 复跑方式:DSH_WORKSPACE_ROOT=<dir> node test/poc.mjs(须以可读路径运行,不能直接从 700 的 /opt/dshs 下跑)

两个实测坑(已固化):

  1. /opt/dshs 是 700 root → 实例 uid 读不到 → 插件必须复制进 profile,不能软链到仓库。
  2. 服务实例可能被框架 Proxy 包装 → #私有字段 的 brand 检查会抛 Receiver must be an instance of class … → 实现改用普通方法(realRoot / assertInside / crumbs),已修并复测通过。

六、已定稿决策(原待确认项)

# 问题 定稿
Q1 自有根范围 A:<userRoot>/ws
Q2 是否同批做档案 17 的 H2(bwrap 写保护) 分两批:本档案先解决"可见性";H2 单独一批(彻底消除 P1 写边界)
Q3 门户 #/files(admin 平台文件管理)是否收窄 保持现状(管理工具,已被 userFs + fs-guard 限定在自有 ws)

七、验证清单(装包后)

  • --dump-config 中 directory-picker 行 = @dsh-local/workspace-scoped-picker
  • admin:会话内「添加工作区」→ 顶层 = 自有 ws,看不到 /;上一级不可越界;子目录可进
  • guest:同上;且看不到 admin 的目录
  • 「新建文件夹」在自有目录内可用;越界被拒
  • 实例启动正常;插件加载失败时只"隐藏选择入口"(不搞坏实例)
  • 回归:功能插件 section(档案 16)、技能管理(档案 11)、门户各页不受影响
  • admin 与 guest 行为一致(无角色例外)

八、回滚

项 回滚
bundle 路由 profile package.json 移除 bundles + dependencies 两行 → 删 <profile>/node_modules/@dsh-local/workspace-scoped-picker → 删 <ws>/*.tgz → 重启实例(回到官方 -auto picker)
profile patch(若走备路径) 由单 owner 脚本 --revert-platform 删除平台段
生效前提 任一方式均需重启实例

九、红线遵守

  • R1:不触发 dsh 升级。
  • R2:不改官方 dsh 主程序与缓存;仅子类化官方 seam 基类 + 走官方 bundle/profile patch 机制(官方注释原文即"在 overlay 里挂载 -browse/-native 以固定交互")。
  • R3:host-only 插件,不涉 client bundle,无 exports.default 红线风险。
  • R4:PoC 与验证使用临时目录/测试路径;装包前 --dry-run;不改用户 cordis.patch.yml。

十、实施定稿(2026-09-11 · v3,用户实测通过)

关键机制(三次试错后的结论)

尝试 结果
① bundle patch「同 id 换 name」覆盖官方行 ❌ 不生效
② profile patch「同 id 换 name」覆盖 ❌ 不生效(dump-config 实测未应用)
③ disable 官方行 + insert 自建 host 行 ⚠️ host 生效(有加载标记)但客户端对话框消失 → 用户「点不动、没反应」
④(定稿)官方 client 面单独插回 + 自建 host + disable 官方 auto 行 ✅ 实测通过

③ 的根因:官方 dsh-web-app 的 directory-picker 行(@deepseek-ai/dsh-host-directory-picker-auto)在启动时会连带挂载配套的客户端对话框 @deepseek-ai/dsh-client-ui-directory-picker-browse(该包自带 dsh.client 元数据、且是 dsh-web-app 的依赖)。disable 那一行 → 对话框随之消失,客户端连 RPC 都不发(日志里无 directoryPicker/* 请求)。

定稿配置(写入各用户 profile 的 cordis.patch.yml)

# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker-patch.cjs)
- insert:
    - id: workspace-scoped-picker            # host 面:自建受限实现(根=<userRoot>/ws)
      name: "@dsh-local/workspace-scoped-picker"
    - id: ui-directory-picker-browse         # client 面:官方对话框 UI 单独插回
      name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"
- id: directory-picker
  name: "@deepseek-ai/dsh-host-directory-picker-auto"
  disabled: true
# <<< platform: workspace-scoped-picker

三层防线与实测结果

层 手段 实测
① 越界拒绝 自建 host 的 list/createDirectory 校验(词法 + realpath 抗符号链接) ✅ 路径框手输 / 被拒(用户实测)
② 入口隐藏 插件 client 面注入 CSS:隐藏 crumbEditZone/crumbEditGlyph([class*=…] 抗 CSS-module 哈希 + MutationObserver 兜懒挂载),只导出 apply+inject(R3 禁 exports.default) ✅ 用户确认「改路径入口不可见」
③ 路径不暴露 面包屑根节点显示「我的工作区」(可 DSH_WORKSPACE_LABEL 覆盖) ✅

工具与铺开

工具 用途
poc/workspace-scoped-picker/ensure-workspace-picker-patch.cjs 以 BEGIN/END 标记幂等追加平台段(保留角色 patch),支持 --dry-run / --restart / 指定用户名
scripts/install-workspace-picker.sh <tgz> 逐用户安装插件包(按 profile 是否为 pnpm workspace 根决定是否加 -w)并校验 lib/
加载标记 [workspace-scoped-picker] loaded root=…(实例启动日志可确定性验证是否生效)

四个踩坑(务必记住)

  1. pnpm 同版本缓存:同名同版本 tgz 内容变了也复用旧包 → 必须升版本号(0.1.3→0.1.4)才生效;
  2. scp 报错被日志过滤吞掉 → lib/client.js 漏传 → 传输后必须显式 ls 校验;
  3. 平台段追加前必须整文件去重(否则出现两段 → duplicate loader id → 实例崩溃循环,档案 25 同类);
  4. 判断"实例是否自动重启"要留足时间(kill → 自愈含退避 ≈6 s)。

回滚

<profile>/cordis.patch.yml.bak-*(含 v1/v2/v3 各版本备份);git revert be3d3f6;插件包可 pnpm remove @dsh-local/workspace-scoped-picker 并删除平台段。