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

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