From ccd44b77fea5d24eea092f99bae411c866a5ac7a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 08:51:48 -0700 Subject: [PATCH] fix(FN-6085): canonicalize merge worktree boundaries --- .changeset/merge-worktree-canonical-path.md | 2 +- .../src/__tests__/pi-create-fn-agent.test.ts | 44 ++++++++++++++++++- .../src/__tests__/pi-layers-wiring.test.ts | 18 ++++++-- packages/engine/src/pi.ts | 33 ++++++++++---- 4 files changed, 83 insertions(+), 14 deletions(-) diff --git a/.changeset/merge-worktree-canonical-path.md b/.changeset/merge-worktree-canonical-path.md index e4e8f28678..a4d95ddbd7 100644 --- a/.changeset/merge-worktree-canonical-path.md +++ b/.changeset/merge-worktree-canonical-path.md @@ -1,3 +1,3 @@ "@runfusion/fusion": patch -Fix coding-agent startup from AI merge temp worktrees on macOS by comparing Git worktree paths with filesystem-canonical paths. +Fix coding-agent startup and tool boundary checks from AI merge temp worktrees on macOS by comparing Git worktree paths with filesystem-canonical paths. diff --git a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts index 93694d8470..346d63899a 100644 --- a/packages/engine/src/__tests__/pi-create-fn-agent.test.ts +++ b/packages/engine/src/__tests__/pi-create-fn-agent.test.ts @@ -20,12 +20,14 @@ const settingsManagerInMemoryMock = vi.fn(() => ({ kind: "settings-manager" })); const setFallbackResolverMock = vi.fn(); const reloadMock = vi.fn(async () => {}); const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => ""); +const spawnSyncMock = vi.fn(() => ({ status: 1, stdout: "" })); const execFileMock = vi.fn((_file?: any, _args?: any, _opts?: any, cb?: any) => { const callback = typeof _opts === "function" ? _opts : cb; if (typeof callback === "function") callback(null, "", ""); }); const existsSyncMock = vi.fn((_path: PathLike) => false); const readFileSyncMock = vi.fn((_path?: any) => "{}"); +const realpathSyncNativeMock = vi.fn((path: PathLike) => String(path)); const readCustomProvidersMock = vi.fn(() => []); const packageManagerCwdCapture = vi.fn(); @@ -63,7 +65,7 @@ vi.mock("node:child_process", () => { } }); }); - return { execSync: execSyncFn, exec: execFn, execFile: execFileMock }; + return { execSync: execSyncFn, exec: execFn, execFile: execFileMock, spawnSync: spawnSyncMock }; }); vi.mock("node:fs", async () => { @@ -72,6 +74,9 @@ vi.mock("node:fs", async () => { ...actual, existsSync: existsSyncMock, readFileSync: readFileSyncMock, + realpathSync: Object.assign(vi.fn((path: PathLike) => String(path)), { + native: realpathSyncNativeMock, + }), }; }); @@ -283,6 +288,10 @@ describe("RTK bash rewrite wrapper", () => { describe("worktree path boundary helpers", () => { // Test helper functions directly by importing them // Note: These tests verify the boundary logic without needing a full agent session + beforeEach(() => { + spawnSyncMock.mockReturnValue({ status: 1, stdout: "" }); + realpathSyncNativeMock.mockImplementation((path: PathLike) => String(path)); + }); describe("path boundary logic for worktree sessions", () => { it("wraps file tools with boundary validation when cwd is a worktree", async () => { @@ -323,6 +332,37 @@ describe("worktree path boundary helpers", () => { expect(mockReadTool.execute).not.toHaveBeenCalled(); }, 15_000); + it("allows macOS-canonicalized paths inside the worktree boundary", async () => { + const mockBashTool = { + name: "bash", + label: "Bash", + description: "Run a command", + parameters: {}, + execute: vi.fn().mockResolvedValue({ ok: true, content: [] }), + }; + const worktreePath = "/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/fusion-ai-merge-fn-6085-2nTWPZ"; + const canonicalWorktreePath = "/private/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/fusion-ai-merge-fn-6085-2nTWPZ"; + realpathSyncNativeMock.mockImplementation((path: PathLike) => { + const text = String(path); + return text.startsWith("/var/folders/") ? `/private${text}` : text; + }); + + const { wrapToolsWithBoundary } = await import("../pi.js"); + const wrapped = wrapToolsWithBoundary( + [mockBashTool as any], + worktreePath, + "/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/project", + ); + + const result = await (wrapped[0] as any).execute("call-1", { + command: "pwd", + cwd: canonicalWorktreePath, + }); + + expect(result).toEqual({ ok: true, content: [] }); + expect(mockBashTool.execute).toHaveBeenCalled(); + }); + it("allows project root .fusion/memory/ files from worktree session", async () => { const mockReadTool = { name: "read", @@ -972,8 +1012,10 @@ describe("createFnAgent", () => { beforeEach(() => { vi.clearAllMocks(); execSyncMock.mockReturnValue(""); + spawnSyncMock.mockReturnValue({ status: 1, stdout: "" }); existsSyncMock.mockReturnValue(false); readFileSyncMock.mockReturnValue("{}"); + realpathSyncNativeMock.mockImplementation((path: PathLike) => String(path)); readCustomProvidersMock.mockReturnValue([]); findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId })); createBashToolMock.mockClear(); diff --git a/packages/engine/src/__tests__/pi-layers-wiring.test.ts b/packages/engine/src/__tests__/pi-layers-wiring.test.ts index c23f354963..2553e3402c 100644 --- a/packages/engine/src/__tests__/pi-layers-wiring.test.ts +++ b/packages/engine/src/__tests__/pi-layers-wiring.test.ts @@ -182,12 +182,22 @@ describe("createFnAgent prompt layer wiring", () => { }); it("accepts macOS-canonicalized Git linked worktrees during session validation", async () => { - const cwd = "/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/project/.worktrees/fn-6085"; - const projectRoot = "/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/project"; - const canonicalCwd = "/private/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/project/.worktrees/fn-6085"; + const canonicalProjectRoot = "/private/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/project"; + const cwd = "/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/fusion-ai-merge-fn-6085-2nTWPZ"; + const canonicalCwd = "/private/var/folders/zp/fjh8794n7bl61c_pn1gmdt200000gn/T/fusion-ai-merge-fn-6085-2nTWPZ"; existsSyncMock.mockImplementation((path: PathLike) => { const text = String(path); - return text === cwd || text === `${cwd}/.git` || text === `${projectRoot}/.fusion`; + return text === cwd || text === `${cwd}/.git` || text === `${canonicalProjectRoot}/.fusion`; + }); + spawnSyncMock.mockImplementation((_command: string, args: string[]) => { + const joined = args.join(" "); + if (joined.includes("--git-common-dir")) { + return { status: 0, stdout: `${canonicalProjectRoot}/.git\n` }; + } + if (joined.includes("--git-dir")) { + return { status: 0, stdout: `${canonicalProjectRoot}/.git/worktrees/fusion-ai-merge-fn-6085-2nTWPZ\n` }; + } + return { status: 1, stdout: "" }; }); realpathSyncNativeMock.mockImplementation((path: PathLike) => { const text = String(path); diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index 3a70ed7e21..8f891b2728 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1417,6 +1417,11 @@ function normalizeExistingPathForGitComparison(path: string): string { } } +function isSameOrInsidePath(parentPath: string, childPath: string): boolean { + const rel = relative(parentPath, childPath); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + async function isCompleteGitWorktree(worktreePath: string): Promise { try { const { stdout } = await execAsync("git rev-parse --show-toplevel", { @@ -1467,25 +1472,34 @@ function isWorktreeAllowedPath( const worktreeResolved = resolve(worktreePath); const projectRootResolved = resolve(projectRoot); const requestedResolved = isAbsolute(requestedPath) ? resolve(requestedPath) : resolve(worktreeResolved, requestedPath); + const worktreeCanonical = normalizeExistingPathForGitComparison(worktreeResolved); + const projectRootCanonical = normalizeExistingPathForGitComparison(projectRootResolved); + const requestedCanonical = normalizeExistingPathForGitComparison(requestedResolved); // Check if path is inside the worktree - const relToWorktree = relative(worktreeResolved, requestedResolved); - if (!relToWorktree.startsWith("..") && !isAbsolute(relToWorktree)) { + if ( + isSameOrInsidePath(worktreeResolved, requestedResolved) || + isSameOrInsidePath(worktreeCanonical, requestedCanonical) + ) { return true; // Path is inside the worktree } // Exception: project root `.fusion/memory/` files for durable project learnings const relToProjectRoot = relative(projectRootResolved, requestedResolved).replace(/\\/g, "/"); + const relToCanonicalProjectRoot = relative(projectRootCanonical, requestedCanonical).replace(/\\/g, "/"); + const projectRelativePaths = [relToProjectRoot, relToCanonicalProjectRoot]; if ( - relToProjectRoot === ".fusion/memory" || - relToProjectRoot === ".fusion/memory/" || - relToProjectRoot.startsWith(".fusion/memory/") + projectRelativePaths.some((relPath) => + relPath === ".fusion/memory" || + relPath === ".fusion/memory/" || + relPath.startsWith(".fusion/memory/") + ) ) { return true; } // Exception: task attachments under `.fusion/tasks/*/attachments/*` - if (relToProjectRoot.match(/^\.fusion\/tasks\/[^/]+\/attachments\//)) { + if (projectRelativePaths.some((relPath) => relPath.match(/^\.fusion\/tasks\/[^/]+\/attachments\//))) { return true; } @@ -1494,8 +1508,11 @@ function isWorktreeAllowedPath( // into the worktree. `glob`/`grep` are narrow enough to allow as well so // the agent can discover them; writes and bash remain restricted. const readOnlyTools = new Set(["read", "glob", "grep"]); - if (toolName && readOnlyTools.has(toolName) && - /^\.fusion\/tasks\/[^/]+\/(PROMPT\.md|task\.json)$/.test(relToProjectRoot)) { + if ( + toolName && + readOnlyTools.has(toolName) && + projectRelativePaths.some((relPath) => /^\.fusion\/tasks\/[^/]+\/(PROMPT\.md|task\.json)$/.test(relPath)) + ) { return true; }