diff --git a/.changeset/fn-7264-absolute-file-browser-paths.md b/.changeset/fn-7264-absolute-file-browser-paths.md new file mode 100644 index 0000000000..216d34b1e4 --- /dev/null +++ b/.changeset/fn-7264-absolute-file-browser-paths.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a project setting for absolute workspace file-browser paths. +category: feature +dev: Adds allowAbsoluteFileBrowserPaths for workspace file-browser routes while keeping the default confined. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index b6fa7748df..9fc007ad54 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -351,6 +351,8 @@ show a short redirect stub linking to the workflow editor (for one release). Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`. +Security-sensitive file-browser escape hatches are project-only. `allowAbsoluteFileBrowserPaths` is intentionally absent from global settings so one project's local-admin browsing policy cannot silently widen another project's workspace boundary. + > **Moved keys retained for reference.** Some rows below — the step-execution, > review/approval, and per-phase model-lane keys listed under > [Where did my setting go?](#where-did-my-setting-go) — are no longer project @@ -386,6 +388,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS` | `pluginTrustPolicy` | `"off" | "warn" | "enforce"` | `"warn"` | Plugin provenance enforcement mode: `off` records verification metadata only, `warn` blocks only `invalid` signatures, `enforce` allows only `verified-trusted` or `trusted-local`. | | `ignoreHiddenOverlapPaths` | `boolean` | `true` | Exclude hidden dot paths from overlap serialization by default. A hidden path is any normalized project-relative path with a segment beginning with `.`, such as `.fusion/tasks/FN-1/PROMPT.md`, `.changeset/fix.md`, `.github/workflows/ci.yml`, `.env`, or `packages/.cache/out.js`. Set to `false` to restore legacy strict counting of dot paths. Explicit `overlapIgnorePaths` entries still apply in addition to this default filter, and still apply when hidden-path filtering is disabled. | | `overlapIgnorePaths` | `string[]` | `[]` | Optional project-relative file or directory paths to exclude from overlap blocking (for example `docs` or `generated/openapi.json`). Entries are trimmed, deduplicated, and must not be absolute or contain `..` traversal. | +| `allowAbsoluteFileBrowserPaths` | `boolean` | `false` | Project-scoped Settings → General toggle for the workspace file browser. When enabled, slash-prefixed paths such as `/tmp` can be listed/read/written/downloaded through workspace file-browser routes while keeping existing file-size, binary, type, null-byte, traversal, and permission checks. Windows drive-letter paths remain blocked, and task-local file routes, memory APIs, worktree-copy validation, plugin bundle paths, and other validators are unchanged. | | `autoMerge` | `boolean` | `true` | Auto-finalize tasks from `in-review`. Tasks can override this per-task (including at create time in New Task modal via **Auto-merge** = Default/Enabled/Disabled); explicit overrides are tagged with `autoMergeProvenance: "user"`, while tasks left at **Default** keep following the live global setting and do not snapshot it when entering review. Legacy pre-FN-6245 in-review rows that were stamped `autoMerge: true` are marked `autoMergeProvenance: "legacy-stamp"` on startup and can be inspected/cleared with Settings → Merge → **Legacy auto-merge stamp cleanup**, `fn pr automerge-cleanup [--apply] [--json]`, or `reconcileLegacyAutoMergeStamps({ apply: true })` after operator review. For grouped branch flows, per-task `autoMerge` governs member→group-integration landing while group `autoMerge` governs group→default-branch promotion eligibility. | | `planApprovalMode` | `"workflow" \| "auto-approve-all" \| "require-all"` | `"workflow"` | Project-scoped override for the planning approval gate. `"workflow"` preserves the workflow-resolved `requirePlanApproval`; `"auto-approve-all"` moves every specified task to todo without manual approval; `"require-all"` parks every specified task at `status: "awaiting-approval"` regardless of workflow settings. | | `maxAutoMergeRetries` | `number` | `3` | Project-scoped positive-integer cap for auto-merge conflict-resolution retries before Fusion parks or bounces a task for human/recovery handling. Unset, non-finite, zero, or negative values fall back to `3` to preserve historical behavior. | diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 9386d6ae0b..9faee3587a 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -98,6 +98,14 @@ describe("settings key parity", () => { expect(PROJECT_SETTINGS_KEYS).toContain("ignoreHiddenOverlapPaths"); }); + it("defaults absolute file-browser paths off and keeps it project-scoped", () => { + expect(DEFAULT_PROJECT_SETTINGS.allowAbsoluteFileBrowserPaths).toBe(false); + expect(isProjectSettingsKey("allowAbsoluteFileBrowserPaths")).toBe(true); + expect(isGlobalSettingsKey("allowAbsoluteFileBrowserPaths")).toBe(false); + expect(PROJECT_SETTINGS_KEYS).toContain("allowAbsoluteFileBrowserPaths"); + expect(GLOBAL_SETTINGS_KEYS).not.toContain("allowAbsoluteFileBrowserPaths"); + }); + it("defaults autoClaimCandidatesInPrompt to 5 and keeps it project-scoped", () => { expect(DEFAULT_PROJECT_SETTINGS.autoClaimCandidatesInPrompt).toBe(5); expect(isProjectSettingsKey("autoClaimCandidatesInPrompt")).toBe(true); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 8a5c53d4af..7d24e41f1a 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -283,6 +283,11 @@ export const DEFAULT_PROJECT_SETTINGS = { groupOverlappingFiles: true, ignoreHiddenOverlapPaths: true, overlapIgnorePaths: [], + /* + FNXC:FileBrowser 2026-06-29-00:00: + Absolute file-browser paths are disabled unless a project explicitly opts in, preserving the workspace boundary for normal installs while allowing local admin browsing through the same file-size, binary, type, and permission checks. + */ + allowAbsoluteFileBrowserPaths: false, autoMerge: true, planApprovalMode: "workflow", // U18 (R15): the Review-response loop is default-on. Independent of `autoMerge` — diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 19ecca6d56..6ddcd6c0dc 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3562,6 +3562,11 @@ export interface ProjectSettings { * When set, matching paths are excluded from overlap checks for both * active in-progress tasks and in-review tasks with unmerged worktrees. */ overlapIgnorePaths?: string[]; + /** + * FNXC:FileBrowser 2026-06-29-00:00: + * Project owners can opt the workspace file browser into slash-prefixed absolute paths for local admin workflows. Default false keeps browsing confined to the selected project/task workspace; this does not apply to task-local file APIs, memory, plugin bundles, worktree-copy validation, or Windows drive-letter paths. + */ + allowAbsoluteFileBrowserPaths?: boolean; autoMerge: boolean; /** When true, force every AI lane onto the deterministic mock provider regardless * of per-task or per-lane overrides. No network calls, zero token cost. */ diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx index fd9cdd8df7..9409029b26 100644 --- a/packages/dashboard/app/__tests__/settings-sections.test.tsx +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -14,6 +14,7 @@ import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import * as jestDomMatchers from "@testing-library/jest-dom/matchers"; import { AppearanceSection } from "../components/settings/sections/AppearanceSection"; +import { GeneralSection } from "../components/settings/sections/GeneralSection"; import { NotificationsSection } from "../components/settings/sections/NotificationsSection"; import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection"; import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub"; @@ -29,6 +30,17 @@ vi.mock("../components/AgentPromptsManager", () => ({ vi.mock("../components/SecretsView", () => ({ SecretsView: () =>
, })); +vi.mock("../api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWorkflows: vi.fn(async () => []), + fetchWorkflow: vi.fn(async () => ({ id: "builtin:coding", name: "Coding" })), + fetchProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })), + setProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })), + fetchGlobalSettings: vi.fn(async () => ({})), + }; +}); expect.extend(jestDomMatchers); afterEach(() => cleanup()); @@ -65,6 +77,35 @@ describe("AppearanceSection", () => { }); }); +describe("GeneralSection", () => { + it("emits the absolute file-browser path toggle via setForm", () => { + function GeneralHost() { + const [form, setForm] = useState({ allowAbsoluteFileBrowserPaths: false } as SettingsFormState); + return ( + + ); + } + + render(); + + const checkbox = screen.getByLabelText(/Allow absolute file-browser paths/i) as HTMLInputElement; + expect(checkbox.checked).toBe(false); + fireEvent.click(checkbox); + + expect(checkbox.checked).toBe(true); + }); +}); + describe("NotificationsSection", () => { it("emits the chosen failure-notification mode via setForm", () => { const setForm = vi.fn(); diff --git a/packages/dashboard/app/components/FileBrowser.tsx b/packages/dashboard/app/components/FileBrowser.tsx index 6a18d67614..b67323d141 100644 --- a/packages/dashboard/app/components/FileBrowser.tsx +++ b/packages/dashboard/app/components/FileBrowser.tsx @@ -39,9 +39,29 @@ function formatTime(mtime?: string): string { /** Build the full relative path for a file/directory entry */ function entryPath(currentPath: string, name: string): string { + if (currentPath.startsWith("/")) { + const base = currentPath === "/" ? "" : currentPath.replace(/\/+$/g, ""); + return `${base}/${name}`; + } return joinDisplayPath(currentPath, name); } +/* +FNXC:FileBrowser 2026-06-29-00:00: +When the project opts into absolute slash-prefixed browsing, Up navigation must preserve filesystem-root semantics instead of collapsing `/etc` to the workspace-relative root marker. +*/ +function parentPath(currentPath: string): string { + if (currentPath === "/") { + return "/"; + } + if (currentPath.startsWith("/")) { + const trimmed = currentPath.replace(/\/+$/g, ""); + const index = trimmed.lastIndexOf("/"); + return index <= 0 ? "/" : trimmed.slice(0, index); + } + return getParentDisplayPath(currentPath); +} + // ── Context Menu State ────────────────────────────────────────────────── interface ContextMenuState { @@ -537,13 +557,13 @@ export function FileBrowser({ await deleteFile(workspace, dialog.entryFullPath, projectId); break; case "create-file": { - const newFilePath = joinDisplayPath(dialog.entryFullPath, value); + const newFilePath = entryPath(dialog.entryFullPath, value); await createWorkspaceFile(workspace, newFilePath, projectId); onSelectFile(newFilePath); break; } case "create-folder": - await createWorkspaceDirectory(workspace, joinDisplayPath(dialog.entryFullPath, value), projectId); + await createWorkspaceDirectory(workspace, entryPath(dialog.entryFullPath, value), projectId); break; } @@ -605,7 +625,7 @@ export function FileBrowser({
+ {/* + FNXC:FileBrowser 2026-06-29-00:00: + This project-scoped General toggle is intentionally default-off because slash-prefixed file-browser paths can browse outside the workspace. It only affects workspace file-browser routes and keeps task-local file APIs and other path validators confined. + */} +
+ + {t("settings.general.allowAbsoluteFileBrowserPathsHint", "When enabled, slash-prefixed paths such as /tmp can be opened in the workspace file browser. Windows drive-letter paths remain blocked, and other path validators are unchanged.")} +