From 302c20109f7cd837a589092364a58d49fbbd02cc Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 11 Aug 2026 19:28:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=86=85=E7=BD=AE=20HTML=20=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=99=A8=20+=20=E6=B2=99=E7=AE=B1=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=20tab=EF=BC=88v0.5.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - html viewer:.html/.htm 沙箱 iframe 预览(相对资源经 /sidebar/html 路径编码路由加载,CSP sandbox + 信任围栏 + cwd 边界),预览/编辑切换 - browser tab:+ 菜单多开(browser:),地址栏后退/前进/刷新/在浏览器中打开(15px 图标),URL 与标题按会话持久化(patchTab + nextBrowser) - 安全:iframe 一律不透明源沙箱(无 allow-same-origin/top-navigation、no-referrer、权限策略全禁、禁 srcdoc);地址栏仅 http(s)、拒绝 localhost 等本机地址;界面实时沙箱状态(关闭红色警示)+ 临时解锁/恢复 - 设置:htmlViewerNoSandbox / htmlViewerDefaultUnsafe / browserNoSandbox / browserInterceptLinks 四个声明式二级设置(默认安全) - 外链拦截:聊天/界面 http(s) 外链默认在侧边栏打开(openTab seed.url),Ctrl/Cmd 点击放行 - 嵌入拒绝面板:browser.probe 路由探测 X-Frame-Options/frame-ancestors,被拒站点显示原因 + 在浏览器中打开/仍然加载 - 设置页可滚动;内置 7 tab + 9 viewer;新增设计文档;268 个测试全绿 --- AGENTS.md | 15 +- ...2026-08-11-html-browser-builtins-design.md | 136 ++++++++++ dsh.plugin.json | 4 +- package.json | 4 +- src/browser-probe.ts | 26 ++ src/client/BrowserView.tsx | 253 ++++++++++++++++++ src/client/SandboxStatusBar.tsx | 60 +++++ src/client/SideCardSection.module.css | 6 + src/client/TextEditor.tsx | 49 +++- src/client/api.ts | 13 + src/client/browser.ts | 119 ++++++++ src/client/builtins/tabs.tsx | 45 +++- src/client/builtins/viewers.tsx | 33 ++- src/client/icons.tsx | 18 ++ src/client/index.tsx | 25 ++ src/client/link-intercept.ts | 66 +++++ src/client/locales.ts | 54 ++++ src/client/prefs.ts | 12 + src/client/service.ts | 47 ++-- src/client/sidebar.module.css | 180 +++++++++++++ src/client/state.ts | 40 +++ src/config.ts | 4 + src/html-route.ts | 70 +++++ src/index.ts | 119 +++++++- src/prefs-shared.ts | 36 +++ tests/browser-globals.ts | 1 + tests/browser-probe.spec.ts | 50 ++++ tests/browser.spec.ts | 79 ++++++ tests/builtins.spec.ts | 60 ++++- tests/html-route.spec.ts | 110 ++++++++ tests/link-intercept.spec.ts | 57 ++++ tests/plugin-shape.spec.ts | 2 +- tests/sandbox-views.spec.tsx | 187 +++++++++++++ tests/service.spec.ts | 22 ++ tests/smoke.spec.ts | 72 ++++- tests/unit.spec.ts | 68 ++++- 36 files changed, 2084 insertions(+), 58 deletions(-) create mode 100644 docs/plans/2026-08-11-html-browser-builtins-design.md create mode 100644 src/browser-probe.ts create mode 100644 src/client/BrowserView.tsx create mode 100644 src/client/SandboxStatusBar.tsx create mode 100644 src/client/browser.ts create mode 100644 src/client/link-intercept.ts create mode 100644 src/html-route.ts create mode 100644 tests/browser-probe.spec.ts create mode 100644 tests/browser.spec.ts create mode 100644 tests/html-route.spec.ts create mode 100644 tests/link-intercept.spec.ts create mode 100644 tests/sandbox-views.spec.tsx diff --git a/AGENTS.md b/AGENTS.md index 00bc739..b909a41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ interface TabDescriptor { */ settings?: { toggles?: readonly { - /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools') */ + /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */ key: string title: string | (() => string) desc?: string | (() => string) @@ -210,6 +210,7 @@ ctx.effect(() => | `git` | 20 | 是 | 否 | Git 面板 | | `subagent` | 30 | 是 | 否 | 子代理拓扑 | | `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) | +| `browser` | 50 | 否(createTab 铸造 browser:``,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) | | `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) | 你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。 @@ -283,7 +284,7 @@ interface FileViewerProps { > **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。 -> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。 +> **内置 viewer**(不可重复注册,全部 9 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。 > code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。 ### 4.5 注册示例 @@ -360,9 +361,11 @@ interface BetterSidebarService { * 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。 * title 可选:给出时优先于 descriptor.title(editor 显示文件名); * 有 createTab 的 descriptor(terminal)会忽略 title/path/id。 + * url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子, + * 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。 * 被设置禁用的类型是 no-op(console.warn 提示)。 */ - openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void + openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void /** 关闭一个 tab */ closeTab(tabId: string): void /** 订阅注册表变化(register/dispose 时触发) */ @@ -370,7 +373,7 @@ interface BetterSidebarService { } ``` -> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`),外部插件的自定义键会被 settings seam 丢弃。 +> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。 --- @@ -476,11 +479,11 @@ function parseCsv(text: string): string[][] { /* ... */ } better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"): -- **`src/client/builtins/`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 8 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts) +- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts) - **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现 - **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化) - **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试 -- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据) +- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据) - **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录) 调试时直接读这些文件即可看到所有 API 的真实用法。 diff --git a/docs/plans/2026-08-11-html-browser-builtins-design.md b/docs/plans/2026-08-11-html-browser-builtins-design.md new file mode 100644 index 0000000..3626c33 --- /dev/null +++ b/docs/plans/2026-08-11-html-browser-builtins-design.md @@ -0,0 +1,136 @@ +# 内置 HTML 预览器 + 内嵌网页浏览器(沙箱化)设计 + +**日期**:2026-08-11 +**状态**:已批准,已实施 +**作者**:opencode + 用户 +**当前版本**:v0.4.3(main `35cde82`) +**目标版本**:v0.5.0 + +## 1. 目标 + +在 `dsh-better-sidebar` 内按既有 builtins 模式新增两个内置能力: + +1. **`html` 文件预览器**:`.html`/`.htm` 文件在侧边栏编辑器中默认以真实渲染预览(相对资源可加载),可切换代码编辑(CodeMirror HTML 高亮 + 保存)。 +2. **`browser` 浏览器 tab**:`+` 菜单新增「浏览器」页面,地址栏输入 URL 在沙箱 iframe 中浏览,支持后退/前进/刷新,URL 与标题按 tab 持久化。 + +安全问题为第一优先级(用户显式要求"注意安全问题"):所有渲染走**不透明源沙箱 iframe**,host 侧新增 `/sidebar/html` 路由带 CSP + 信任围栏 + cwd 边界。另按用户追加要求提供**关闭沙箱的子设置**(默认关闭该设置;开启需明确勾选,设置项与界面都带安全警告文案)。 + +## 2. 复用门(dsh-reuse-first) + +`NO EXISTING CAPABILITY`:已搜索 DSH checkout(docs/*-catalog、apps/web、packages 源码——sidecar/iframe/webview/html preview/CSP 均无等价物;`WorkspaceBrowser` 只是目录树)与 dsh-external org hub 目录(browser/html/webview/iframe/sidecar/viewer/preview 均 0 命中)。全新实现,落点为本仓库 builtins(用户已确认)。 + +## 3. 威胁模型与安全基线 + +| 威胁 | 对策 | +|---|---| +| 恶意 HTML/网页读取 GUI 数据(localStorage、`/sidebar/api`、同源 cookie) | iframe 一律 `sandbox` 且**不含 `allow-same-origin`** → 文档源为不透明源(null)。**禁止 srcdoc**(srcdoc 在无沙箱时继承父源),一律 route-src | +| 预览/浏览内容劫持 GUI 页面(top 导航) | 不含 `allow-top-navigation` / `allow-top-navigation-by-user-activation` | +| 表单 CSRF 打到本地服务 | html 预览器 iframe **不含 `allow-forms`**;浏览器 tab 含 `allow-forms`(登录流必需,残余风险见 §9) | +| 浏览器 tab 访问内部地址(GUI 自身、localhost、127.0.0.0/8) | 地址栏导航前客户端阻断(`browser.ts` 纯函数);`javascript:`/`data:`/`file:` 等非 http(s) 一律拒绝 | +| 预览页脚本请求 `/sidebar/api` | 三层:不透明源 fetch 被 CORS 拒读;信任围栏拒 `sec-fetch-site: cross-site`;`Origin: null` 无法伪造同源 | +| 敏感信息泄露给被浏览站点 | iframe `referrerPolicy="no-referrer"`;权限策略 `allow=""`(拒绝 camera/mic/geolocation 等一切策略特性) | +| 直接顶层打开 `/sidebar/html/*` URL(弹窗等) | 路由响应带 `Content-Security-Policy: sandbox ...; object-src 'none'`,顶层同样被沙箱化 | +| 路径逃逸 | 路由复用 `requireAbsolute` + `isWithin(cwd, path)` + `mediaLimit` 上限 + 信任围栏 | +| 关闭沙箱(用户主动选择) | 每功能独立子设置(`htmlViewerNoSandbox` / `browserNoSandbox`,默认 false);设置文案 + 渲染时持久警告条双警告 | + +## 4. 变更清单 + +### 4.1 host:`/sidebar/html` 路由(src/html-route.ts 新 + src/index.ts) + +URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相对引用会丢弃 base 的 query,query 式 URL 下 `./style.css` 会丢失 session 作用域: + +``` +/sidebar/html//<绝对路径逐段 encodeURIComponent> +例:/sidebar/html/S/Users/me/proj/index.html + ./style.css → /sidebar/html/S/Users/me/proj/style.css ✓ + Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html +``` + +- `src/html-route.ts`(无依赖纯模块,client 可导入不触发纯度门):`encodeHtmlUrl(sessionId, path)`、`decodeHtmlUrl(pathname)`(404 错前缀;400 空路径/双斜杠/畸形编码/缺 sessionId 或文件路径/空段)。 +- `src/index.ts`:注册 `/sidebar/html` prefix 路由(fence → GET 校验 → decode → `requireAbsolute` → `isWithin(cwd)` → stat isFile + `mediaLimit` → 读文件),响应头: + `content-type`(新增 `.html`/`.htm` → `text/html` 到 MEDIA_TYPES)、`cache-control: no-cache`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: no-referrer`、`Content-Security-Policy: sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'`。 +- cwd 语义与媒体路由一致:session store 权威;会话附着前的首请求回退 `process.cwd()`,通常被 `isWithin` 拒绝(可接受)。 + +### 4.2 client:`html` 预览器(TextEditor.tsx 扩展 + builtins/viewers.tsx) + +- descriptor:`{ id:'html', exts:['html','htm'], priority:0, fetchStrategy:'fsRead', settings.toggles:[htmlViewerNoSandbox] }`。 +- TextEditor:`markdown || html` 都显示预览/编辑切换;html 预览分支渲染: + `