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 |
三、目标行为
范围取值(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):
备路径(仅当 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 复现) |
⚠️ 判定口径实测教训(重要,勿再踩)
--dump-config 不是 bundle 生效的判定口径:实测 dump 中 directory-picker 行仍是官方 -auto,但同一 dump 里连已确认生效的第三方 bundle 行(portal-entry / business-plugins)也完全不出现(grep 计数均为 0)。⇒ dump-config 不反映第三方 bundle 层 patch,不能用它判定覆盖是否生效;正确口径 = 实例启动日志 + 浏览器行为。
- 手放目录 ≠ 安装:
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)。
- Windows→ssh 传参不得含反斜杠:写 YAML 的实验脚本里
\n 被吞成 /n,把 patch 文件写坏。远程写文件一律本地写好再 scp,或用不含反斜杠的写法。
- 判断"实例是否自动重启"要留足时间:
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 下跑)
两个实测坑(已固化):
/opt/dshs 是 700 root → 实例 uid 读不到 → 插件必须复制进 profile,不能软链到仓库。
- 服务实例可能被框架 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) |
七、验证清单(装包后)
八、回滚
| 项 |
回滚 |
| 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)
三层防线与实测结果
| 层 |
手段 |
实测 |
| ① 越界拒绝 |
自建 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=…(实例启动日志可确定性验证是否生效) |
四个踩坑(务必记住)
- pnpm 同版本缓存:同名同版本 tgz 内容变了也复用旧包 → 必须升版本号(0.1.3→0.1.4)才生效;
- scp 报错被日志过滤吞掉 →
lib/client.js 漏传 → 传输后必须显式 ls 校验;
- 平台段追加前必须整文件去重(否则出现两段 → duplicate loader id → 实例崩溃循环,档案 25 同类);
- 判断"实例是否自动重启"要留足时间(kill → 自愈含退避 ≈6 s)。
回滚
<profile>/cordis.patch.yml.bak-*(含 v1/v2/v3 各版本备份);git revert be3d3f6;插件包可 pnpm remove @dsh-local/workspace-scoped-picker 并删除平台段。