fix(FN-6085): canonicalize merge worktree boundaries

This commit is contained in:
gsxdsm
2026-06-09 08:51:48 -07:00
parent 2a2b7d0b61
commit ccd44b77fe
4 changed files with 83 additions and 14 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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);

View File

@@ -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<boolean> {
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;
}