Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
## ✨ 功能一览

- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容默认运行在沙箱 iframe,状态栏可临时解锁或持久切换全局沙箱;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原;工作区容器下自动发现子仓库并显示**仓库选择器**,支持 linked worktree 变更发现
Expand Down Expand Up @@ -475,7 +475,7 @@ pnpm watch # tsdown --watch

- 路由受 Host 头信任围栏保护(与 `/api` 一致);`fs.write` 原子写入;媒体/预览路由仅限会话 cwd 内文件;git 只调 CLI、绝不设置身份
- HTML 预览与浏览器 tab 的内容在**不透明源沙箱 iframe** 中渲染(无 `allow-same-origin`/`allow-top-navigation`、`no-referrer`、权限策略全禁);`/sidebar/html` 路由带 CSP `sandbox` + 大小/路径边界;地址栏拒绝 `javascript:`/`data:`/`file:` 与 localhost 等本机地址
- 界面实时显示沙箱状态(关闭时红色警示)可临时解锁当前页面;设置页可按功能关闭沙箱(默认关闭该设置,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用
- 界面实时显示沙箱状态(关闭时红色警示)可临时解锁当前页面;浏览器状态栏另提供持久化全局开关,对所有浏览器 tab、后续会话和页面重载生效;设置页也可按功能切换(默认保持沙箱开启,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用

## ⚠️ 已知限制

Expand Down
2 changes: 1 addition & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ pnpm watch # tsdown --watch

- Routes protected by a Host-header trust fence (same as `/api`); `fs.write` is atomic; media/preview routes only serve files inside the session cwd; git only shells out to the CLI and never sets identity
- HTML preview and browser tab content render in **opaque-origin sandboxed iframes** (no `allow-same-origin`/`allow-top-navigation`, `no-referrer`, all permission policies disabled); the `/sidebar/html` route carries a CSP `sandbox` + size/path bounds; the address bar rejects `javascript:`/`data:`/`file:` and local addresses like localhost
- The UI shows the sandbox status live (red warning when off) and can temporarily unlock the current page; the settings page can disable the sandbox per feature (disabled by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content
- The UI shows the sandbox status live (red warning when off) and can temporarily unlock the current page; the browser status row also has a persistent global switch that applies to every browser tab, future sessions, and page reloads; the settings page retains the same per-feature control (sandbox stays on by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content

## ⚠️ Known Limitations

Expand Down
45 changes: 42 additions & 3 deletions src/client/BrowserView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* address-bar navigations (in-frame link clicks are cross-origin and
* invisible — a documented limitation).
*/
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import {
IconChevronLeftOutline14,
IconChevronRightOutline14,
Expand All @@ -27,6 +27,7 @@ import {
import { VscLinkExternal } from 'react-icons/vsc'
import { api } from './api.ts'
import { embeddabilityOf, isAllowedLoopbackUrl, normalizeBrowserUrl } from './browser.ts'
import { parsePrefs } from './prefs.ts'
import { patchTab } from './state.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { t } from './locales.ts'
Expand Down Expand Up @@ -95,7 +96,22 @@ export function BrowserView(props: TabComponentProps) {
/** TEMPORARY sandbox unlock for THIS surface only (never writes the global
* side card setting; lasts until the tab unmounts or the user restores). */
const [localUnlock, setLocalUnlock] = useState(false)
const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock
/** The persistent browser-wide setting is reactive: changing it from any
* browser tab remounts every open browser iframe in this page. */
const subscribePrefs = useCallback((listener: () => void) => store.subscribe(listener), [store])
const readBrowserNoSandbox = useCallback(() => store.getSnapshot().prefs.browserNoSandbox, [store])
const globallyUnsandboxed = useSyncExternalStore(subscribePrefs, readBrowserNoSandbox, readBrowserNoSandbox)
const noSandbox = globallyUnsandboxed || localUnlock
const [sandboxSettingPending, setSandboxSettingPending] = useState(false)
const [sandboxSettingError, setSandboxSettingError] = useState<string | null>(null)

// A global restore is authoritative over every tab's earlier temporary
// unlock. All mounted BrowserViews observe the shared pref transition and
// clear their local escape hatch together.
useEffect(() => {
if (!globallyUnsandboxed) setLocalUnlock(false)
}, [globallyUnsandboxed])

/** A site that refuses to be embedded (X-Frame-Options / frame-ancestors):
* the probe verdict shown instead of the blank iframe. */
const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
Expand Down Expand Up @@ -161,6 +177,22 @@ export function BrowserView(props: TabComponentProps) {
setReloadKey(key => key + 1)
}

/** Persist the browser-wide sandbox mode and publish the round-tripped
* preferences through the shared store. This affects every open browser
* tab immediately and survives session/page changes. */
const toggleGlobalSandbox = (): void => {
if (sandboxSettingPending) return
setSandboxSettingPending(true)
setSandboxSettingError(null)
void api.settingsUpdate({ browserNoSandbox: !globallyUnsandboxed }).then((view) => {
store.setPrefs(parsePrefs(view.value))
}).catch(() => {
setSandboxSettingError(t('settingsSaveFailed'))
}).finally(() => {
setSandboxSettingPending(false)
})
}

return (
<div className={css.browser}>
<div className={css.browserBar}>
Expand Down Expand Up @@ -228,11 +260,18 @@ export function BrowserView(props: TabComponentProps) {
{message !== null && <div className={css.browserMessage}>{message}</div>}
<SandboxStatusBar
sandboxed={!noSandbox}
local={localUnlock}
local={localUnlock && !globallyUnsandboxed}
dangerCopy={t('browserNoSandboxWarning')}
onUnlock={() => { setLocalUnlock(true) }}
onRestore={() => { setLocalUnlock(false) }}
persistentAction={{
label: globallyUnsandboxed ? t('sandboxRestore') : t('settingsBrowserSandboxTitle'),
title: t('settingsBrowserSandboxDesc'),
pending: sandboxSettingPending,
onClick: toggleGlobalSandbox,
}}
/>
{sandboxSettingError !== null && <div className={css.browserMessage}>{sandboxSettingError}</div>}
{url === undefined ? (
<div className={css.browserStart}>{t('browserStart')}</div>
) : embedBlocked !== null && !forceEmbed ? (
Expand Down
68 changes: 48 additions & 20 deletions src/client/SandboxStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
* The live sandbox status row of the two built-in web surfaces (HTML
* preview and the browser tab): a green "sandbox on" state with a one-tap
* TEMPORARY unlock, or a RED "sandbox off" state (global setting or the
* temporary unlock) with a restore action.
* temporary unlock) with a restore action. A surface may additionally expose
* one persistent/global action in the same row (the browser uses it to write
* `browserNoSandbox` for every browser tab and future session).
*
* The temporary unlock is component state only — it never writes the
* global side card setting (`htmlViewerNoSandbox` / `browserNoSandbox`);
* it lasts until the surface unmounts (tab switch / file switch) or the
* user restores the sandbox from the row. When the global setting already
* drops the sandbox, no unlock/restore action is offered (changing the
* global setting is the settings page's job) — the red warning stands.
* user restores the sandbox from the row. Persistent actions are optional,
* so the HTML preview keeps its existing local-only status controls.
*/
import clsx from 'clsx'
import { t } from './locales.ts'
Expand All @@ -24,36 +25,63 @@ export function SandboxStatusBar(props: {
dangerCopy: string
onUnlock: () => void
onRestore: () => void
/** Optional persistent action (for example the browser-wide sandbox setting). */
persistentAction?: {
label: string
title?: string
pending?: boolean
onClick: () => void
}
}) {
const { sandboxed, local, dangerCopy, onUnlock, onRestore } = props
const { sandboxed, local, dangerCopy, onUnlock, onRestore, persistentAction } = props
const persistentButton = persistentAction === undefined ? null : (
<button
type="button"
className={css.sandboxAction}
title={persistentAction.title}
disabled={persistentAction.pending === true}
aria-busy={persistentAction.pending === true ? 'true' : undefined}
onClick={persistentAction.onClick}
>
{persistentAction.label}
</button>
)
if (sandboxed) {
const copy = t('sandboxStatusOn')
return (
<div className={clsx(css.sandboxStatus, css.sandboxStatusOn)}>
<span className={css.sandboxDot} />
<span className={css.sandboxStatusText} title={copy}>{copy}</span>
<button
type="button"
className={css.sandboxAction}
onClick={onUnlock}
>
{t('sandboxUnlock')}
</button>
<div className={css.sandboxActions}>
<button
type="button"
className={css.sandboxAction}
onClick={onUnlock}
>
{t('sandboxUnlock')}
</button>
{persistentButton}
</div>
</div>
)
}
return (
<div className={clsx(css.sandboxStatus, css.sandboxStatusOff)}>
<span className={css.sandboxDot} />
<span className={css.sandboxStatusText} title={dangerCopy}>{dangerCopy}</span>
{local && (
<button
type="button"
className={css.sandboxAction}
onClick={onRestore}
>
{t('sandboxRestore')}
</button>
{(local || persistentButton !== null) && (
<div className={css.sandboxActions}>
{local && (
<button
type="button"
className={css.sandboxAction}
onClick={onRestore}
>
{t('sandboxRestore')}
</button>
)}
{persistentButton}
</div>
)}
</div>
)
Expand Down
16 changes: 16 additions & 0 deletions src/client/SideCardSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,22 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
const optimisticRef = useRef(prefs)
useEffect(() => { optimisticRef.current = prefs }, [prefs])

// Keep this settings surface aligned with preference writes made elsewhere
// in the page (notably the browser status row's global sandbox switch).
// SidebarStore also publishes ordinary session-state changes, so compare the
// stable prefs snapshot reference before touching local/optimistic state.
useEffect(() => {
let current = store.getSnapshot().prefs
return store.subscribe(() => {
const next = store.getSnapshot().prefs
if (next === current) return
current = next
optimisticRef.current = next
setPrefs(next)
setWidthDraft(String(next.defaultWidthPercent))
})
}, [store])

// The declarative inventory: the registered tab types and file viewers.
// Local state + service.subscribe (registry changes are rare — plugin
// load/unload — so a plain effect is enough; no external-store ceremony).
Expand Down
14 changes: 13 additions & 1 deletion src/client/sidebar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,13 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
white-space: nowrap;
}

.sandboxActions {
flex: none;
display: flex;
align-items: center;
gap: 6px;
}

.sandboxAction {
flex: none;
padding: 2px 8px;
Expand All @@ -1557,10 +1564,15 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
cursor: pointer;
}

.sandboxAction:hover {
.sandboxAction:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}

.sandboxAction:disabled {
opacity: 0.5;
cursor: wait;
}

/* The HTML preview iframe: fills the editor body. Route-src (never srcdoc)
so the frame is cross-origin by construction even if the sandbox
attribute is ever dropped; sandboxed by default (see TextEditor). */
Expand Down
96 changes: 96 additions & 0 deletions tests/browser-global-sandbox-toggle.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* The browser status row owns a persistent/global sandbox switch in addition
* to its local temporary unlock. One write must update every mounted browser
* tab through the shared prefs store and the round-tripped value must restore
* the sandbox just as directly.
*/
// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { createElement, Fragment } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import type { Context } from '../src/context-types.ts'
import { api } from '../src/client/api.ts'
import { BrowserView } from '../src/client/BrowserView.tsx'
import { createSidebarStore } from '../src/client/state.ts'

;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true

beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'zh-CN', configurable: true })
})

const CTX = {} as Context

function tabProps(store: ReturnType<typeof createSidebarStore>, id: string) {
return {
ctx: CTX,
store,
scope: { sessionId: 's1', cwd: '/p' },
tab: { id, type: 'browser', title: '浏览器', path: 'https://example.com/' },
visible: true,
}
}

describe('browser global sandbox toggle', () => {
let root: Root | undefined
let container: HTMLDivElement | undefined

afterEach(() => {
if (root !== undefined) act(() => { root!.unmount() })
container?.remove()
root = undefined
container = undefined
vi.restoreAllMocks()
})

it('persists the setting and updates every open browser iframe', async () => {
const store = createSidebarStore()
vi.spyOn(api, 'browserProbe').mockResolvedValue({ reachable: false })
const update = vi.spyOn(api, 'settingsUpdate').mockImplementation(async (patch) => ({
value: { ...store.getPrefs(), ...patch },
revision: 2,
}))

container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
act(() => {
root!.render(createElement(Fragment, null,
createElement(BrowserView, tabProps(store, 'browser:1')),
createElement(BrowserView, tabProps(store, 'browser:2')),
))
})

const temporaryUnlocks = [...container.querySelectorAll<HTMLButtonElement>('button')]
.filter(button => button.textContent?.trim() === '临时解锁(不安全)')
const globalOff = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.includes('关闭浏览器沙箱'))
expect(temporaryUnlocks).toHaveLength(2)
expect(globalOff).toBeDefined()
expect(container.querySelectorAll('iframe[sandbox]')).toHaveLength(2)

// Leave tab B locally unlocked before exercising the global switch. A
// later global restore must still secure both tabs.
act(() => { temporaryUnlocks[1]!.click() })
expect(container.querySelectorAll('iframe[sandbox]')).toHaveLength(1)

await act(async () => { globalOff!.click() })

expect(update).toHaveBeenLastCalledWith({ browserNoSandbox: true })
expect(store.getPrefs().browserNoSandbox).toBe(true)
expect(container.querySelectorAll('iframe[sandbox]')).toHaveLength(0)
expect(container.textContent).toContain('沙箱已关闭')

const globalOn = [...container.querySelectorAll<HTMLButtonElement>('button')]
.find(button => button.textContent?.trim() === '恢复沙箱')
expect(globalOn).toBeDefined()

await act(async () => { globalOn!.click() })

expect(update).toHaveBeenLastCalledWith({ browserNoSandbox: false })
expect(store.getPrefs().browserNoSandbox).toBe(false)
expect(container.querySelectorAll('iframe[sandbox]')).toHaveLength(2)
expect(container.textContent).toContain('沙箱模式:已启用')
})
})
Loading