diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 9135a4c..8b9a5a2 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -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[]`(开放 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` 后自动重试失败的操作)。 +- ⚠️ **安全代价**:关闭期间,页面内任意同源脚本(**包括第三方消费插件**)都能通过上述路由读写主机上任意文件——开关文案已明示该风险,用完建议重新打开。 --- diff --git a/docs/plans/2026-08-29-workspace-fence-toggle-design.md b/docs/plans/2026-08-29-workspace-fence-toggle-design.md new file mode 100644 index 0000000..3c657e6 --- /dev/null +++ b/docs/plans/2026-08-29-workspace-fence-toggle-design.md @@ -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(本特性对消费插件的可感知行为与风险)。 diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index d394c73..9db60cf 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -27,8 +27,9 @@ import { createElement } from 'react' import clsx from 'clsx' import { IconCheckOutline16, IconFolderOpen16, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' -import { api, mediaUrl, type SessionScope } from './api.ts' +import { api, isOutsideWorkspaceMessage, mediaUrl, type SessionScope } from './api.ts' import { BinaryDownload } from './binary-download.tsx' +import { FenceErrorNotice } from './FenceErrorNotice.tsx' import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts' import { baseName } from './FileTree.tsx' import { createFrameBatcher } from './frame-batcher.ts' @@ -369,6 +370,7 @@ export function EditorHost(props: {
{showEmpty &&
{t('editorEmptyHint')}
} {!showEmpty && load.status === 'loading' &&
{t('loading')}
} - {!showEmpty && load.status === 'error' &&
{load.message}
} + {!showEmpty && load.status === 'error' && (isOutsideWorkspaceMessage(load.message) + ? { setReloadSeq(sequence => sequence + 1) }} /> + :
{load.message}
)} {!showEmpty && load.status === 'binary' && } {!showEmpty && load.status === 'ready' && createElement(load.viewer.component, { ctx, store, scope, path, title, @@ -488,6 +492,7 @@ export function EditorHost(props: { onPointerCancel={onResizeEnd} /> void }) { + const { store, onDisabled } = props + const [busy, setBusy] = useState(false) + const disable = (): void => { + if (busy) return + setBusy(true) + api.settingsUpdate({ workspaceFence: false }).then((view) => { + store.setPrefs(parsePrefs(view.value)) + onDisabled() + }).catch((error: unknown) => { + // The fence stays armed on failure (the notice remains, button + // re-enabled); the console carries the cause. + console.error('workspace fence disable failed', error) + setBusy(false) + }) + } + return ( +
+ {t('fenceErrorReason')} + +
+ ) +} diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 6f1d320..942a06f 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -29,11 +29,13 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import { SiCursor, SiZedindustries } from 'react-icons/si' import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc' -import { api, downloadUrl, type FsEntry } from './api.ts' +import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts' +import { FenceErrorNotice } from './FenceErrorNotice.tsx' import { IconUploadOutline16, IconVscode16 } from './icons.tsx' import type { OpenWithTarget } from './open-with.ts' import { relativeTo } from './paths.ts' import { t } from './locales.ts' +import type { SidebarStore } from './state.ts' import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts' import css from './sidebar.module.css' @@ -105,6 +107,8 @@ const ChatDropIllustration = () => ( export function FileTree(props: { sessionId: string cwd: string | undefined + /** The sidebar store: the fence-refusal notice writes the `workspaceFence` pref through it. */ + store: SidebarStore expanded: string[] /** Files highlighted by a "Show in folder" reveal (absolute paths). */ revealed: string[] @@ -136,7 +140,7 @@ export function FileTree(props: { /** True while an upload is in flight (drops are ignored). */ busy: boolean }) { - const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props + const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props const [data, setData] = useState>({}) const dataRef = useRef(data) /** The row whose path was just copied ("copied" label replaces its button). */ @@ -257,6 +261,13 @@ export function FileTree(props: { }) }, [sessionId, cwd, storeLevel]) + /** Drop one level from the cache and reload it (the fence notice's retry). */ + const retryDir = useCallback((dir: string) => { + delete dataRef.current[dir] + setData({ ...dataRef.current }) + loadDir(dir) + }, [loadDir]) + // The caller's refresh tick wipes the cache (declared BEFORE the load // effect so the reload below sees the empty cache). const lastTick = useRef(refreshTick) @@ -436,6 +447,16 @@ export function FileTree(props: { return
{t('loading')}
} if (level.error !== undefined) { + // The fence refusal becomes the friendly notice (reason + one-click + // global off + immediate retry of this directory), never the raw + // `path "..." is outside workspace` wire text. + if (isOutsideWorkspaceMessage(level.error)) { + return ( +
+ { retryDir(dir) }} /> +
+ ) + } return (
{level.error} diff --git a/src/client/GitView.tsx b/src/client/GitView.tsx index 66acbe9..773ca29 100644 --- a/src/client/GitView.tsx +++ b/src/client/GitView.tsx @@ -19,7 +19,7 @@ import { api } from './api.ts' import { isWithinWorkspace, relativeTo } from './paths.ts' import { resolveSidebarPath } from './produced-files.ts' import { relativeTime, t } from './locales.ts' -import type { SidebarTab } from './state.ts' +import type { SidebarStore, SidebarTab } from './state.ts' import css from './sidebar.module.css' /** The XY status letters a row badge shows (X = index, Y = worktree). */ @@ -83,13 +83,15 @@ const LOG_BATCH = 20 export function GitView(props: { scope: SessionScope + /** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */ + store: SidebarStore onOpenFile: (path: string) => void /** Open a diff tab (the shell places it below the git pane on first use). */ onOpenDiff: (tab: SidebarTab) => void /** Poll only while the tab is actually visible. */ visible: boolean }) { - const { scope, onOpenFile, onOpenDiff, visible } = props + const { scope, store, onOpenFile, onOpenDiff, visible } = props const [status, setStatus] = useState(null) const [worktrees, setWorktrees] = useState([]) const [selectedWorktree, setSelectedWorktree] = useState() @@ -572,10 +574,12 @@ export function GitView(props: { onClose={() => { setFileMenu(null) }} items={[ // A linked worktree outside the session workspace cannot be - // opened in the editor: the host's workspace fence rejects - // every path under it. Hide the action for that checkout so - // the menu does not offer a no-op that confuses the user. - ...(fileMenu !== null && isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.entry.path)) + // opened in the editor while the host's workspace fence is + // armed: it rejects every path under that checkout. Hide the + // action for that checkout so the menu does not offer a no-op + // that confuses the user; with the fence disarmed (the + // `workspaceFence` pref) the open is allowed through. + ...(fileMenu !== null && (store.getPrefs().workspaceFence === false || isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.entry.path))) ? [{ id: 'open', label: t('openEditor'), icon: }] : []), fileMenu?.staged === true @@ -597,8 +601,9 @@ export function GitView(props: { // Defense-in-depth: the menu hides this action when the // resolved path escapes the session workspace, but a // racing repo switch could still reach here with a path - // the host would reject. No-op in that case. - if (!isWithinWorkspace(scope.cwd ?? '', resolved)) return + // the host would reject. No-op in that case — unless the + // workspace fence is disarmed by pref. + if (store.getPrefs().workspaceFence !== false && !isWithinWorkspace(scope.cwd ?? '', resolved)) return onOpenFile(resolved) return } diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx index e304328..63d6162 100644 --- a/src/client/TreePanel.tsx +++ b/src/client/TreePanel.tsx @@ -19,6 +19,7 @@ import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react' import clsx from 'clsx' import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { api } from './api.ts' +import type { SidebarStore } from './state.ts' import { FileTree } from './FileTree.tsx' import { IconUploadOutline16 } from './icons.tsx' import type { OpenWithTarget } from './open-with.ts' @@ -44,6 +45,8 @@ interface UploadSession { export function TreePanel(props: { sessionId: string cwd: string | undefined + /** The sidebar store (passed through to the tree's fence-refusal notice). */ + store: SidebarStore expanded: string[] revealed: string[] onToggle: (path: string) => void @@ -64,7 +67,7 @@ export function TreePanel(props: { * at a fixed width. */ full?: boolean }) { - const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props + const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props const [query, setQuery] = useState('') const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null) const [error, setError] = useState(null) @@ -221,6 +224,7 @@ export function TreePanel(props: { tab.path, // Declarative settings: the file-open behavior picker (in-place switch // vs per-path windows) renders as an iconed select row under the - // editor card's gear in the Side card settings page; the "open with" - // configuration (SSH host + custom editors) is the custom panel BELOW - // those rows — the settings seam renders rows first, custom panel after. + // editor card's gear in the Side card settings page, followed by the + // workspace fence switch (the host's containment guard over every + // sidebar filesystem route); the "open with" configuration (SSH host + + // custom editors) is the custom panel BELOW those rows — the settings + // seam renders rows first, custom panel after. settings: { toggles: [{ key: 'editorExplorer', @@ -114,6 +116,10 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read desc: () => t('editorExplorerSplitDesc'), }, ], + }, { + key: 'workspaceFence', + title: () => t('settingsFenceTitle'), + desc: () => t('settingsFenceDesc'), }], render: ({ pluginSettings, updatePluginSetting }) => ( @@ -141,6 +147,7 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read component: ({ ctx, store, scope, visible, onOpenDiff }) => ( { openSidebarFile(ctx, store, scope.sessionId, path) }} onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })} diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index e16accb..07a7be1 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -228,6 +228,10 @@ export const ar: Record = { settingsJobsDesc: 'توسيع البطاقة الجانبية وفتح صفحة المهام الخلفية عند ظهور مهمة خلفية جديدة للمحادثة الحالية (كل مهمة جديدة تُطلق ذلك)؛ أوقفها للفتح يدوياً', settingsToolsTitle: 'حقن أدوات الطرفية للنموذج', settingsToolsDesc: 'عند التفعيل، يمكن للنموذج إنشاء وتشغيل طرفيات الشريط الجانبي عبر أدوات terminal_* الثمانية (معطّل افتراضياً)', + settingsFenceTitle: 'فحص مسارات مساحة العمل', + settingsFenceDesc: 'عند التفعيل، تصل وظائف الملفات في الشريط الجانبي إلى مسارات داخل مساحة عمل الجلسة فقط (الافتراضي)؛ عند الإيقاف تصبح ملفات المضيف كلها متاحة — وتحصل سكربتات الصفحة على النفاذ نفسه طوال ذلك', + fenceErrorReason: 'هذا المسار خارج مساحة عمل الجلسة وقد حجبه الفحص', + fenceDisableAction: 'إيقاف فحص مساحة العمل', settingsBottomTerminalTitle: 'فتح طرفية تلقائياً عند أول توسيع للوحة السفلية', settingsBottomTerminalDesc: 'عند توسيع اللوحة السفلية لأول مرة في الجلسة، حاول فتح تبويب طرفية جديد هناك (حصة الطرفيات ما زالت تسري؛ مفعّل افتراضياً)', settingsFontFamilyTitle: 'عائلة خط الطرفية', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index c84b40b..71991da 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -213,6 +213,10 @@ export const de: Record = { settingsJobsDesc: 'Die Seitenkarte wird ausgeklappt und die Aufgaben-Seite geöffnet, sobald für die aktuelle Unterhaltung eine neue Hintergrundaufgabe erscheint (jede neue Aufgabe löst dies aus); ausschalten, um sie manuell zu öffnen', settingsToolsTitle: 'Terminal-Werkzeuge für das Modell bereitstellen', settingsToolsDesc: 'Wenn aktiviert, kann das Modell über die 8 terminal_*-Werkzeuge Terminale in der Seitenleiste erstellen und steuern (standardmäßig deaktiviert)', + settingsFenceTitle: 'Workspace-Pfadkontrolle', + settingsFenceDesc: 'Wenn aktiv, erreichen die Dateifunktionen der Seitenleiste nur Pfade innerhalb des Session-Workspaces (Standard); ausgeschaltet sind Dateien überall auf dem Host erreichbar — Skripte der Seite erhalten solange dieselbe Reichweite', + fenceErrorReason: 'Dieser Pfad liegt außerhalb des Session-Workspaces und wurde von der Workspace-Kontrolle blockiert', + fenceDisableAction: 'Workspace-Kontrolle ausschalten', settingsBottomTerminalTitle: 'Beim ersten Ausklappen des unteren Panels automatisch ein Terminal öffnen', settingsBottomTerminalDesc: 'Wird das untere Panel in einer Sitzung zum ersten Mal ausgeklappt, wird dort versucht, einen neuen Terminal-Tab zu öffnen (das Terminal-Kontingent gilt weiterhin; standardmäßig aktiviert)', settingsFontFamilyTitle: 'Terminal-Schriftart', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 3452530..26060dd 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -220,6 +220,10 @@ export const fr: Record = { settingsJobsDesc: 'Lorsque la session actuelle produit une nouvelle tâche d’arrière-plan, déployer automatiquement la barre latérale et ouvrir la page des tâches (déclenché à chaque nouvelle tâche) ; une fois désactivé, l’ouverture est manuelle', settingsToolsTitle: 'Injecter des outils de terminal au modèle', settingsToolsDesc: 'Une fois activé, le modèle peut créer et piloter des terminaux de la barre latérale via les 8 outils terminal_* (désactivé par défaut)', + settingsFenceTitle: 'Contrôle des chemins de l\'espace de travail', + settingsFenceDesc: 'Activé, les fonctions fichiers de la barre latérale n\'atteignent que les chemins dans l\'espace de travail de la session (par défaut) ; désactivé, les fichiers de tout l\'hôte deviennent accessibles — les scripts de la page obtiennent la même portée pendant ce temps', + fenceErrorReason: 'Ce chemin est hors de l\'espace de travail de la session et a été bloqué par le contrôle', + fenceDisableAction: 'Désactiver le contrôle de l\'espace de travail', settingsBottomTerminalTitle: 'Ouvrir automatiquement un terminal au premier déploiement du panneau inférieur', settingsBottomTerminalDesc: 'Lors du premier déploiement du panneau inférieur dans une session, tenter d’y ouvrir automatiquement un nouvel onglet de terminal (la limite du nombre de terminaux s’applique toujours ; activé par défaut)', settingsFontFamilyTitle: 'Police du terminal', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index d925ad1..3671114 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -227,6 +227,10 @@ export const hi: Record = { settingsJobsDesc: 'वर्तमान वार्तालाप में नया बैकग्राउंड कार्य दिखने पर साइडबार विस्तृत करें और कार्य पृष्ठ खोलें (हर नया कार्य ट्रिगर करता है); बंद करने पर मैन्युअल रूप से खोलें', settingsToolsTitle: 'मॉडल के लिए टर्मिनल टूल इंजेक्ट करें', settingsToolsDesc: 'सक्षम होने पर, मॉडल 8 terminal_* टूल्स से साइडबार टर्मिनल बना और चला सकता है (डिफ़ॉल्ट रूप से बंद)', + settingsFenceTitle: 'वर्कस्पेस पाथ जाँच', + settingsFenceDesc: 'चालू होने पर साइडबार की फ़ाइल सुविधाएँ केवल सेशन वर्कस्पेस के भीतर के पाथ तक पहुँचती हैं (डिफ़ॉल्ट); बंद करने पर होस्ट की कोई भी फ़ाइल खोली जा सकती है — उस दौरान पेज के स्क्रिप्ट को भी वही पहुँच मिल जाती है', + fenceErrorReason: 'यह पाथ सेशन वर्कस्पेस के बाहर है और जाँच द्वारा अवरुद्ध किया गया', + fenceDisableAction: 'वर्कस्पेस जाँच बंद करें', settingsBottomTerminalTitle: 'निचला पैनल पहली बार खुलने पर स्वतः टर्मिनल खोलें', settingsBottomTerminalDesc: 'सत्र में निचला पैनल पहली बार विस्तृत होने पर वहाँ नया टर्मिनल टैब खोलने का प्रयास (टर्मिनल कोटा लागू; डिफ़ॉल्ट रूप से चालू)', settingsFontFamilyTitle: 'टर्मिनल फ़ॉन्ट परिवार', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 713a56f..da324ea 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -225,6 +225,10 @@ export const id: Record = { settingsJobsDesc: 'Bentangkan kartu samping dan buka halaman Jobs setiap kali tugas latar baru muncul untuk obrolan saat ini (setiap tugas baru memicu); matikan untuk membukanya secara manual', settingsToolsTitle: 'Suntik alat terminal untuk model', settingsToolsDesc: 'Saat diaktifkan, model dapat membuat dan mengendalikan terminal sidebar melalui 8 alat terminal_* (nonaktif secara default)', + settingsFenceTitle: 'Pemeriksaan path workspace', + settingsFenceDesc: 'Saat aktif, fitur berkas sidebar hanya menjangkau path di dalam workspace sesi (default); saat nonaktif, berkas di mana pun di host dapat diakses — skrip halaman memperoleh jangkauan yang sama selama itu', + fenceErrorReason: 'Path ini berada di luar workspace sesi dan diblokir oleh pemeriksaan', + fenceDisableAction: 'Matikan pemeriksaan workspace', settingsBottomTerminalTitle: 'Buka terminal otomatis saat panel bawah pertama kali dibentangkan', settingsBottomTerminalDesc: 'Saat panel bawah pertama kali dibentangkan dalam sesi, coba buka tab terminal baru di panel bawah (kuota terminal tetap berlaku; aktif secara default)', settingsFontFamilyTitle: 'Keluarga font terminal', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 1c28e21..51eb67a 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -218,6 +218,10 @@ export const it: Record = { settingsJobsDesc: 'Espande la barra laterale e apre la pagina Attività in background ogni volta che compare una nuova attività in background per la conversazione attuale (ogni nuova attività la attiva); disattivato va aperta manualmente', settingsToolsTitle: 'Inietta strumenti del terminale per il modello', settingsToolsDesc: 'Se attivato, il modello può creare e gestire terminali della barra laterale attraverso gli 8 strumenti terminal_* (disattivato per impostazione predefinita)', + settingsFenceTitle: 'Controllo dei percorsi del workspace', + settingsFenceDesc: 'Attivo, le funzioni file della barra laterale raggiungono solo i percorsi nel workspace di sessione (predefinito); disattivo, ogni file dell\'host è raggiungibile — gli script della pagina ottengono la stessa portata nel frattempo', + fenceErrorReason: 'Questo percorso è fuori dal workspace di sessione ed è stato bloccato dal controllo', + fenceDisableAction: 'Disattiva il controllo del workspace', settingsBottomTerminalTitle: 'Apri automaticamente un terminale alla prima espansione del pannello inferiore', settingsBottomTerminalDesc: 'Quando il pannello inferiore viene espanso per la prima volta in una sessione, prova ad aprire una nuova scheda terminale nel pannello inferiore (il limite di terminali si applica comunque; attivo per impostazione predefinita)', settingsFontFamilyTitle: 'Famiglia di caratteri del terminale', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 761f3f1..4ba4a50 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -227,6 +227,10 @@ export const ja: Record = { settingsJobsDesc: '現在の会話で新しいバックグラウンドタスクが発生するたびにサイドバーを展開してタスクページを開く(新規タスクごとに発火)。オフ時は手動で開く必要あり', settingsToolsTitle: 'モデルにターミナルツールを注入', settingsToolsDesc: 'オンにすると、モデルが terminal_create 等 8 個のツールでサイドバーターミナルを作成・操作可能(デフォルトオフ)', + settingsFenceTitle: 'ワークスペースパスガード', + settingsFenceDesc: 'オンの場合、サイドバーのファイル機能はセッションワークスペース内のパスのみアクセス可能(デフォルト)。オフにするとホスト上の任意のファイルにアクセスできるが、オフの間はページ内スクリプトも同じ権限を得る', + fenceErrorReason: 'このパスはセッションワークスペースの外にあり、ワークスペースガードによりブロックされました', + fenceDisableAction: 'ワークスペースガードをオフにする', settingsBottomTerminalTitle: '下パネル初回展開時に自動でターミナルを開く', settingsBottomTerminalDesc: '会話ごとに下パネルを初めて展開した際、下パネルに新しいターミナルタブを自動で開く(ターミナル数上限は依然適用。デフォルトオン)', settingsFontFamilyTitle: 'ターミナルフォント', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 1c07740..1338cfb 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -219,6 +219,10 @@ export const ko: Record = { settingsJobsDesc: '현재 대화에 새 백그라운드 작업이 생기면 사이드바를 자동으로 펼치고 백그라운드 작업 페이지를 엽니다(새 작업마다 트리거됨). 끄면 수동으로 열어야 합니다', settingsToolsTitle: '모델에 터미널 도구 주입', settingsToolsDesc: '켜면 모델이 terminal_create 등 8개 도구로 사이드바 터미널을 만들고 조작할 수 있습니다(기본 꺼짐)', + settingsFenceTitle: '워크스페이스 경로 검사', + settingsFenceDesc: '켜면 사이드바의 파일 기능이 세션 워크스페이스 내 경로만 접근합니다(기본값). 끄면 호스트의 모든 파일에 접근할 수 있으며, 꺼져 있는 동안 페이지 내 스크립트도 동일한 권한을 얻습니다', + fenceErrorReason: '이 경로는 세션 워크스페이스 밖에 있어 검사에 의해 차단되었습니다', + fenceDisableAction: '워크스페이스 검사 끄기', settingsBottomTerminalTitle: '하단 패널을 처음 펼칠 때 터미널 자동 열기', settingsBottomTerminalDesc: '세션에서 하단 패널을 처음 펼칠 때 하단 패널에 새 터미널 탭을 자동으로 열려고 시도합니다(터미널 개수 상한은 여전히 적용됩니다. 기본 켜짐)', settingsFontFamilyTitle: '터미널 글꼴', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 9aa23ec..4d27d7b 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -225,6 +225,10 @@ export const nl: Record = { settingsJobsDesc: 'De zijkaart uitklappen en de Jobs-pagina openen telkens een nieuwe achtergrondtaak verschijnt voor de huidige conversatie (elke nieuwe taak triggert); schakel uit om het handmatig te openen', settingsToolsTitle: 'Terminaltools voor het model injecteren', settingsToolsDesc: 'Indien ingeschakeld kan het model zijbalk-terminals aanmaken en besturen via de 8 terminal_*-tools (standaard uit)', + settingsFenceTitle: 'Workspace-padcontrole', + settingsFenceDesc: 'Aan: de bestandsfuncties van de zijbalk bereiken alleen paden binnen de sessie-workspace (standaard); uit: elk bestand op de host is bereikbaar — paginascripts krijgen dat bereik zolang het uit staat', + fenceErrorReason: 'Dit pad ligt buiten de sessie-workspace en is geblokkeerd door de controle', + fenceDisableAction: 'Workspace-controle uitzetten', settingsBottomTerminalTitle: 'Automatisch een terminal openen bij de eerste uitklap van het onderpaneel', settingsBottomTerminalDesc: 'Wanneer het onderpaneel voor het eerst in een sessie wordt uitgeklapt, wordt geprobeerd daar een nieuw terminaltabblad te openen (het terminalquotum blijft gelden; standaard aan)', settingsFontFamilyTitle: 'Terminallettertype-familie', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 42ce168..cc14f9c 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -229,6 +229,10 @@ export const pl: Record = { settingsJobsDesc: 'Gdy w bieżącej rozmowie pojawi się nowe zadanie w tle, rozwiń panel boczny i otwórz stronę zadań w tle (każde nowe zadanie wyzwala); wyłącz, aby otwierać ręcznie', settingsToolsTitle: 'Wstrzyknij narzędzia terminala dla modelu', settingsToolsDesc: 'Po włączeniu model może tworzyć i sterować terminalami panelu bocznego przez 8 narzędzi terminal_* (domyślnie wyłączone)', + settingsFenceTitle: 'Kontrola ścieżek obszaru roboczego', + settingsFenceDesc: 'Włączona: funkcje plików paska bocznego sięgają tylko ścieżek w obszarze roboczym sesji (domyślnie); wyłączona: dostępny jest dowolny plik hosta — skrypty strony zyskują ten sam zasięg przez ten czas', + fenceErrorReason: 'Ta ścieżka jest poza obszarem roboczym sesji i została zablokowana przez kontrolę', + fenceDisableAction: 'Wyłącz kontrolę obszaru roboczego', settingsBottomTerminalTitle: 'Automatycznie otwieraj terminal przy pierwszym rozwinięciu panelu dolnego', settingsBottomTerminalDesc: 'Gdy panel dolny jest rozwijany po raz pierwszy w sesji, spróbuj otworzyć nową kartę terminala tam (limit terminali nadal obowiązuje; domyślnie włączone)', settingsFontFamilyTitle: 'Krój czcionki terminala', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 5e1a763..e066236 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -210,6 +210,10 @@ export const pt: Record = { settingsJobsDesc: 'Expandir o cartão lateral e abrir a página de Tarefas sempre que uma nova tarefa em segundo plano aparecer para a conversa atual (cada nova tarefa dispara); desative para abrir manualmente', settingsToolsTitle: 'Injetar ferramentas de terminal para o modelo', settingsToolsDesc: 'Quando ativado, o modelo pode criar e operar terminais da barra lateral por meio das 8 ferramentas terminal_* (desativado por padrão)', + settingsFenceTitle: 'Controle de caminhos do workspace', + settingsFenceDesc: 'Ativado, os recursos de arquivos da barra lateral só alcançam caminhos dentro do workspace da sessão (padrão); desativado, arquivos de todo o host ficam acessíveis — scripts da página ganham o mesmo alcance enquanto isso', + fenceErrorReason: 'Este caminho está fora do workspace da sessão e foi bloqueado pelo controle', + fenceDisableAction: 'Desativar o controle do workspace', settingsBottomTerminalTitle: 'Abrir um terminal automaticamente na primeira expansão do painel inferior', settingsBottomTerminalDesc: 'Quando o painel inferior for expandido pela primeira vez em uma sessão, tente abrir uma nova aba de terminal lá (a cota de terminais ainda se aplica; ativado por padrão)', settingsFontFamilyTitle: 'Família de fonte do terminal', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index c2fafe8..aa3cadf 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -224,6 +224,10 @@ export const ru: Record = { settingsJobsDesc: 'При появлении новой фоновой задачи в текущей сессии боковая панель автоматически разворачивается и открывается страница «Фоновые задачи» (срабатывает на каждую новую задачу); при отключении нужно открывать вручную', settingsToolsTitle: 'Внедрить терминальные инструменты для модели', settingsToolsDesc: 'Если включено, модель может через 8 инструментов (terminal_create и др.) создавать терминалы боковой панели и управлять ими (по умолчанию выключено)', + settingsFenceTitle: 'Контроль путей рабочей области', + settingsFenceDesc: 'Включено — файловые функции боковой панели достигают только путей внутри рабочей области сессии (по умолчанию); выключено — доступны любые файлы хоста, а скрипты страницы получают тот же доступ всё это время', + fenceErrorReason: 'Этот путь вне рабочей области сессии и заблокирован контролем', + fenceDisableAction: 'Отключить контроль рабочей области', settingsBottomTerminalTitle: 'Автооткрытие терминала при первом разворачивании нижней панели', settingsBottomTerminalDesc: 'При первом разворачивании нижней панели в сессии попытаться автоматически открыть новую вкладку терминала в ней (лимит терминалов всё равно применяется; по умолчанию включено)', settingsFontFamilyTitle: 'Шрифт терминала', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index bd920e3..a51a0e0 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -210,6 +210,10 @@ export const sv: Record = { settingsJobsDesc: 'Expandera sidokortet och öppna Jobbsidan när ett nytt bakgrundsjobb dyker upp för aktuell konversation (varje nytt jobb utlöser); stäng av för att öppna manuellt', settingsToolsTitle: 'Injicera terminalverktyg för modellen', settingsToolsDesc: 'När aktiverat kan modellen skapa och styra sidopanelsterminaler via de 8 terminal_*-verktygen (av som standard)', + settingsFenceTitle: 'Kontroll av workspace-sökvägar', + settingsFenceDesc: 'På: sidopanelens filfunktioner når bara sökvägar i sessionens workspace (standard); av: vilken fil som helst på värden kan nås — sidans skript får samma räckvidd så länge', + fenceErrorReason: 'Denna sökväg ligger utanför sessionens workspace och blockerades av kontrollen', + fenceDisableAction: 'Stäng av workspace-kontrollen', settingsBottomTerminalTitle: 'Öppna automatiskt en terminal vid bottenpanelens första expandering', settingsBottomTerminalDesc: 'När bottenpanelen expanderas för första gången i en session, försök öppna en ny terminalflik där (terminalgränsen gäller fortfarande; på som standard)', settingsFontFamilyTitle: 'Terminalens teckensnittsfamilj', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 9966b59..37e0361 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -227,6 +227,10 @@ export const th: Record = { settingsJobsDesc: 'ขยายการ์ดด้านข้างและเปิดหน้า Jobs เมื่อใดก็ตามที่มีงานเบื้องหลังใหม่ปรากฏสำหรับแชทปัจจุบัน (ทุกงานใหม่จะทริกเกอร์); ปิดเพื่อเปิดเอง', settingsToolsTitle: 'ฉีดเครื่องมือเทอร์มินัลสำหรับโมเดล', settingsToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถสร้างและควบคุมเทอร์มินัลแถบด้านข้างผ่านเครื่องมือ terminal_* 8 ตัว (ปิดเป็นค่าเริ่มต้น)', + settingsFenceTitle: 'การตรวจสอบเส้นทางเวิร์กสเปซ', + settingsFenceDesc: 'เมื่อเปิด ฟีเจอร์ไฟล์ของแถบข้างเข้าถึงได้เฉพาะเส้นทางในเวิร์กสเปซของเซสชัน (ค่าเริ่มต้น) เมื่อปิด จะเข้าถึงไฟล์ใดก็ได้บนโฮสต์ — สคริปต์ในหน้าก็ได้ขอบเขตเดียวกันตลอดช่วงนั้น', + fenceErrorReason: 'เส้นทางนี้อยู่นอกเวิร์กสเปซของเซสชันและถูกบล็อกโดยการตรวจสอบ', + fenceDisableAction: 'ปิดการตรวจสอบเวิร์กสเปซ', settingsBottomTerminalTitle: 'เปิดเทอร์มินัลอัตโนมัติเมื่อพาเนลล่างขยายครั้งแรก', settingsBottomTerminalDesc: 'เมื่อพาเนลล่างถูกขยายครั้งแรกในเซสชัน ลองเปิด tab เทอร์มินัลใหม่ที่นั่น (โควต้าเทอร์มินัลยังคงมีผล; เปิดเป็นค่าเริ่มต้น)', settingsFontFamilyTitle: 'ฟอนต์เทอร์มินัล', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index 2eb308e..efe71ed 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -227,6 +227,10 @@ export const tr: Record = { settingsJobsDesc: 'Geçerli sohbet için yeni bir arka plan işi belirdiğinde yan kartı genişlet ve İşler sayfasını aç (her yeni iş tetikler); elle açmak için kapatın', settingsToolsTitle: 'Model için terminal araçları enjekte et', settingsToolsDesc: 'Etkinleştirildiğinde model, 8 terminal_* aracı üzerinden kenar çubuğu terminalleri oluşturup kullanabilir (varsayılan kapalı)', + settingsFenceTitle: 'Çalışma alanı yol denetimi', + settingsFenceDesc: 'Açıkken kenar çubuğunun dosya işlevleri yalnızca oturum çalışma alanındaki yollara erişir (varsayılan); kapatıldığında ana makinedeki her dosyaya erişilebilir — o süre boyunca sayfadaki betikler de aynı erişimi kazanır', + fenceErrorReason: 'Bu yol oturum çalışma alanının dışında ve denetim tarafından engellendi', + fenceDisableAction: 'Çalışma alanı denetimini kapat', settingsBottomTerminalTitle: 'Alt panelin ilk genişletilmesinde otomatik terminal aç', settingsBottomTerminalDesc: 'Bir oturumda alt panel ilk genişletildiğinde orada yeni bir terminal sekmesi açmayı dene (terminal kotası yine geçerli; varsayılan açık)', settingsFontFamilyTitle: 'Terminal yazı tipi ailesi', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 9b3a910..5aa773c 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -227,6 +227,10 @@ export const vi: Record = { settingsJobsDesc: 'Khi phiên hiện tại có tác vụ nền mới, tự động mở rộng thanh bên và mở trang Tác vụ nền (mỗi tác vụ mới đều kích hoạt); tắt thì phải mở thủ công', settingsToolsTitle: 'Tiêm công cụ terminal cho mô hình', settingsToolsDesc: 'Khi bật, mô hình có thể tạo và điều khiển terminal thanh bên qua 8 công cụ terminal_* (mặc định tắt)', + settingsFenceTitle: 'Kiểm tra đường dẫn workspace', + settingsFenceDesc: 'Khi bật, các tính năng tệp của thanh bên chỉ truy cập đường dẫn trong workspace của phiên (mặc định); khi tắt, mọi tệp trên máy chủ đều có thể truy cập — script trong trang cũng có quyền tương ứng trong thời gian đó', + fenceErrorReason: 'Đường dẫn này nằm ngoài workspace của phiên và đã bị chặn bởi kiểm tra', + fenceDisableAction: 'Tắt kiểm tra workspace', settingsBottomTerminalTitle: 'Tự động mở terminal khi panel dưới mở rộng lần đầu', settingsBottomTerminalDesc: 'Khi panel dưới được mở rộng lần đầu trong phiên, thử mở một tab terminal mới ở panel dưới (giới hạn terminal vẫn áp dụng; mặc định bật)', settingsFontFamilyTitle: 'Font terminal', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index f6fe0f5..0dd4288 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -242,6 +242,10 @@ export const zhHK: Record = { settingsJobsDesc: '目前工作階段出現新的背景任務時,自動展開側邊欄並開啟背景任務頁(每個新任務都會觸發);關閉後需手動開啟', settingsToolsTitle: '為模型注入終端工具', settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)', + settingsFenceTitle: '工作區路徑檢測', + settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力', + fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截', + fenceDisableAction: '關閉工作區檢測', settingsBottomTerminalTitle: '底部面板首次展開自動開終端', settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)', settingsFontFamilyTitle: '終端字體', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 853edb1..224103d 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -242,6 +242,10 @@ export const zhMO: Record = { settingsJobsDesc: '目前工作階段出現新的背景任務時,自動展開側邊欄並開啟背景任務頁(每個新任務都會觸發);關閉後需手動開啟', settingsToolsTitle: '為模型注入終端工具', settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)', + settingsFenceTitle: '工作區路徑檢測', + settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力', + fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截', + fenceDisableAction: '關閉工作區檢測', settingsBottomTerminalTitle: '底部面板首次展開自動開終端', settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)', settingsFontFamilyTitle: '終端字體', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 6639c9d..1a3247b 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -242,6 +242,10 @@ export const zhTW: Record = { settingsJobsDesc: '目前工作階段出現新的背景任務時,自動展開側邊欄並開啟背景任務頁(每個新任務都會觸發);關閉後需手動開啟', settingsToolsTitle: '為模型注入終端工具', settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)', + settingsFenceTitle: '工作區路徑檢測', + settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力', + fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截', + fenceDisableAction: '關閉工作區檢測', settingsBottomTerminalTitle: '底部面板首次展開自動開終端', settingsBottomTerminalDesc: '每次工作階段中第一次展開底部面板時,嘗試在底部面板自動開啟一個新終端標籤(終端數量上限仍會限制;預設開啟)', settingsFontFamilyTitle: '終端字體', diff --git a/src/client/locales.ts b/src/client/locales.ts index da0f5da..55cb846 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -234,6 +234,10 @@ export const zh = { settingsJobsDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开后台任务页(每个新任务都会触发);关闭后需手动打开', settingsToolsTitle: '为模型注入终端工具', settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', + settingsFenceTitle: '工作区路径检测', + settingsFenceDesc: '开启后,侧栏的文件功能仅能访问会话工作区内的路径(默认);关闭后可访问主机上任意文件——关闭期间页面内脚本也将获得同等访问能力', + fenceErrorReason: '此路径在会话工作区之外,已被工作区检测拦截', + fenceDisableAction: '关闭工作区检测', settingsBottomTerminalTitle: '底部面板首次展开自动开终端', settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)', settingsFontFamilyTitle: '终端字体', @@ -598,6 +602,10 @@ export const en: Record = { settingsJobsDesc: 'Expand the side card and open the Jobs page whenever a new background job appears for the current conversation (every new job triggers); turn off to open it manually', settingsToolsTitle: 'Inject terminal tools for the model', settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)', + settingsFenceTitle: 'Workspace path fence', + settingsFenceDesc: 'On, the sidebar\'s file features only reach paths inside the session workspace (default); off, any file on the host is reachable — page scripts gain the same reach while it is off', + fenceErrorReason: 'This path is outside the session workspace and was blocked by the workspace fence', + fenceDisableAction: 'Turn off the workspace fence', settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion', settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)', settingsFontFamilyTitle: 'Terminal font family', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index a855109..4c9335e 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -82,6 +82,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { editorExplorer: typeof record.editorExplorer === 'boolean' ? record.editorExplorer : SIDEBAR_PREFS_DEFAULTS.editorExplorer, + workspaceFence: typeof record.workspaceFence === 'boolean' + ? record.workspaceFence + : SIDEBAR_PREFS_DEFAULTS.workspaceFence, // The title-bar scheme (auto | web | preset | custom). The schema // declares the field WITHOUT a default, so documents written by older // plugin versions resolve without it — migrate from the legacy fields: diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 9d06be8..f3b74e3 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -1493,6 +1493,18 @@ body[data-dsh-sidebar-dragging] .bottomPanel { color: var(--dsw-alias-state-error-primary); } +/* The workspace-fence refusal row (FenceErrorNotice): reason text plus the + * one-click global-off button share one baseline, wrapping when narrow. */ +.fenceError { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 8px 16px; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-state-error-primary); +} + .editorBanner { flex: none; padding: 4px 8px; diff --git a/src/config.ts b/src/config.ts index b28b2a7..18c8a30 100644 --- a/src/config.ts +++ b/src/config.ts @@ -128,6 +128,7 @@ export const PrefsSchema: z = z.object({ terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT), interceptOpenPath: z.boolean().default(true), editorExplorer: z.boolean().default(false), + workspaceFence: z.boolean().default(true), terminalShell: z.string().default(''), terminalShellArgs: z.string().default(''), titleBarScheme: z.union([z.const('auto'), z.const('web'), z.const('preset'), z.const('custom')]), diff --git a/src/fs-operations.ts b/src/fs-operations.ts index c867a09..919fe8a 100644 --- a/src/fs-operations.ts +++ b/src/fs-operations.ts @@ -32,6 +32,8 @@ export interface WorkspaceUploadInput { chunks: AsyncIterable /** Byte cap; an oversized upload is refused without touching the target. */ limit: number + /** Whether workspace containment is enforced (the `workspaceFence` setting; on by default). */ + fence?: boolean } /** @@ -46,9 +48,9 @@ export interface WorkspaceUploadInput { * failures; the temp file is always removed on failure. */ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise<{ path: string; size: number }> { - const { cwd, dir, relativePath, chunks, limit } = input + const { cwd, dir, relativePath, chunks, limit, fence = true } = input const base = requireAbsolute(dir) - await ensureWorkspacePath(cwd, base) + await ensureWorkspacePath(cwd, base, fence) if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) { throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400) } @@ -57,7 +59,7 @@ export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400) } const target = join(base, ...segments) - const safeTarget = await ensureWorkspaceWritePath(cwd, target) + const safeTarget = await ensureWorkspaceWritePath(cwd, target, fence) const tmp = join(dirname(safeTarget), `.${basename(safeTarget)}.dsh-upload-${randomUUID()}.tmp`) await mkdir(dirname(safeTarget), { recursive: true }) const stream = createWriteStream(tmp, { flags: 'wx' }) diff --git a/src/index.ts b/src/index.ts index 46c9481..cf1a739 100644 --- a/src/index.ts +++ b/src/index.ts @@ -257,6 +257,19 @@ function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { } } +/** + * Whether the workspace fence is armed for the sidebar's filesystem routes + * (the settings-page `workspaceFence` switch under the files card's gear). + * An absent settings service or a missing field keeps the fence ON — the + * containment default never depends on the settings surface being reachable. + */ +function fenceEnabledOf(getSettings: () => SidebarSettingsFace | undefined): boolean { + const settings = getSettings() + const value = settings?.get().value + if (value === null || typeof value !== 'object') return true + return (value as Record).workspaceFence !== false +} + /** * Parse the browser tab's `browserAllowedLoopback` allowlist into a matcher * over host:port (same contract as the client-side helper in @@ -317,7 +330,7 @@ function buildApi( 'fs.tree': async (payload) => { const { cwd } = await cwdOf(payload) const record = payload as { path?: unknown } - const target = record.path === undefined ? cwd : await ensureWorkspacePath(cwd, requireString(payload, 'path')) + const target = record.path === undefined ? cwd : await ensureWorkspacePath(cwd, requireString(payload, 'path'), fenceEnabledOf(getSettings)) return listDirectory(target, resolved.listLimit) }, 'fs.search': async (payload) => { @@ -335,14 +348,14 @@ function buildApi( // child-repo path is relative to the selected repoRoot, not the session // cwd; thread it so the path resolves inside the authorized workspace. const selected = selectedRepoOf(payload) - const path = await ensureWorkspacePath(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), selected)) + const path = await ensureWorkspacePath(cwd, await resolveGitPath(cwd, requireString(payload, 'path'), selected), fenceEnabledOf(getSettings)) const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit) if (binary) return { kind: 'binary', size, truncated, head } return { kind: 'text', content, truncated } }, 'fs.write': async (payload) => { const { cwd } = await cwdOf(payload) - const path = await ensureWorkspaceWritePath(cwd, requireString(payload, 'path')) + const path = await ensureWorkspaceWritePath(cwd, requireString(payload, 'path'), fenceEnabledOf(getSettings)) const content = requireString(payload, 'content') const tmp = `${path}.dsh-sidebar-tmp-${process.pid}` try { @@ -838,6 +851,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { relativePath, chunks: req, limit: resolved.uploadLimit, + fence: fenceEnabledOf(() => settingsFace), }) writeOk(res, { path, size }) } catch (error) { @@ -873,7 +887,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { const raw = url.searchParams.get('path') if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required') const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined) - const path = await ensureWorkspacePath(cwd, raw) + const path = await ensureWorkspacePath(cwd, raw, fenceEnabledOf(() => settingsFace)) const info = await stat(path) if (!info.isFile() || info.size > resolved.mediaLimit) { throw new SidebarError('fs-error', 'not a file or too large', 400) @@ -932,7 +946,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void { // real-path guard, with the same semantics as the media route's // fallback. const cwd = await sessionCwdOf(ctx, sessionId) - const absolute = await ensureWorkspacePath(cwd, path) + const absolute = await ensureWorkspacePath(cwd, path, fenceEnabledOf(() => settingsFace)) const info = await stat(absolute) if (!info.isFile() || info.size > resolved.mediaLimit) { throw new SidebarError('fs-error', 'not a file or too large', 400) diff --git a/src/path-security.ts b/src/path-security.ts index 0edbcef..a1556d3 100644 --- a/src/path-security.ts +++ b/src/path-security.ts @@ -21,19 +21,23 @@ function assertWithinWorkspace(workspace: string, target: string): void { } /** - * Resolve an existing workspace path through symlinks and enforce containment. + * Resolve an existing workspace path through symlinks and (unless disarmed) + * enforce containment. * * @param cwd - Session workspace directory. * @param target - Client-supplied absolute path. + * @param fence - Whether containment is enforced (the settings-page + * `workspaceFence` switch). Even when false the paths are still resolved + * through symlinks so callers always receive the canonical target. * @returns The canonical absolute path used for the filesystem operation. */ -export async function ensureWorkspacePath(cwd: string, target: string): Promise { +export async function ensureWorkspacePath(cwd: string, target: string, fence = true): Promise { const absolute = requireAbsolute(target) const [realCwd, realTarget] = await Promise.all([ resolveRealPath(cwd, 'workspace'), resolveRealPath(absolute, 'target'), ]) - assertWithinWorkspace(realCwd, realTarget) + if (fence) assertWithinWorkspace(realCwd, realTarget) return realTarget } @@ -46,9 +50,11 @@ export async function ensureWorkspacePath(cwd: string, target: string): Promise< * * @param cwd - Session workspace directory. * @param target - Client-supplied absolute destination path. + * @param fence - Whether containment is enforced (the settings-page + * `workspaceFence` switch). Resolution/canonicalization is identical either way. * @returns A canonical path for an existing target or its nearest existing ancestor. */ -export async function ensureWorkspaceWritePath(cwd: string, target: string): Promise { +export async function ensureWorkspaceWritePath(cwd: string, target: string, fence = true): Promise { const absolute = requireAbsolute(target) const realCwd = await resolveRealPath(cwd, 'workspace') let existingPath = absolute @@ -57,7 +63,7 @@ export async function ensureWorkspaceWritePath(cwd: string, target: string): Pro for (;;) { try { const realTarget = await realpath(existingPath) - assertWithinWorkspace(realCwd, realTarget) + if (fence) assertWithinWorkspace(realCwd, realTarget) return missingSegments.reduce((path, segment) => join(path, segment), realTarget) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 0b44084..2f7f5be 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -77,6 +77,20 @@ export interface SidebarPrefs { * Side card settings; off restores the pre-merge editor exactly. */ editorExplorer: boolean + /** + * Whether the sidebar's filesystem routes enforce the workspace fence: + * every client-supplied path must resolve (through symlinks) inside the + * session workspace, else the route answers 403 "outside workspace". On + * by default; turning it OFF lets the file tree / editor read+write / + * media / HTML preview / upload routes reach ANY host path (e.g. the + * global ~/.dsh/AGENTS.md or a linked worktree outside the session cwd) + * — the trade-off being that any same-origin script (including + * third-party consumer plugins) can read/write outside the workspace + * through those routes while it is off. The switch lives under the files + * tab's gear in the Side card settings; the fence error surfaces offer a + * one-click global off + retry. + */ + workspaceFence: boolean /** * The shell the UI and agent terminals spawn (absolute path or bare * executable name). Empty (default) keeps the legacy resolution order: @@ -253,6 +267,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, interceptOpenPath: true, editorExplorer: false, + workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 8eaac40..a0868b9 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -86,7 +86,7 @@ describe('built-in tab registrations', () => { it('the editor tab declares its merged-mode (embedded file tree) setting', () => { const { service } = setup() const toggles = service.getTab('editor')?.settings?.toggles ?? [] - expect(toggles.map(t => t.key)).toEqual(['editorExplorer']) + expect(toggles.map(t => t.key)).toEqual(['editorExplorer', 'workspaceFence']) expect(toggles[0]?.title).toBeDefined() expect(toggles[0]?.desc).toBeDefined() // The merged mode is an iconed select (merged vs separate), not a switch. @@ -94,6 +94,9 @@ describe('built-in tab registrations', () => { const options = toggles[0]?.options ?? [] expect(options.map(o => o.value)).toEqual([true, false]) expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true) + // The workspace fence switch rides the same card as a plain boolean row. + expect(toggles[1]?.title).toBeDefined() + expect(toggles[1]?.desc).toBeDefined() // The open-with configuration (SSH host + custom editors) is the custom // panel rendered below the declarative rows. expect(service.getTab('editor')?.settings?.render).toBeDefined() diff --git a/tests/file-tree-drop.spec.tsx b/tests/file-tree-drop.spec.tsx index f6d496c..cfb414a 100644 --- a/tests/file-tree-drop.spec.tsx +++ b/tests/file-tree-drop.spec.tsx @@ -14,6 +14,7 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' +import { createSidebarStore } from '../src/client/state.ts' import { TAB_DRAG_TYPE } from '../src/client/TabBar.tsx' import type { UploadItem } from '../src/client/upload.ts' @@ -55,6 +56,7 @@ async function mountTree(busy = false): Promise { root.render(createElement(FileTree, { sessionId: 's1', cwd: '/tmp', + store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, diff --git a/tests/file-tree-open-with.spec.tsx b/tests/file-tree-open-with.spec.tsx index 774542d..50f8be7 100644 --- a/tests/file-tree-open-with.spec.tsx +++ b/tests/file-tree-open-with.spec.tsx @@ -11,6 +11,7 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' +import { createSidebarStore } from '../src/client/state.ts' import type { OpenWithTarget } from '../src/client/open-with.ts' // The act() environment flag (React 18.2 reads it before flushing effects). @@ -64,6 +65,7 @@ async function mountTree(overrides: { root.render(createElement(FileTree, { sessionId: 's1', cwd: '/tmp', + store: createSidebarStore(), expanded: [], revealed: [], onToggle: () => {}, diff --git a/tests/file-tree-reveal-scroll.spec.tsx b/tests/file-tree-reveal-scroll.spec.tsx index 4f9377f..8ec025f 100644 --- a/tests/file-tree-reveal-scroll.spec.tsx +++ b/tests/file-tree-reveal-scroll.spec.tsx @@ -14,6 +14,7 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' +import { createSidebarStore } from '../src/client/state.ts' ;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true @@ -54,6 +55,7 @@ async function mountTree(bodyScroll: { top: number; client: number; height: numb root.render(createElement(FileTree, { sessionId: 's1', cwd: '/tmp', + store: createSidebarStore(), expanded: [], revealed, onToggle: () => {}, diff --git a/tests/git-view-worktree.spec.tsx b/tests/git-view-worktree.spec.tsx index 7685b73..e4b6e2a 100644 --- a/tests/git-view-worktree.spec.tsx +++ b/tests/git-view-worktree.spec.tsx @@ -10,6 +10,7 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { GitView } from '../src/client/GitView.tsx' +import { createSidebarStore } from '../src/client/state.ts' import { api, type GitLogEntry, type GitStatusResult, type GitWorktree } from '../src/client/api.ts' ;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true @@ -72,6 +73,7 @@ describe('GitView linked-worktree consistency', () => { await act(async () => { root.render(createElement(GitView, { scope: { sessionId: 'session', cwd: MAIN }, + store: createSidebarStore(), onOpenFile: () => {}, onOpenDiff: () => {}, visible: false, @@ -129,6 +131,7 @@ describe('GitView linked-worktree consistency', () => { await act(async () => { root.render(createElement(GitView, { scope: { sessionId: 'session', cwd: MAIN }, + store: createSidebarStore(), onOpenFile: () => {}, onOpenDiff: () => {}, visible: false, diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 81c1fb0..9bc0e90 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -88,12 +88,15 @@ describe('dsh-better-sidebar plugin export shape', () => { // The separate file-window mode is the default (each file opens its own // tab; the merged editor-explorer is opt-in). expect(resolved.editorExplorer).toBe(false) + // The workspace fence (containment over the sidebar fs routes) defaults + // ON — the safe default never depends on the stored document. + expect(resolved.workspaceFence).toBe(true) // A stored overridden value resolves through (the range contract is // enforced by the settings service on write); the new pref keeps its // default when the stored document predates it. const overridden = (PrefsSchema as unknown as { (input: Record | undefined): Record })({ openByDefault: false, defaultWidthPercent: 45 }) - expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) }) }) diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index 4226128..a80d643 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -43,6 +43,7 @@ describe('side card preferences', () => { terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, + workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', @@ -76,6 +77,7 @@ describe('side card preferences', () => { terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, + workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', @@ -109,6 +111,7 @@ describe('side card preferences', () => { terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, + workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', @@ -170,6 +173,15 @@ describe('side card preferences', () => { expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true) }) + it('defaults workspaceFence to true; only an explicit false disarms the containment guard', async () => { + // Absent or malformed → on (the fs routes keep refusing outside paths). + expect((await loadPrefs(wire({}))).workspaceFence).toBe(true) + expect((await loadPrefs(wire({ workspaceFence: 'no' }))).workspaceFence).toBe(true) + expect((await loadPrefs(wire({ workspaceFence: 0 }))).workspaceFence).toBe(true) + // An explicit false survives (the one-click off in the fence error notice). + expect((await loadPrefs(wire({ workspaceFence: false }))).workspaceFence).toBe(false) + }) + it('defaults the title-bar scheme to the conservative auto with no preset or custom CSS', async () => { // Absent or malformed → auto (plain web keeps the untouched layout). expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto') @@ -270,9 +282,9 @@ describe('side card preferences', () => { const store = createSidebarStore() // Node environment: no window → the width falls back to PANEL_DEFAULT, // while the open flag still follows the preference. - store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) store.setSession('fresh-session') - expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) const snapshot = store.getSnapshot() expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.state?.panelOpen).toBe(false) @@ -308,7 +320,7 @@ describe('side card preferences', () => { it('skips the default seed tab when the editor (files window) type is disabled', () => { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} }) store.setSession('no-editor') const state = store.getSnapshot().state! const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) @@ -318,7 +330,7 @@ describe('side card preferences', () => { // editorExplorer modes. for (const editorExplorer of [true, false]) { const openStore = createSidebarStore() - openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) openStore.setSession(`with-editor-${editorExplorer}`) const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(openTabs.map(tab => tab.type)).toEqual(['editor']) @@ -328,7 +340,7 @@ describe('side card preferences', () => { it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => { for (const editorExplorer of [true, false]) { const store = createSidebarStore() - store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) + store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} }) store.setSession(`fresh-${editorExplorer}`) const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) expect(tabs).toHaveLength(1) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index ad2f267..da8c143 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -943,6 +943,7 @@ describe('side card settings routes', () => { terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, + workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, @@ -972,6 +973,34 @@ describe('side card settings routes', () => { expect(view.revision).toBe(1) }) + it('disarms the workspace fence for the fs routes when the pref is off', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fence-off-')) + const workspace = join(root, 'workspace') + const outside = join(root, 'outside') + mkdirSync(workspace) + mkdirSync(outside) + writeFileSync(join(outside, 'secret.txt'), 'global instructions') + try { + const route = mountWithSettings(createFakeSettings()) + // Default (fence on): the outside read is refused as usual… + const refused = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') }) + expect(refused).toMatchObject({ ok: false, error: { code: 'forbidden' } }) + // …then the settings-page switch (or the fence notice's one-click off) + // disarms every fs route for paths outside the workspace. + const off = await invoke(route, 'settings.update', { patch: { workspaceFence: false } }) + expect(off.ok).toBe(true) + const read = await invoke(route, 'fs.read', { sessionId: 'fence', cwd: workspace, path: join(outside, 'secret.txt') }) + expect(read).toMatchObject({ ok: true, value: { kind: 'text', content: 'global instructions' } }) + const tree = await invoke(route, 'fs.tree', { sessionId: 'fence', cwd: workspace, path: outside }) + expect(tree).toMatchObject({ ok: true }) + const write = await invoke(route, 'fs.write', { sessionId: 'fence', cwd: workspace, path: join(outside, 'written.txt'), content: 'ok' }) + expect(write).toMatchObject({ ok: true }) + expect(readFileSync(join(outside, 'written.txt'), 'utf8')).toBe('ok') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('refuses a stale write with settings-conflict (409)', async () => { const route = mountWithSettings(createFakeSettings()) await invoke(route, 'settings.update', { patch: { openByDefault: false } })