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

216 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 并删除平台段。