feat: workspace fence toggle (workspaceFence pref + one-click off in error surfaces)
- New persisted built-in pref workspaceFence (default true) under the files card's secondary settings modal; host reads it per request via the settings seam (fenceEnabledOf — absent/malformed stays ON). - ensureWorkspacePath/ensureWorkspaceWritePath gain a fence param: disarmed skips the containment assert but keeps realpath resolution; wired through fs.tree/fs.read/fs.write, the media + html routes, and the upload path (writeWorkspaceUpload fence input). - Editor load failures and file-tree level errors show the localized reason plus a one-click global off (FenceErrorNotice) that flips the pref, adopts it into the store, and auto-retries the failed operation (reloadSeq / retryDir) — the raw wire text is never shown. - GitView's two client-side pre-checks (hidden open action + no-op defense) respect the pref; store threaded through TreePanel/FileTree. - i18n: 4 new keys across all 21 dictionaries; tests: fence-off route coverage, prefs parsing, schema default, editor toggles list; docs: guide §8/§8.1 + plans/2026-08-29 design record.
This commit is contained in:
+19
-5
@@ -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<string, unknown>).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)
|
||||
|
||||
Reference in New Issue
Block a user