diff --git a/.changeset/fn-6943-worktree-copy-files.md b/.changeset/fn-6943-worktree-copy-files.md new file mode 100644 index 0000000000..3cc70ddd3f --- /dev/null +++ b/.changeset/fn-6943-worktree-copy-files.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Add a Worktrees setting for copying repository files into new task worktrees. +category: feature +dev: Adds project setting `worktreeCopyFiles`; release with the standard changeset workflow, not manual versioning. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 3bc881dd87..39d2a91006 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -277,6 +277,10 @@ Saved API keys are stored in settings but are masked in API responses and UI-loa For the stored settings shape, see [`customProviders` in the Settings Reference](./settings-reference.md#customproviders). For the API behavior, including masked keys in responses, see [Architecture → Custom Provider endpoints](./architecture.md#custom-provider-endpoints). +## Worktree copy files + +Open **Settings → Worktrees** to maintain **Files to copy into new worktrees**. Add editable rows for repository-root-relative files such as `.env`, use **Browse** to select a project file, remove rows you no longer want, then Save. Fusion trims blank rows and de-duplicates paths before persisting. During task startup, configured regular files are copied into fresh or pooled task worktrees before the worktree init command and task execution begin; existing/resumed worktrees are not overwritten. Missing files, directories, absolute paths, traversal entries, and unreadable sources are skipped with non-fatal diagnostics and without logging file contents. See [`worktreeCopyFiles` in the Settings Reference](./settings-reference.md#project-settings) for the stored setting shape. + ## Planning Mode Planning is a desktop/tablet left-sidebar main-content destination after **Command Center**. It opens the planning-session list and composer in the main content region; mobile continues to use the compact planning entry points. Planning Mode now includes branch controls on the summary screen before you create a task. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 28f96089b7..e33d26d115 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -397,6 +397,7 @@ Sandbox backend precedence is: | `pushAfterMerge` | `boolean` | `false` | Auto-push to remote after successful direct merge. Includes pulling latest and AI conflict resolution. | | `pushRemote` | `string` | `"origin"` | Git remote (and optional branch) to push to after merge. | | `worktreeInitCommand` | `string` | `undefined` | Shell command run after task worktree creation and in temporary merge worktrees before merge/review verification. In standalone AI merge, this runs inside each fresh `fusion-ai-merge-*` clean-room worktree after `git worktree add`; when unset, Fusion infers a package-manager install from the lockfile and may skip only when the install marker matches. Useful for project-specific setup beyond package install (for example `pnpm install --frozen-lockfile`, `cp .env.local .env`, or codegen/bootstrap scripts). | +| `worktreeCopyFiles` | `string[]` | `[]` | Repository-root-relative regular files to copy into each newly assigned non-resume task worktree. Configure from Settings → Worktrees with editable rows or Browse (useful for `.env`-style files). Fusion copies these files after fresh creation or pooled-worktree preparation and before `worktreeInitCommand`, secrets-env materialization, and task execution. Blank/duplicate entries are ignored; absolute paths, `..` traversal, missing files, directories, and unreadable/non-regular sources are skipped as non-fatal task-log/audit diagnostics without logging file contents. Resume/existing worktrees are not overwritten. | | `testCommand` | `string` | `undefined` | Merge-time test command (hard gate). When unset, Fusion auto-detects from lockfile. | | `buildCommand` | `string` | `undefined` | Merge-time build command (hard gate). | | `recycleWorktrees` | `boolean` | `false` | Default: off (opt-in). Reuse worktrees from a pool for faster startup. | diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts index 4464dce2c7..b4090ab48f 100644 --- a/packages/core/src/__tests__/store-settings.test.ts +++ b/packages/core/src/__tests__/store-settings.test.ts @@ -40,6 +40,30 @@ describe("TaskStore", () => { }); }); + describe("worktreeCopyFiles setting", () => { + it("round-trips populated copy-file paths via getSettings and project serialization", async () => { + await harness.store().updateSettings({ worktreeCopyFiles: [".env", "config/local.env", "packages/api/.env.test"] }); + + const settings = await harness.store().getSettings(); + expect(settings.worktreeCopyFiles).toEqual([".env", "config/local.env", "packages/api/.env.test"]); + + const configRaw = await readFile(join(harness.rootDir(), ".fusion", "config.json"), "utf-8"); + const config = JSON.parse(configRaw); + expect(config.settings.worktreeCopyFiles).toEqual([".env", "config/local.env", "packages/api/.env.test"]); + }); + + it("persists cleared copy-file paths without dropping the project key", async () => { + await harness.store().updateSettings({ worktreeCopyFiles: [".env"] }); + await harness.store().updateSettings({ worktreeCopyFiles: [] }); + + const settings = await harness.store().getSettings(); + expect(settings.worktreeCopyFiles).toEqual([]); + + const { project } = await harness.store().getSettingsByScope(); + expect(project.worktreeCopyFiles).toEqual([]); + }); + }); + describe("worktreesDir setting", () => { it("round-trips worktreesDir via updateSettings and project serialization", async () => { await harness.store().updateSettings({ worktreesDir: "~/.fn-worktrees/{repo}" }); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index a296a22bdd..adb2ecb701 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -295,6 +295,11 @@ export const DEFAULT_PROJECT_SETTINGS = { defaultNodeId: undefined, secretsEnv: undefined, worktreeInitCommand: undefined, + /* + FNXC:WorktreeCopyFiles 2026-06-24-00:00: + The safe default is an empty allowlist so new worktrees never copy potentially sensitive repository files until the project owner explicitly configures root-relative regular-file paths. + */ + worktreeCopyFiles: [], testCommand: undefined, buildCommand: undefined, recycleWorktrees: false, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index c90f437082..957af522d5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3778,6 +3778,13 @@ export interface ProjectSettings { /** Shell command to run inside each new worktree immediately after creation. * Useful for project-specific setup (e.g. `pnpm install --frozen-lockfile`, `cp .env.local .env`). */ worktreeInitCommand?: string; + /** + * Repository-root-relative regular files copied into newly assigned non-resume task worktrees. + * + * FNXC:WorktreeCopyFiles 2026-06-24-00:00: + * Operators need `.env`-style repo files available before worktree init commands run without embedding shell copy commands in setup. Entries stay root-relative, copy only regular files, and apply only when Fusion prepares a fresh or pooled assignment so resume worktrees keep their existing on-disk state. + */ + worktreeCopyFiles?: string[]; /** Custom test command for the project (e.g. "pnpm test") */ testCommand?: string; /** Custom build command for the project (e.g. "pnpm build") */ diff --git a/packages/dashboard/app/__tests__/settings-sections.test.tsx b/packages/dashboard/app/__tests__/settings-sections.test.tsx index 70b8bd10ab..2b33fcedd5 100644 --- a/packages/dashboard/app/__tests__/settings-sections.test.tsx +++ b/packages/dashboard/app/__tests__/settings-sections.test.tsx @@ -19,6 +19,7 @@ import { ExperimentalSection } from "../components/settings/sections/Experimenta import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub"; import { PromptsSection } from "../components/settings/sections/PromptsSection"; import { SecretsSection } from "../components/settings/sections/SecretsSection"; +import { WorktreesSection } from "../components/settings/sections/WorktreesSection"; import type { SettingsFormState } from "../components/settings/sections/context"; vi.mock("../components/AgentPromptsManager", () => ({ @@ -120,6 +121,71 @@ describe("SecretsSection", () => { }); }); +describe("WorktreesSection", () => { + const worktrunkInstall = { + status: "installed", + version: "1.0.0", + installPath: "/tmp/worktrunk", + requesting: false, + requestInstall: vi.fn(), + } as never; + + it("renders editable copy-file rows with add, browse, and remove controls", () => { + const onChange = vi.fn(); + const onBrowse = vi.fn(); + const onRemove = vi.fn(); + const onAdd = vi.fn(); + render( + , + ); + + expect(screen.getByText("Files to copy into new worktrees")).toBeInTheDocument(); + const input = screen.getByLabelText("File to copy into new worktrees") as HTMLInputElement; + expect(input.value).toBe(".env"); + fireEvent.change(input, { target: { value: "config/local.env" } }); + expect(onChange).toHaveBeenCalledWith(0, "config/local.env"); + fireEvent.click(screen.getByRole("button", { name: "Browse file to copy into new worktrees" })); + expect(onBrowse).toHaveBeenCalledWith(0); + fireEvent.click(screen.getByRole("button", { name: "Remove copied worktree file" })); + expect(onRemove).toHaveBeenCalledWith(0); + fireEvent.click(screen.getByRole("button", { name: "Add file" })); + expect(onAdd).toHaveBeenCalledTimes(1); + }); + + it("keeps an empty copy-file row reachable when the setting is undefined", () => { + render( + , + ); + + expect(screen.getByLabelText("File to copy into new worktrees")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Browse file to copy into new worktrees" })).toBeInTheDocument(); + }); +}); + describe("PromptsSection", () => { it("renders the title and mounts AgentPromptsManager", () => { render( diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 28d6d01ed3..dc3ed05ef2 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -365,6 +365,18 @@ function normalizeExperimentalFeaturesForSave(features?: Record return normalized; } +function normalizeWorktreeCopyFilesForSave(paths?: string[]): string[] { + const seen = new Set(); + const normalized: string[] = []; + for (const rawPath of paths ?? []) { + const path = rawPath.trim(); + if (!path || seen.has(path)) continue; + seen.add(path); + normalized.push(path); + } + return normalized; +} + type LegacySectionId = "pi-extensions"; export type SectionId = SettingsSection["id"] | LegacySectionId; @@ -709,6 +721,7 @@ export function SettingsModal({ recycleWorktrees: false, executorAllowSiblingBranchRename: false, worktreeNaming: "random", + worktreeCopyFiles: [], worktreesDir: "", worktrunk: { enabled: false, @@ -767,6 +780,7 @@ export function SettingsModal({ const [researchLimitError, setResearchLimitError] = useState(null); const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState(null); const [worktreesDirPickerOpen, setWorktreesDirPickerOpen] = useState(false); + const [worktreeCopyFilePickerIndex, setWorktreeCopyFilePickerIndex] = useState(null); const { entries: overlapPathPickerEntries, @@ -786,6 +800,15 @@ export function SettingsModal({ refresh: refreshWorktreesDirPicker, } = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId); + const { + entries: worktreeCopyFilePickerEntries, + currentPath: worktreeCopyFilePickerCurrentPath, + setPath: setWorktreeCopyFilePickerPath, + loading: worktreeCopyFilePickerLoading, + error: worktreeCopyFilePickerError, + refresh: refreshWorktreeCopyFilePicker, + } = useWorkspaceFileBrowser("project", worktreeCopyFilePickerIndex !== null, projectId); + const { nodes } = useNodes(); const experimentalFeatures = form.experimentalFeatures ?? {}; const remoteAccessEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "remoteAccess"); @@ -966,6 +989,7 @@ export function SettingsModal({ mergeIntegrationWorktree: normalizeMergeIntegrationWorktreeMode(s.mergeIntegrationWorktree), mergeAdvanceAutoSync: normalizeMergeAdvanceAutoSyncMode(s.mergeAdvanceAutoSync), maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(s), + worktreeCopyFiles: Array.isArray(s.worktreeCopyFiles) ? s.worktreeCopyFiles : [], }; setForm(normalizedSettings); setInitialValues(normalizedSettings); // Store initial values to detect explicit clears @@ -2271,6 +2295,73 @@ export function SettingsModal({ } }, [closeWorktreesDirPicker]); + const openWorktreeCopyFilePicker = useCallback((index: number) => { + setWorktreeCopyFilePickerIndex(index); + setWorktreeCopyFilePickerPath("."); + }, [setWorktreeCopyFilePickerPath]); + + const closeWorktreeCopyFilePicker = useCallback(() => { + setWorktreeCopyFilePickerIndex(null); + }, []); + + const selectWorktreeCopyFile = useCallback((path: string) => { + if (worktreeCopyFilePickerIndex === null) return; + + setForm((f) => { + const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0 + ? [...f.worktreeCopyFiles] + : [""]; + currentPaths[worktreeCopyFilePickerIndex] = path; + return { ...f, worktreeCopyFiles: currentPaths }; + }); + + closeWorktreeCopyFilePicker(); + }, [worktreeCopyFilePickerIndex, closeWorktreeCopyFilePicker]); + + const handleWorktreeCopyFilePickerOverlayClick = useCallback((event: MouseEvent) => { + if (event.target === event.currentTarget) { + closeWorktreeCopyFilePicker(); + } + }, [closeWorktreeCopyFilePicker]); + + const handleWorktreeCopyFileChange = useCallback((index: number, value: string) => { + setForm((f) => { + const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0 + ? [...f.worktreeCopyFiles] + : [""]; + currentPaths[index] = value; + return { ...f, worktreeCopyFiles: currentPaths }; + }); + }, []); + + const handleRemoveWorktreeCopyFile = useCallback((index: number) => { + setForm((f) => { + const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0 + ? [...f.worktreeCopyFiles] + : [""]; + const nextPaths = currentPaths.filter((_, i) => i !== index); + return { ...f, worktreeCopyFiles: nextPaths.length > 0 ? nextPaths : [] }; + }); + + if (worktreeCopyFilePickerIndex === index) { + closeWorktreeCopyFilePicker(); + return; + } + + if (worktreeCopyFilePickerIndex !== null && worktreeCopyFilePickerIndex > index) { + setWorktreeCopyFilePickerIndex(worktreeCopyFilePickerIndex - 1); + } + }, [worktreeCopyFilePickerIndex, closeWorktreeCopyFilePicker]); + + const handleAddWorktreeCopyFile = useCallback(() => { + setForm((f) => { + const currentPaths = f.worktreeCopyFiles && f.worktreeCopyFiles.length > 0 + ? f.worktreeCopyFiles + : [""]; + return { ...f, worktreeCopyFiles: [...currentPaths, ""] }; + }); + }, []); + const handleOverlapIgnorePathChange = useCallback((index: number, value: string) => { setForm((f) => { const currentPaths = f.overlapIgnorePaths && f.overlapIgnorePaths.length > 0 @@ -2334,6 +2425,7 @@ export function SettingsModal({ setIsSaving(true); try { + const normalizedWorktreeCopyFiles = normalizeWorktreeCopyFilesForSave(form.worktreeCopyFiles); const payload = { ...form, worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined, @@ -2348,6 +2440,9 @@ export function SettingsModal({ githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined, githubAuthToken: form.githubAuthToken?.trim() || undefined, overlapIgnorePaths: (form.overlapIgnorePaths ?? []).map((path) => path.trim()).filter((path) => path.length > 0), + worktreeCopyFiles: normalizedWorktreeCopyFiles.length > 0 || initialScopedValues?.project?.worktreeCopyFiles !== undefined + ? normalizedWorktreeCopyFiles + : undefined, experimentalFeatures: normalizeExperimentalFeaturesForSave(form.experimentalFeatures), }; @@ -2740,6 +2835,10 @@ export function SettingsModal({ worktrunkInstall={worktrunkInstall} worktrunkInstallVerified={worktrunkInstallVerified} onOpenWorktreesDirPicker={openWorktreesDirPicker} + onWorktreeCopyFileChange={handleWorktreeCopyFileChange} + onRemoveWorktreeCopyFile={handleRemoveWorktreeCopyFile} + onAddWorktreeCopyFile={handleAddWorktreeCopyFile} + onOpenWorktreeCopyFilePicker={openWorktreeCopyFilePicker} onOpenApprovals={onOpenApprovals} /> ); @@ -3263,6 +3362,53 @@ export function SettingsModal({ )} + + {worktreeCopyFilePickerIndex !== null && ( +
+
event.stopPropagation()}> +
+

{t("settings.worktrees.selectCopyFile", "Select file to copy")}

+ +
+
+

+ {t("settings.worktrees.copyFilePickerNote", "Choose a repository file to copy into each newly assigned task worktree. Directories are not selected from this picker.")} +

+ +
+
+
+ + {t("settings.fileBrowser.currentDirectory", "Current directory:")} {worktreeCopyFilePickerCurrentPath === "." ? t("settings.fileBrowser.projectRoot", "(project root)") : worktreeCopyFilePickerCurrentPath} + +
+
+ +
+
+
+
+ )} {/* Import Confirmation Dialog */} {importDialogOpen && importPreview && ( diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 4df5673871..4c6d141789 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -215,6 +215,7 @@ const defaultSettings = { ephemeralAgentsEnabled: true, executorAllowSiblingBranchRename: false, worktreeNaming: "random", + worktreeCopyFiles: [], worktreesDir: "", worktrunk: { enabled: false, @@ -3092,6 +3093,96 @@ describe("SettingsModal", () => { expect(payload.worktreesDir).toBe("~/.fn-worktrees/{repo}"); }); + it("adds, browses, de-duplicates, and saves worktree copy files", async () => { + mockFetchSettings.mockResolvedValue({ + ...defaultSettings, + worktreeCopyFiles: [".env"], + }); + mockFetchSettingsByScope.mockResolvedValue({ + global: defaultSettings, + project: { worktreeCopyFiles: [".env"] }, + }); + + renderModal({ initialSection: "worktrees" }); + await waitForSettingsModalReady(); + + expect(screen.getByDisplayValue(".env")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Add file" })); + const inputs = screen.getAllByLabelText("File to copy into new worktrees") as HTMLInputElement[]; + await userEvent.type(inputs[1], " .env "); + await userEvent.click(screen.getAllByRole("button", { name: "Browse file to copy into new worktrees" })[1]); + expect(await screen.findByRole("dialog", { name: "Browse file to copy into new worktrees" })).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Select README.md" })); + + await userEvent.click(screen.getByRole("button", { name: "Add file" })); + const updatedInputs = screen.getAllByLabelText("File to copy into new worktrees") as HTMLInputElement[]; + await userEvent.type(updatedInputs[2], " README.md "); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + const payload = mockUpdateSettings.mock.calls[0][0] as Record; + expect(payload.worktreeCopyFiles).toEqual([".env", "README.md"]); + }); + + it("clears worktree copy files to an empty persisted list", async () => { + mockFetchSettings.mockResolvedValue({ + ...defaultSettings, + worktreeCopyFiles: [".env"], + }); + mockFetchSettingsByScope.mockResolvedValue({ + global: defaultSettings, + project: { worktreeCopyFiles: [".env"] }, + }); + + renderModal({ initialSection: "worktrees" }); + await waitForSettingsModalReady(); + + await userEvent.click(screen.getByRole("button", { name: "Remove copied worktree file" })); + await userEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mockUpdateSettings).toHaveBeenCalled()); + const payload = mockUpdateSettings.mock.calls[0][0] as Record; + expect(payload.worktreeCopyFiles).toEqual([]); + }); + + it("exposes worktree copy file controls via the mobile Settings Section picker", async () => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query === "(max-width: 768px)" || query === "(max-width: 768px), (max-height: 480px)", + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + mockFetchSettings.mockResolvedValue({ + ...defaultSettings, + worktreeCopyFiles: [".env.local"], + }); + mockFetchSettingsByScope.mockResolvedValue({ + global: defaultSettings, + project: { worktreeCopyFiles: [".env.local"] }, + }); + + renderModal(); + await waitForSettingsModalReady(); + + const sectionPicker = screen.getByLabelText("Settings Section") as HTMLSelectElement; + expect(sectionPicker).toBeInTheDocument(); + expect(sectionPicker.querySelector('option[value="worktrees"]')).toHaveTextContent("Worktrees"); + + await userEvent.selectOptions(sectionPicker, "worktrees"); + + expect(screen.getByText("Files to copy into new worktrees")).toBeInTheDocument(); + expect(screen.getByDisplayValue(".env.local")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Browse file to copy into new worktrees" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add file" })).toBeInTheDocument(); + }); + it("allows clearing maxWorktrees without leaving a stuck zero", async () => { renderModal(); await waitFor(() => expect(mockFetchSettings).toHaveBeenCalled()); diff --git a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx index e8509a2cee..a07d76a7e3 100644 --- a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx +++ b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx @@ -9,10 +9,15 @@ export interface WorktreesSectionProps extends SectionBaseProps { worktrunkInstall: ReturnType; worktrunkInstallVerified: boolean; onOpenWorktreesDirPicker: () => void; + onWorktreeCopyFileChange: (index: number, value: string) => void; + onRemoveWorktreeCopyFile: (index: number) => void; + onAddWorktreeCopyFile: () => void; + onOpenWorktreeCopyFilePicker: (index: number) => void; onOpenApprovals?: (approvalId?: string) => void; } -export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, worktrunkInstall, worktrunkInstallVerified, onOpenWorktreesDirPicker, onOpenApprovals, }: WorktreesSectionProps) { +export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, worktrunkInstall, worktrunkInstallVerified, onOpenWorktreesDirPicker, onWorktreeCopyFileChange, onRemoveWorktreeCopyFile, onAddWorktreeCopyFile, onOpenWorktreeCopyFilePicker, onOpenApprovals, }: WorktreesSectionProps) { const { t } = useTranslation("app"); + const worktreeCopyFileRows = (form.worktreeCopyFiles?.length ?? 0) > 0 ? form.worktreeCopyFiles ?? [] : [""]; return (<> {scopeBanner}

{t("settings.worktrees.worktrees", "Worktrees")}

@@ -34,6 +39,50 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt setForm((f) => ({ ...f, recycleWorktrees: e.target.checked }))}/>{t("settings.worktrees.recycleWorktrees", " Recycle worktrees ")} {t("settings.worktrees.offByDefaultOptInWhenEnabledCompleted", "Off by default (opt-in). When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup")} +
+ + {/* + FNXC:WorktreeCopyFiles 2026-06-24-00:00: + Users need a visible, editable allowlist for repository files such as `.env` that Fusion copies into freshly prepared task worktrees. The UI preserves blank rows while editing, but save normalization trims, removes blanks, and de-duplicates before persistence. + */} +
+ {worktreeCopyFileRows.map((path, index) => ( +
+
+ onWorktreeCopyFileChange(index, e.target.value)} + aria-label={t("settings.worktrees.copyFilePathLabel", "File to copy into new worktrees")} + /> + +
+ +
+ ))} +
+ + {t("settings.worktrees.copyFilesHelp", "Optional. Repository-root-relative regular files are copied into fresh or pooled task worktrees before init commands run. Missing files or directories are skipped without exposing contents.")} +
diff --git a/packages/engine/src/__tests__/worktree-acquisition.test.ts b/packages/engine/src/__tests__/worktree-acquisition.test.ts index 374b53a465..e56f4f0dca 100644 --- a/packages/engine/src/__tests__/worktree-acquisition.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { execSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; @@ -471,6 +471,29 @@ describe("acquireTaskWorktree", () => { expect(cleanupOrder).toBeLessThan(initOrder); }); + it("copies configured files for fresh acquisition before init command", async () => { + const rootDir = track(mkdtempSync(join(tmpdir(), "fn-copy-fresh-root-"))); + const worktreePath = track(mkdtempSync(join(tmpdir(), "fn-copy-fresh-worktree-"))); + writeFileSync(join(rootDir, ".env"), "SECRET=redacted\n", "utf-8"); + const runConfiguredCommand = vi.fn().mockImplementation(async () => { + expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("SECRET=redacted\n"); + return { exitCode: 0, stderr: "", stdout: "" }; + }); + + await acquireTaskWorktree({ + task, + rootDir, + store, + settings: { worktreeCopyFiles: [".env"], worktreeInitCommand: "pnpm install" } as any, + createWorktree: vi.fn().mockResolvedValue({ path: worktreePath, branch: "fusion/fn-1" }), + runConfiguredCommand, + runInitCommand: true, + }); + + expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("SECRET=redacted\n"); + expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Copied configured worktree files into fresh worktree: .env", undefined, undefined); + }); + it("invokes desktop artifact cleanup once for pooled acquisition", async () => { const runConfiguredCommand = vi.fn(); @@ -494,6 +517,50 @@ describe("acquireTaskWorktree", () => { expect(runConfiguredCommand).not.toHaveBeenCalled(); }); + it("copies configured files into pooled worktrees after preparation", async () => { + const rootDir = track(mkdtempSync(join(tmpdir(), "fn-copy-pool-root-"))); + const worktreePath = track(mkdtempSync(join(tmpdir(), "fn-copy-pool-worktree-"))); + writeFileSync(join(rootDir, ".env"), "POOL=updated\n", "utf-8"); + writeFileSync(join(worktreePath, ".env"), "POOL=old\n", "utf-8"); + + const result = await acquireTaskWorktree({ + task, + rootDir, + store, + settings: { recycleWorktrees: true, worktreeCopyFiles: [".env"] } as any, + pool: { + acquire: (_taskId: string) => worktreePath, + prepareForTask: vi.fn().mockResolvedValue({ branch: "fusion/fn-1", worktreePath, reclaimed: false }), + release: vi.fn(), + } as any, + createWorktree: vi.fn(), + }); + + expect(result.source).toBe("pool"); + expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("POOL=updated\n"); + expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Copied configured worktree files into pool worktree: .env", undefined, undefined); + }); + + it("does not copy configured files over resumed worktree state", async () => { + const rootDir = track(mkdtempSync(join(tmpdir(), "fn-copy-resume-root-"))); + const worktreePath = track(mkdtempSync(join(tmpdir(), "fn-copy-resume-worktree-"))); + writeFileSync(join(rootDir, ".env"), "ROOT=updated\n", "utf-8"); + writeFileSync(join(worktreePath, ".env"), "RESUME=keep\n", "utf-8"); + + const result = await acquireTaskWorktree({ + task: { ...task, worktree: worktreePath, branch: "fusion/fn-1" }, + rootDir, + store, + settings: { worktreeCopyFiles: [".env"] } as any, + createWorktree: vi.fn(), + }); + + expect(result.source).toBe("existing"); + expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("RESUME=keep\n"); + expect(store.logEntry).not.toHaveBeenCalledWith("FN-1", expect.stringContaining("Copied configured worktree files"), undefined, undefined); + expect(existsSync(join(worktreePath, ".env"))).toBe(true); + }); + it("FN-4834: logs worktree init stderr in task log outcome", async () => { const runConfiguredCommand = vi.fn().mockResolvedValue({ exitCode: 1, diff --git a/packages/engine/src/__tests__/worktree-copy-files.test.ts b/packages/engine/src/__tests__/worktree-copy-files.test.ts new file mode 100644 index 0000000000..cc5cd03a2f --- /dev/null +++ b/packages/engine/src/__tests__/worktree-copy-files.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { copyConfiguredWorktreeFiles } from "../worktree-copy-files.js"; + +const cleanupPaths: string[] = []; +function makeDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + cleanupPaths.push(dir); + return dir; +} + +afterEach(() => { + for (const path of cleanupPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }); + } +}); + +describe("copyConfiguredWorktreeFiles", () => { + it("copies configured regular files to the same relative worktree path", async () => { + const rootDir = makeDir("fn-copy-root-"); + const worktreePath = makeDir("fn-copy-worktree-"); + mkdirSync(join(rootDir, "config"), { recursive: true }); + writeFileSync(join(rootDir, ".env"), "SECRET=redacted\n", "utf-8"); + writeFileSync(join(rootDir, "config", "local.env"), "LOCAL=1\n", "utf-8"); + + const results = await copyConfiguredWorktreeFiles({ + rootDir, + worktreePath, + paths: [".env", "config/local.env"], + taskId: "FN-6943", + }); + + expect(results.map((result) => result.outcome)).toEqual(["copied", "copied"]); + expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("SECRET=redacted\n"); + expect(readFileSync(join(worktreePath, "config", "local.env"), "utf-8")).toBe("LOCAL=1\n"); + }); + + it("skips blank and duplicate entries without extra writes", async () => { + const rootDir = makeDir("fn-copy-root-"); + const worktreePath = makeDir("fn-copy-worktree-"); + writeFileSync(join(rootDir, ".env"), "first\n", "utf-8"); + + const results = await copyConfiguredWorktreeFiles({ + rootDir, + worktreePath, + paths: ["", " .env ", ".env"], + taskId: "FN-6943", + }); + + expect(results.map((result) => result.reason ?? result.outcome)).toEqual(["blank", "copied", "duplicate"]); + expect(readFileSync(join(worktreePath, ".env"), "utf-8")).toBe("first\n"); + }); + + it("rejects absolute and traversal paths before reading or writing", async () => { + const rootDir = makeDir("fn-copy-root-"); + const worktreePath = makeDir("fn-copy-worktree-"); + const logger = { warn: vi.fn() }; + + const results = await copyConfiguredWorktreeFiles({ + rootDir, + worktreePath, + paths: ["/tmp/outside.env", "../outside.env", "nested/../../outside.env"], + taskId: "FN-6943", + logger, + }); + + expect(results.map((result) => result.reason)).toEqual(["absolute-path", "path-traversal", "path-traversal"]); + expect(existsSync(join(worktreePath, "outside.env"))).toBe(false); + expect(logger.warn).toHaveBeenCalledTimes(3); + }); + + it("skips missing sources and directories as non-fatal diagnostics", async () => { + const rootDir = makeDir("fn-copy-root-"); + const worktreePath = makeDir("fn-copy-worktree-"); + mkdirSync(join(rootDir, "config"), { recursive: true }); + + const results = await copyConfiguredWorktreeFiles({ + rootDir, + worktreePath, + paths: ["missing.env", "config"], + taskId: "FN-6943", + }); + + expect(results.map((result) => result.reason)).toEqual(["missing", "non-regular"]); + expect(existsSync(join(worktreePath, "missing.env"))).toBe(false); + expect(existsSync(join(worktreePath, "config"))).toBe(false); + }); + + it("emits audit events without exposing file contents", async () => { + const rootDir = makeDir("fn-copy-root-"); + const worktreePath = makeDir("fn-copy-worktree-"); + writeFileSync(join(rootDir, ".env"), "SECRET=redacted\n", "utf-8"); + const filesystem = vi.fn().mockResolvedValue(undefined); + + await copyConfiguredWorktreeFiles({ + rootDir, + worktreePath, + paths: [".env"], + taskId: "FN-6943", + audit: { filesystem }, + }); + + expect(filesystem).toHaveBeenCalledWith(expect.objectContaining({ + type: "worktree:copy-file", + target: "FN-6943", + metadata: expect.objectContaining({ path: ".env", outcome: "copied" }), + })); + expect(JSON.stringify(filesystem.mock.calls)).not.toContain("SECRET=redacted"); + }); +}); diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index c21bf58a13..6a596af362 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -736,6 +736,10 @@ export function assertNoSecretPlaintext(metadata?: Record): voi * - secret:env-write-skipped -> { filename, reason: "disabled"|"no-secrets"|"not-gitignored"|"skip-existing"|"invalid-filename"|"no-store"|"list-failed", overwritePolicy?, checkIgnoreError?, symlink? } * - secret:env-cleanup -> { filename, fingerprint, reason: "fingerprint-match"|"directory-missing" } * - secret:env-cleanup-skipped -> { filename, reason: "fingerprint-mismatch"|"file-missing"|"no-record"|"disabled"|"stat-failed", checkError? } + * - worktree:copy-file / worktree:copy-file-skipped -> { path, outcome, reason?, error? } (never file contents) + * + * FNXC:WorktreeCopyFiles 2026-06-24-00:00: + * Worktree copy-file audit events are filesystem-domain setup diagnostics and must carry only paths/reasons, never `.env` contents or copied file bytes. */ export type FilesystemMutationType = | "file:write" @@ -751,6 +755,8 @@ export type FilesystemMutationType = | "binary:install-success" | "binary:install-failed" | "binary:install-denied" + | "worktree:copy-file" + | "worktree:copy-file-skipped" | (typeof SECRET_MUTATION_TYPES)[number]; export type SandboxMutationType = "sandbox:prepare" | "sandbox:run" | "sandbox:failure" | "sandbox:fallback"; diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index d769071f32..2894f680d1 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -36,6 +36,7 @@ import type { RunAuditor } from "./run-audit.js"; import { writeSecretsEnvFile } from "./secrets-env-writer.js"; import { removeDesktopBuildArtifacts } from "./worktree-desktop-artifacts.js"; import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js"; +import { copyConfiguredWorktreeFiles, type WorktreeCopyFileResult } from "./worktree-copy-files.js"; import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js"; import { resolveIntegrationBranch } from "./integration-branch.js"; import { activeSessionRegistry, type ActiveSessionRegistry } from "./active-session-registry.js"; @@ -307,8 +308,34 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro } }; + const logConfiguredCopyFileResults = async (results: WorktreeCopyFileResult[], source: "fresh" | "pool") => { + if (results.length === 0) return; + const copied = results.filter((result) => result.outcome === "copied"); + const skipped = results.filter((result) => result.outcome === "skipped" && result.reason !== "blank" && result.reason !== "duplicate"); + if (copied.length > 0) { + await store.logEntry(task.id, `Copied configured worktree files into ${source} worktree: ${copied.map((result) => result.path).join(", ")}`, undefined, runContext); + } + for (const result of skipped) { + await store.logEntry(task.id, `Skipped configured worktree copy file ${result.path}: ${result.reason ?? "unknown"}`, undefined, runContext); + } + }; + + const copyConfiguredFilesForPreparedWorktree = async (source: "fresh" | "pool") => { + const preparedWorktreePath = worktreePath; + if (!preparedWorktreePath) return; + const results = await copyConfiguredWorktreeFiles({ + rootDir, + worktreePath: preparedWorktreePath, + paths: settings.worktreeCopyFiles, + taskId: task.id, + logger, + audit, + }); + await logConfiguredCopyFileResults(results, source); + }; + const emitRepoRootReturnGuardAudit = async (guardedPath: string, source: string) => { - await audit?.git({ + await audit?.git({ type: "worktree:incomplete-detected", target: guardedPath, metadata: { @@ -355,6 +382,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext); } + await copyConfiguredFilesForPreparedWorktree(source); + if (runInitCommand && settings.worktreeInitCommand && runConfiguredCommand) { const initStartedAt = Date.now(); let initResult: InitCommandResult | undefined; @@ -516,6 +545,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro if (cleanup.removed.length > 0) { await store.logEntry(task.id, `Removed desktop build artifacts from worktree: ${cleanup.removed.join(", ")}`, undefined, runContext); } + await copyConfiguredFilesForPreparedWorktree("pool"); await maybeWarnForeignTaskStartPoint({ baseBranch, rootDir, diff --git a/packages/engine/src/worktree-copy-files.ts b/packages/engine/src/worktree-copy-files.ts new file mode 100644 index 0000000000..8f43f0495a --- /dev/null +++ b/packages/engine/src/worktree-copy-files.ts @@ -0,0 +1,147 @@ +import { constants as fsConstants } from "node:fs"; +import { access, copyFile, lstat, mkdir } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import type { RunAuditor } from "./run-audit.js"; + +export type WorktreeCopyFileSkipReason = + | "blank" + | "duplicate" + | "absolute-path" + | "path-traversal" + | "missing" + | "non-regular" + | "unreadable" + | "copy-failed"; + +export interface WorktreeCopyFileResult { + path: string; + sourcePath?: string; + destinationPath?: string; + outcome: "copied" | "skipped"; + reason?: WorktreeCopyFileSkipReason; + error?: string; +} + +export interface CopyConfiguredWorktreeFilesOptions { + rootDir: string; + worktreePath: string; + paths?: readonly string[]; + taskId?: string; + logger?: { log?: (message: string) => void; warn?: (message: string) => void }; + audit?: Pick; +} + +function isInsideRoot(rootDir: string, candidate: string): boolean { + const rel = relative(rootDir, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function safeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function auditCopyResult( + audit: Pick | undefined, + taskId: string | undefined, + result: WorktreeCopyFileResult, +): Promise { + try { + await audit?.filesystem({ + type: result.outcome === "copied" ? "worktree:copy-file" : "worktree:copy-file-skipped", + target: taskId ?? result.path, + metadata: { + path: result.path, + outcome: result.outcome, + reason: result.reason, + error: result.error, + }, + }); + } catch { + // Best-effort observability only; copy decisions must not fail acquisition. + } +} + +/** + * FNXC:WorktreeCopyFiles 2026-06-24-00:00: + * Configured copy files may contain secrets, so the engine copies only root-relative regular files, never shells out, never logs contents, and treats missing/non-file/unreadable entries as non-fatal setup diagnostics for newly prepared worktrees. + */ +export async function copyConfiguredWorktreeFiles(options: CopyConfiguredWorktreeFilesOptions): Promise { + const { rootDir, worktreePath, paths = [], taskId, logger, audit } = options; + const root = resolve(rootDir); + const worktreeRoot = resolve(worktreePath); + const seen = new Set(); + const results: WorktreeCopyFileResult[] = []; + + for (const rawPath of paths) { + const trimmed = typeof rawPath === "string" ? rawPath.trim() : ""; + if (!trimmed) { + const result: WorktreeCopyFileResult = { path: trimmed, outcome: "skipped", reason: "blank" }; + results.push(result); + await auditCopyResult(audit, taskId, result); + continue; + } + + const normalizedKey = trimmed.replace(/\\/g, "/"); + if (seen.has(normalizedKey)) { + const result: WorktreeCopyFileResult = { path: trimmed, outcome: "skipped", reason: "duplicate" }; + results.push(result); + await auditCopyResult(audit, taskId, result); + continue; + } + seen.add(normalizedKey); + + if (isAbsolute(trimmed)) { + const result: WorktreeCopyFileResult = { path: trimmed, outcome: "skipped", reason: "absolute-path" }; + logger?.warn?.(`${taskId ?? "worktree"}: skipped configured worktree copy file ${trimmed}: absolute paths are not allowed`); + results.push(result); + await auditCopyResult(audit, taskId, result); + continue; + } + + const sourcePath = resolve(root, trimmed); + const destinationPath = resolve(worktreeRoot, trimmed); + if (!isInsideRoot(root, sourcePath) || !isInsideRoot(worktreeRoot, destinationPath)) { + const result: WorktreeCopyFileResult = { path: trimmed, sourcePath, destinationPath, outcome: "skipped", reason: "path-traversal" }; + logger?.warn?.(`${taskId ?? "worktree"}: skipped configured worktree copy file ${trimmed}: path escapes repository root`); + results.push(result); + await auditCopyResult(audit, taskId, result); + continue; + } + + try { + const sourceStat = await lstat(sourcePath); + if (!sourceStat.isFile()) { + const result: WorktreeCopyFileResult = { path: trimmed, sourcePath, destinationPath, outcome: "skipped", reason: "non-regular" }; + logger?.warn?.(`${taskId ?? "worktree"}: skipped configured worktree copy file ${trimmed}: source is not a regular file`); + results.push(result); + await auditCopyResult(audit, taskId, result); + continue; + } + await access(sourcePath, fsConstants.R_OK); + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code) : ""; + const reason: WorktreeCopyFileSkipReason = code === "ENOENT" ? "missing" : "unreadable"; + const result: WorktreeCopyFileResult = { path: trimmed, sourcePath, destinationPath, outcome: "skipped", reason, error: safeError(error) }; + logger?.warn?.(`${taskId ?? "worktree"}: skipped configured worktree copy file ${trimmed}: ${reason}`); + results.push(result); + await auditCopyResult(audit, taskId, result); + continue; + } + + try { + await mkdir(dirname(destinationPath), { recursive: true }); + await copyFile(sourcePath, destinationPath); + const result: WorktreeCopyFileResult = { path: trimmed, sourcePath, destinationPath, outcome: "copied" }; + logger?.log?.(`${taskId ?? "worktree"}: copied configured worktree file ${trimmed}`); + results.push(result); + await auditCopyResult(audit, taskId, result); + } catch (error) { + const result: WorktreeCopyFileResult = { path: trimmed, sourcePath, destinationPath, outcome: "skipped", reason: "copy-failed", error: safeError(error) }; + logger?.warn?.(`${taskId ?? "worktree"}: skipped configured worktree copy file ${trimmed}: copy failed`); + results.push(result); + await auditCopyResult(audit, taskId, result); + } + } + + return results; +}