feat: workspace fence toggle (workspaceFence pref + one-click off in error surfaces)

- New persisted built-in pref workspaceFence (default true) under the
  files card's secondary settings modal; host reads it per request via
  the settings seam (fenceEnabledOf — absent/malformed stays ON).
- ensureWorkspacePath/ensureWorkspaceWritePath gain a fence param:
  disarmed skips the containment assert but keeps realpath resolution;
  wired through fs.tree/fs.read/fs.write, the media + html routes, and
  the upload path (writeWorkspaceUpload fence input).
- Editor load failures and file-tree level errors show the localized
  reason plus a one-click global off (FenceErrorNotice) that flips the
  pref, adopts it into the store, and auto-retries the failed operation
  (reloadSeq / retryDir) — the raw wire text is never shown.
- GitView's two client-side pre-checks (hidden open action + no-op
  defense) respect the pref; store threaded through TreePanel/FileTree.
- i18n: 4 new keys across all 21 dictionaries; tests: fence-off route
  coverage, prefs parsing, schema default, editor toggles list;
  docs: guide §8/§8.1 + plans/2026-08-29 design record.
This commit is contained in:
Menghuan1918
2026-08-29 15:49:19 +08:00
parent 2088ef6891
commit a9353ce627
44 changed files with 391 additions and 38 deletions
+9 -2
View File
@@ -651,7 +651,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`、terminal tab 的 `agentTerminalTools` + 自定义字体行、editor tab 的 `editorExplorer` 图标化下拉。
- `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`、terminal tab 的 `agentTerminalTools` + 自定义字体行、editor tab 的 `editorExplorer` 图标化下拉与 `workspaceFence` 开关(工作区路径围栏,见 §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()`;抛错会被吞掉并显示内联错误。
@@ -691,7 +691,14 @@ ctx.effect(() =>
)
```
> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `agentOpenTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `agentOpenTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `workspaceFence` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
### 8.1 内置键 `workspaceFence`(工作区路径围栏)
所有侧栏文件系统路由(`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)。
- 开关位置:设置页「文件」卡片 →「功能设置」二级弹窗;编辑器加载失败与文件树列目录失败两个错误面在触发围栏拒绝时会显示原因 + 一键全局关闭按钮(`FenceErrorNotice`,写 `workspaceFence: false` 后自动重试失败的操作)。
- ⚠️ **安全代价**:关闭期间,页面内任意同源脚本(**包括第三方消费插件**)都能通过上述路由读写主机上任意文件——开关文案已明示该风险,用完建议重新打开。
---
@@ -0,0 +1,55 @@
# 设计:工作区路径围栏开关(`workspaceFence` 设置 + 错误面一键关闭)
> 日期:2026-08-29 · 状态:已实现 · 仓库:omdsh-dev/DSH-better-sidebar
## 需求
侧栏所有文件系统路由(`fs.tree` / `fs.read` / `fs.write` / `/sidebar/file` 媒体 / `/sidebar/html` 预览 / `/sidebar/upload`)默认强制工作区包含检查:客户端路径经 realpath 解析后必须落在会话工作区内,否则 403 `path "..." is outside workspace`(`src/path-security.ts`,由 `tests/smoke.spec.ts` 的穿越/符号链接用例守护)。
两个真实痛点:
1. 打开工作区外的合法文件被拒——全局 `~/.dsh/AGENTS.md`、会话 cwd 之外的 linked worktree(GitView 为此隐藏了「在编辑器中打开」菜单项)。
2. 拒绝时直接暴露原始 wire 文本 `path "/tmp/xxx.sh" is outside workspace`,用户不知道原因也不知道怎么办。
方案(用户拍板):
1. 新增**持久化**内置布尔设置 `workspaceFence`(默认 `true`),挂在设置页「文件」卡片的「功能设置」二级弹窗(`settings.toggles`)。
2. 编辑器加载失败与文件树列目录失败两处错误面:命中围栏拒绝时不再显示原始文本,改为**本地化原因 + 「关闭工作区检测」按钮**——点击全局关闭并**自动重试**失败的操作。
## 关键决策
### 1. 全局开关而非 allowlist(取舍记录)
评估过「受控额外根 allowlist(如 `~/.dsh`)」方案:边界更细,但需要路径管理模式、UI 管理界面与 per-root 读写语义,超出本次需求。用户明确选择单一全局开关贯穿**全部**文件路由(树/读/写/媒体/HTML/上传一体放行),KISS 优先。
**安全代价已明示**:关闭期间,页面内任意同源脚本(包括第三方消费插件)都能通过 `/sidebar` 路由读写主机任意文件。默认 `true`、设置描述文案与接入指南 §8.1 均写明该风险。
### 2. host 侧读取:每次调用经 settings seam 同步读
`fenceEnabledOf(getSettings)`(仿 `shellOverridesOf`):`record.workspaceFence !== false`——**缺省即武装**(无 settings 服务 / 字段缺失 / 值非法一律 `true`,安全默认不依赖设置面可达性)。`ensureWorkspacePath` / `ensureWorkspaceWritePath` 增加可选 `fence = true` 参数:`false` 时跳过包含断言,**realpath 解析保留**(调用方拿到的 canonical 路径语义不变,符号链接仍被解析)。上传 `writeWorkspaceUpload` 以可选 `fence` 入参同语义透传。
### 3. 错误识别:wire 不变,客户端按消息片段匹配
不新增 wire 错误码:`/sidebar/api` 信封内 `code: 'forbidden'` 有两个来源——请求信任 fence(消息恒为 `forbidden`)与路径围栏(消息含 `outside workspace`)。客户端 `isOutsideWorkspaceError(e)` / `isOutsideWorkspaceMessage(msg)`(`src/client/api.ts`)以消息片段区分,既有 403 断言零改动。
### 4. 关闭按钮:写设置 → 更新 store → 自动重试
`FenceErrorNotice`(新组件):按钮点击 → `api.settingsUpdate({ workspaceFence: false })`(revision-free,同 `plugin-settings.ts` 队列的 last-write-wins 语义)→ `store.setPrefs(parsePrefs(view.value))`(GitView 的 open 预判、设置页等所有 prefs 读者随之翻转)→ `onDisabled()` 回调:
- EditorHost:`setReloadSeq(seq + 1)` 复用既有重载机制;
- FileTree:`retryDir(dir)` 删该层缓存后重载。
失败路径:保持按钮可用并 console 记录(无乐观翻转)。
### 5. 客户端预判同步放行
GitView 两处 `isWithinWorkspace` 预判(菜单项隐藏 :578、纵深防御 no-op :601)改为仅在 `workspaceFence !== false` 时限制,组件新增 `store` prop。`fs.search` 不动——它锚定 cwd 向下走,本来就不会越界。
## 实施清单
- 字段三处声明 + 解析:`prefs-shared.ts`(含默认 `true`)/ `config.ts` PrefsSchema / `client/prefs.ts` parsePrefs。
- host:`path-security.ts` fence 参数;`index.ts` `fenceEnabledOf` + 六个调用点(fs.tree/fs.read/fs.write 走 buildApi 的 `getSettings` 闭包,媒体/HTML/上传走模块级 `settingsFace`);`fs-operations.ts` 上传入参。
- client:`api.ts` 识别 helper;`FenceErrorNotice.tsx`;EditorHost 错误分支;FileTree `retryDir` + 错误分支(store 经 EditorHost → TreePanel → FileTree 穿透);GitView 预判放行;tabs.tsx editor 卡片 toggles 追加开关行。
- i18n:4 个 key(`settingsFenceTitle` / `settingsFenceDesc` / `fenceErrorReason` / `fenceDisableAction`)× 全部 21 个词典(zh/en/ja + 18 第三语言,`locales.spec` 强制 key 集相等)。
- 测试:smoke「disarms the workspace fence…」(先拒后放,覆盖 tree/read/write);prefs 解析专测(缺省/非法 → `true`,显式 `false` 透传);plugin-shape schema 默认;builtins editor toggles 清单。
- 文档:接入指南 §8 内置键清单 + 新增 §8.1(本特性对消费插件的可感知行为与风险)。