# 18 · 会话内目录选择器收敛为"仅见自有目录"(含 admin) - 日期:2026-09-10 - 触发:档案 17 核查后的**修复口径拍板**(用户 21:27):*"用户应该只能看到这个项目下自己对应的文件夹,包括 admin"* - 结论一句话:**以 bundle 方式挂载自建 host 插件**(子类化官方 `DirectoryPicker` seam),把列举/建目录的根固定在**用户自有目录 `/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 插件把根钉在 `/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:/*.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)**:自有根 = **`/ws`** - 解析优先级:`DSH_WORKSPACE_ROOT` 环境变量 → `process.cwd()` - 编排器 spawn 已 `--chdir /ws`,故 cwd 天然正确;env 作双保险 - 与门户文件管理、dsh 启动目录口径一致;**顺带收窄档案 17 的 P1(写边界随 cwd)影响面** - B(整个 ``,含 `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 落 /ws/ 2) profile package.json: dsh.profile.bundles 追加 "@dsh-local/workspace-scoped-picker" dependencies["@dsh-local/workspace-scoped-picker"] = "file:/workspace-scoped-picker-0.1.0.tgz" 3) 解包到 /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 && HOME=/ws pnpm add file:/.tgz` → 更新 `pnpm-lock.yaml` + 在 `node_modules/@dsh-local/` 建符号链接(指向 `.pnpm/...`)。**手放目录无效**(lockfile 不认,bundle 不被加载) | ✅ 配方已实测 | | `/package.json` | `dsh.profile.bundles` 追加(pnpm 加依赖后需 reconcile:保留 `@deepseek-ai/*` + 已在 dependencies 的项) | ✅ admin 已装 | | `/ws/*.tgz` | 包体落点(与 portal-entry / business-plugins 同惯例) | ✅ admin 已放 | | `/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:`(并设 `HOME=/ws` 以命中同一 store:`/.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= 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:`/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 两行 → 删 `/node_modules/@dsh-local/workspace-scoped-picker` → 删 `/*.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`) ```yaml # >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker-patch.cjs) - insert: - id: workspace-scoped-picker # host 面:自建受限实现(根=/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 ` | 逐用户安装插件包(**按 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)。 ### 回滚 `/cordis.patch.yml.bak-*`(含 v1/v2/v3 各版本备份);`git revert be3d3f6`;插件包可 `pnpm remove @dsh-local/workspace-scoped-picker` 并删除平台段。