perf(sidebar): fs.tree 22.8ms→3.8ms + 删除工作区路径检测(放开权限)+ 树展开状态按会话
用户实测反馈:
1) `/sidebar/api/fs.tree` 性能极差 —— 先测后改(10k 条目目录、热缓存、中位数):
- 冷调用 cap=1000:22.76ms → **3.77ms**;TTL 内重复:≈23ms → **0.000ms**
- 改法:opendir for-await → readdir(withFileTypes) 一次取回;只为 cap 条建行
(slice + 定长数组);路径用字符串拼接而非 path.join;排序弃 localeCompare
(14.6ms/10k → 0.35ms)改小写比较 + 原名码点兜底;新增 1.5s 目录级缓存
(键 绝对路径\0cap,上限 512),由 fs.write/rename/remove/mkdir/upload 与
fs-watch 的变更通知显式失效
- 契约逐字不变;新增 tests/fs-tree.spec.ts 6 例 + fs-watch 失效用例(判别性验证)
2) 删除工作区路径检测(默认不检测、放开权限):
- path-security 变纯词法解析(realpath/包含性判定删除)、fs-operations 去 fence
分支、所有 fs 路由不再读 fence、403 分支消失、config 删 workspaceFence 字段
(schemastery 容忍未知键,旧 profile 不会加载失败)、settings 开关删除
- ⚠️ 安全影响:插件 fs 路由(tree/read/write/rename/remove/mkdir/媒体/HTML/
upload/archive)现在可读写宿主用户可访问的任意路径,只受 OS 权限约束;
指南 §8.1 已加醒目声明
- 死代码清理:FenceErrorNotice 组件 + EditorHost 分支 + 4 个词条(settingsFence*、
fenceErrorReason、fenceDisableAction)+ isOutsideWorkspace* 助手 + TreePanel 的
store prop + 相关 CSS 块
3) 批量列举路由 `fs.trees`(单层失败只在该层带 error)+ 客户端批量加载:
挂载/刷新时的 N+1 请求变为 1 个(8 个展开目录:9 → 1,探针断言)
refreshTick 不再先清缓存(旧行保留到新答案;失败保留旧行 + warn 提示)
4) 修复「关闭文件后返回文件树,展开状态被重置」:
原生承载面的展开集合原来存在每个 tab 的记录里(drop 即丢),改为**每会话状态**
的投影(ensure 从会话态读、toggleExpanded 写回会话态、registerNativeSurface 用
attachStore 订阅);同一会话多 tab 共享、不同会话隔离;revealed 保持 per-tab
验证:typecheck / lint / test(136 文件 / 1484 通过 / 9 跳过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
版本镜像同步(dsh.plugin.json + SIDEBAR_SERVICE_VERSION)
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
| 布局持久化 | 原生栏的布局**只在内存**(刷新后回到折叠默认),插件自己的底部工作台仍然持久化 |
|
||||
| 跨会话打开 | 目标会话的右侧栏 store 未挂载时,打开会排队到该会话上屏后重放 |
|
||||
| 内置类型接管 | 插件的 `editor` 类型以 `extension` 优先级认领 `dsh-resource://file/**`(压过内置 `ui-sidebar-documentpreview` 的 `text` 预览——即 `fallback` 带),并接管内置 `files` 页面 kind(`openTab('files')` 打开插件的文件树);插件卸载/禁用时内置实现自动复位。**但认领是有选择的**:宿主自己的文档预览已经覆盖的格式(表格 / PDF / 图片 / Office,清单见 §5.4)由 `editor.canOpen` 主动**拒绝**,地址交回内置 `text` 档 |
|
||||
| 文件树实时刷新(v0.21.1+) | 插件接管了内置 `files` 页,所以宿主自己的按目录 watch 覆盖不到这棵树——插件自带一条 `/sidebar/ws/fs-watch` socket:客户端上报**已展开**的目录集,宿主侧按目录 `fs.watch`(150ms 去抖、每连接上限 64 个句柄),变动后只让那一层缓存失效并重列;目录折叠即退订。路径仍走 `fs.tree` 同一道 workspace fence |
|
||||
| 文件树实时刷新(v0.21.1+) | 插件接管了内置 `files` 页,所以宿主自己的按目录 watch 覆盖不到这棵树——插件自带一条 `/sidebar/ws/fs-watch` socket:客户端上报**已展开**的目录集,宿主侧按目录 `fs.watch`(150ms 去抖、每连接上限 64 个句柄),变动后只让那一层缓存失效并重列;目录折叠即退订。路径仍走 `fs.tree` 同一套词法解析 |
|
||||
| 链接接管(v0.21.1+) | DOM 层只接管**有类型通过 `urlTarget` 声明认领**的外链(Ctrl/Cmd/Shift/Alt 点击一律放行);一个都没认领到时**不阻止默认行为**,交回宿主。见 §4.1 的 `urlTarget` |
|
||||
| path 种子的去向(v0.19.2+) | `path` seed 的含义**跟随类型**:只有 `editor`(唯一认领 `dsh-resource://file/**` 的类型)把 path 转成资源地址打开(文件落在编辑器);**其余类型保留页面型打开**,path 随导航 params 落到合成记录的 `tab.path` 供组件消费——组件型 tab 的 path seed 不会被改道到文件编辑器(v0.19.0/0.19.1 上一切 path seed 都被改道,组件从未挂载,#632) |
|
||||
| 终端(已交还宿主) | 插件**不再提供任何终端**:宿主 0.1.6 起自带 `ui-sidebar-terminal`(kind `terminal`),插件侧 PTY 栈与 `terminal_*` 工具整体删除。这里不再有「插件终端数量上限」这类语义 |
|
||||
@@ -586,17 +586,18 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me
|
||||
|---|---|
|
||||
| `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) |
|
||||
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型) |
|
||||
| `fs.trees` | **批量**目录列表(v0.23.0+):入参 `{ sessionId, cwd?, paths: string[] }`(最多 64 条,绝对或会话相对),一次请求返回 `{ levels: [{ path, entries, truncated, error? }] }`。每层与 `fs.tree` 同解析、同缓存;**某一层失败只在该层带 `error`**(`entries: []`),整个批次仍是成功响应——树的一次挂载/刷新因此是 1 个请求而不是 N 个 |
|
||||
| `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) |
|
||||
| `fs.write` | 原子写文件 |
|
||||
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
|
||||
| `pty.close` / `agent-pty.close` | **已删除**(插件自带的 PTY 栈随终端一起移除;宿主 `ui-sidebar-terminal` 不通过本插件的路由暴露控制面) |
|
||||
| `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫,冲突回 wire 错误码 `settings-conflict`);后端在 0.1.7 上就是宿主的 `SettingsForms`,见 §8.2 |
|
||||
|
||||
> **文件路径安全边界**:`fs.tree`、`fs.read`、`fs.write`、`fs.rename`、`fs.remove`、`fs.mkdir`、`/sidebar/file`、`/sidebar/html`、`/sidebar/upload` 和 `archive.build` 都以请求对应 session 的权威 `cwd` 作为 workspace 根目录。路径会按真实文件系统路径检查,越界绝对路径、`..` 解析结果和指向 workspace 外部的符号链接都会被拒绝;消费插件不应把 `cwd` 当作可由用户扩大权限范围的参数。
|
||||
> **文件路径语义(v0.23.0 起:无包含检查)**:`fs.tree`、`fs.trees`、`fs.read`、`fs.write`、`fs.rename`、`fs.remove`、`fs.mkdir`、`/sidebar/file`、`/sidebar/html`、`/sidebar/upload` 和 `archive.build` 仍以请求对应 session 的权威 `cwd` 作为**相对路径的基准**,但**不再把 `cwd` 当边界**:绝对路径原样使用,`..` 只做词法折叠,符号链接跟随,越界不再是 403。安全影响见 §8.1 的醒目声明。
|
||||
|
||||
### 6.1 打包下载(`archive.build` / `archive.status` / `/sidebar/archive`,v0.22.0+)
|
||||
|
||||
把若干文件/目录打成 ZIP 下载,并且**有进度可看**:选择项在 `archive.build` 里一次性收集(同一个 workspace fence,目录递归、符号链接跳过、同名条目用父目录消歧),打包在后台进行,客户端轮询进度、完成后取字节。
|
||||
把若干文件/目录打成 ZIP 下载,并且**有进度可看**:选择项在 `archive.build` 里一次性收集(与 `fs.tree` 同一套词法解析——**无包含检查**,目录递归、符号链接跳过、同名条目用父目录消歧),打包在后台进行,客户端轮询进度、完成后取字节。
|
||||
|
||||
```ts
|
||||
// 1) 启动:paths 是会话命名空间里的绝对路径(与 fs.tree 的行 path 同形)
|
||||
@@ -871,7 +872,7 @@ ctx.effect(() =>
|
||||
- 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。
|
||||
- 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。
|
||||
- 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。
|
||||
- `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片底部「功能设置」条在原生弹窗中编辑。行控件形状见 §4.1 的 `SettingRow`:`type: 'switch' | 'text' | 'number'`(v0.11.0+;text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)与 `type: 'select'`(v0.13.0+;`options` 支持 value/title/desc/icon,`multi` 多选存数组并按 options 顺序提交;任一项带 icon 时渲染大图标选项卡)。内置示例:subagent tab 的 `autoOpenSubagent`、editor tab 的 `editorExplorer` 图标化下拉与 `workspaceFence` 开关(工作区路径围栏,见 §8.1)。
|
||||
- `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片底部「功能设置」条在原生弹窗中编辑。行控件形状见 §4.1 的 `SettingRow`:`type: 'switch' | 'text' | 'number'`(v0.11.0+;text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)与 `type: 'select'`(v0.13.0+;`options` 支持 value/title/desc/icon,`multi` 多选存数组并按 options 顺序提交;任一项带 icon 时渲染大图标选项卡)。内置示例:subagent tab 的 `autoOpenSubagent`、editor tab 的 `editorExplorer` 图标化下拉(工作区路径围栏与它的开关已移除,见 §8.1)。
|
||||
- `settings.pluginToggles`(可选,v0.12.0+):**插件自有设置行**,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有设置条)。
|
||||
- `settings.render`(可选,v0.12.0+):**自定义设置面板**——追加渲染在行列表之后,可单独存在。props 含 store/service/prefs、本 descriptor 的 `pluginSettings` blob、`updatePluginSetting(key, value)` 与 `close()`;抛错会被吞掉并显示内联错误。
|
||||
|
||||
@@ -911,7 +912,7 @@ ctx.effect(() =>
|
||||
)
|
||||
```
|
||||
|
||||
> ⚠️ **`toggles` 的 key 必须是本插件 `PrefsSchema` 的字段**(`PrefsSchema` 已并入本插件 Loader 行的 `Config`;内置键:`autoOpenSubagent` / `autoOpenJobs` / `tasksViewMode` / `mobileNoAutoOpen` / `mobileDefaultTree` / `agentOpenTools` / `editorExplorer` / `workspaceFence` / `titleBarScheme` / `titleBarPresetId` / `customCss` / `titleBarCompat` / `titleBarStripPx` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `tabsEnabled` / `viewersEnabled` / `pluginSettings`;**已删除**:`agentTerminalTools` / `terminalShell` / `terminalShellArgs` / `terminalFontFamily` / `terminalFontSize` / `bottomPanelAutoTerminal` / `browserNoSandbox` / `browserAllowedLoopback` / 三个按协议分流的旧外链接管键)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要本插件的 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
|
||||
> ⚠️ **`toggles` 的 key 必须是本插件 `PrefsSchema` 的字段**(`PrefsSchema` 已并入本插件 Loader 行的 `Config`;内置键:`autoOpenSubagent` / `autoOpenJobs` / `tasksViewMode` / `mobileNoAutoOpen` / `mobileDefaultTree` / `agentOpenTools` / `editorExplorer` / `titleBarScheme` / `titleBarPresetId` / `customCss` / `titleBarCompat` / `titleBarStripPx` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `tabsEnabled` / `viewersEnabled` / `pluginSettings`;**已删除**:`workspaceFence`(v0.23.0,围栏整体移除,见 §8.1)/ `agentTerminalTools` / `terminalShell` / `terminalShellArgs` / `terminalFontFamily` / `terminalFontSize` / `bottomPanelAutoTerminal` / `browserNoSandbox` / `browserAllowedLoopback` / 三个按协议分流的旧外链接管键)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要本插件的 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
|
||||
|
||||
### 8.2 宿主设置表单(DSH 0.1.7+:`SettingsForms`)
|
||||
|
||||
@@ -955,12 +956,17 @@ interface SettingsDescriptor {
|
||||
4. **`settings/document-updated` 你监听不到,别写**:事件签名是 `'settings/document-updated'(ns, revision)`(语义是「该条目的表单值 / 可用性 / 页面策略变了,表单客户端重新读取 schema、解析值与 revision」),但它在 **settings 服务自己的 context 上 `emit`**,而 cordis 事件只向该 ctx 的**祖先**冒泡——插件 fiber 是兄弟,`ctx.on(...)` 永远不会触发。**替代做法:每次重新读取表单时重新求值**(本插件的 `settingsFace.get()` 在返回当前值前跑一遍门控同步;客户端侧另经 `remote` 服务的 `$on('settings/document-updated')` 镜像触发重新拉取)。**旧文档里的 `settings/updated` 事件在 0.1.7 已不存在**。
|
||||
|
||||
|
||||
### 8.1 内置键 `workspaceFence`(工作区路径围栏)
|
||||
### 8.1 工作区包含检查已移除(v0.23.0,权限放开)
|
||||
|
||||
所有侧栏文件系统路由(`fs.tree` / `fs.read` / `fs.write` / `/sidebar/file` 媒体 / `/sidebar/html` 预览 / `/sidebar/upload`)默认强制**工作区包含检查**:客户端提供的路径经 realpath 解析后必须落在会话工作区内,否则 403(wire 错误码 `forbidden`,消息 `path "..." is outside workspace`)。`workspaceFence: false`(默认 `true`)解除该包含检查——路径仍会解析符号链接得到 canonical 路径,但不再拒绝工作区外的目标(如全局 `~/.dsh/AGENTS.md`、会话 cwd 之外的 linked worktree)。
|
||||
**`workspaceFence` 这个键已经不存在了,也没有替代开关。** 侧栏的文件系统路由(`fs.tree` / `fs.trees` / `fs.read` / `fs.write` / `fs.rename` / `fs.remove` / `fs.mkdir` / `/sidebar/file` 媒体 / `/sidebar/html` 预览 / `/sidebar/upload` / `archive.build`)**不再做任何工作区包含检查**:路径只做词法解析(会话相对路径拼到会话 cwd 下、`resolve()` 折叠 `..`),不再 realpath、不再比对前缀、**不再有 403 `forbidden` 分支**。符号链接被照常跟随。
|
||||
|
||||
- 开关位置:设置页「文件」卡片 →「功能设置」二级弹窗;编辑器加载失败与文件树列目录失败两个错误面在触发围栏拒绝时会显示原因 + 一键全局关闭按钮(`FenceErrorNotice`,写 `workspaceFence: false` 后自动重试失败的操作)。
|
||||
- ⚠️ **安全代价**:关闭期间,页面内任意同源脚本(**包括第三方消费插件**)都能通过上述路由读写主机上任意文件——开关文案已明示该风险,用完建议重新打开。
|
||||
> ## ⚠️ 安全声明(必读)
|
||||
>
|
||||
> 这是一次**权限放开**:插件侧边栏的 fs 路由现在能读写**宿主用户能访问的任意路径**,只受 OS 权限约束。也就是说,一个能访问到本插件 `/sidebar/*` 路由的同源页面/脚本(**包括你写的第三方消费插件**)可以借这些路由读取、改写、删除该用户权限内的任何文件——例如 `~/.ssh/`、`~/.dsh/`、其他项目的源码。
|
||||
>
|
||||
> 缓解手段只有两块,别指望有第三块:① 每条路由都过同一个**浏览器信任栅栏**(Host 头 loopback / `trustedHosts`,见 §0 与 §10),所以前提是攻击面已经能发同源请求;② 宿主用户的 OS 权限本身的边界。**不要**再把「工作区围栏」当成安全边界来设计你的插件——它已经不在了。
|
||||
|
||||
历史背景(`< v0.23.0`):旧的实现用 realpath 解析后比对工作区前缀,越界回 403,并有一个 `workspaceFence` 开关(默认开)可临时关闭;开启时编辑器/文件树错误面会显示原因 + 一键关闭按钮(`FenceErrorNotice`)。用户要求删除该检查,上述代码路径随之删除(`src/path-security.ts` 现在是纯词法解析)。旧 profile 里遗留的 `workspaceFence` 键只是**未知键**——schemastery 的 object schema 对未知键是容忍的,既不报错也不再有任何效果。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user