Files
dsh_shenxian/dsh-server-docs/04-调整方案/18-目录选择器收敛为仅见自有目录.md
T

215 lines
16 KiB
Markdown
Raw Normal View 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`)
```yaml
# >>> 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` 并删除平台段。