fix(FN-6085): canonicalize merge worktree boundaries
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user