diff --git a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts index 68fa2d37f5a..3142b442a2c 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts @@ -64,6 +64,7 @@ const statMock = vi.fn() const readdirMock = vi.fn() const readlinkMock = vi.fn() const lstatMock = vi.fn() +const realpathMock = vi.fn().mockImplementation((p: string) => Promise.resolve(p)) // Replace fs functions with our mocks fs.readFile = readFileMock as any @@ -71,6 +72,7 @@ fs.stat = statMock as any fs.readdir = readdirMock as any fs.readlink = readlinkMock as any fs.lstat = lstatMock as any +fs.realpath = realpathMock as any // Mock process.cwd const originalCwd = process.cwd @@ -1573,6 +1575,84 @@ describe("Rules directory reading", () => { expect(result).toContain("mmm-middle.txt") }) + it.skipIf(process.platform === "win32")( + "should resolve relative symlinks using realpath of parent directory", + async () => { + // This tests the scenario where: + // /project/.roo/rules -> /external/rules (symlinked directory) + // /external/rules/1-project.txt -> ../1-project.txt (relative symlink) + // The relative symlink should resolve to /external/1-project.txt, NOT /project/.roo/1-project.txt + + // Simulate .roo/rules directory exists + statMock.mockResolvedValueOnce({ + isDirectory: vi.fn().mockReturnValue(true), + } as any) + + // Simulate listing files - one relative symlink inside the symlinked directory + readdirMock.mockResolvedValueOnce([ + { + name: "1-project.txt", + isFile: () => false, + isSymbolicLink: () => true, + parentPath: "/project/.roo/rules", + }, + ] as any) + + // readlink returns a relative target + readlinkMock.mockResolvedValueOnce("../1-project.txt") + + // realpath resolves the symlinked parent directory to its real location + realpathMock.mockImplementation((dirPath: string) => { + const normalizedPath = dirPath.toString().replace(/\\/g, "/") + if (normalizedPath === "/project/.roo/rules") { + return Promise.resolve("/external/rules") + } + return Promise.resolve(dirPath) + }) + + // stat mock for the resolved target + statMock.mockImplementation((filePath: string) => { + const normalizedPath = filePath.toString().replace(/\\/g, "/") + if (normalizedPath === "/external/rules/../1-project.txt" || normalizedPath === "/external/1-project.txt") { + return Promise.resolve({ + isFile: vi.fn().mockReturnValue(true), + isDirectory: vi.fn().mockReturnValue(false), + } as any) + } + return Promise.resolve({ + isFile: vi.fn().mockReturnValue(false), + isDirectory: vi.fn().mockReturnValue(false), + } as any) + }) + + readFileMock.mockImplementation((filePath: string) => { + const normalizedPath = filePath.toString().replace(/\\/g, "/") + if (normalizedPath === "/external/rules/../1-project.txt" || normalizedPath === "/external/1-project.txt") { + return Promise.resolve("content from external file") + } + return Promise.reject({ code: "ENOENT" }) + }) + + const result = await loadRuleFiles("/project") + + // Verify realpath was called on the symlink's parent directory + expect(realpathMock).toHaveBeenCalledWith("/project/.roo/rules") + + // Verify the content was read from the correctly resolved path + // (resolved relative to /external/rules, not /project/.roo/rules) + expect(result).toContain("content from external file") + + // Verify readlink was called + expect(readlinkMock).toHaveBeenCalledWith("/project/.roo/rules/1-project.txt") + + // The key assertion: stat should NOT have been called with the wrong path + // that would result from resolving relative to the symlinked path + const statCalls = statMock.mock.calls.map((call: any[]) => call[0].toString().replace(/\\/g, "/")) + expect(statCalls).not.toContain("/project/.roo/rules/../1-project.txt") + expect(statCalls).not.toContain("/project/.roo/1-project.txt") + }, + ) + it("should handle empty file list gracefully", async () => { // Simulate .roo/rules directory exists statMock.mockResolvedValueOnce({ diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 46cf1bf1f9e..0d72e568b83 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -84,8 +84,19 @@ async function resolveSymLink( try { // Get the symlink target const linkTarget = await fs.readlink(symlinkPath) - // Resolve the target path (relative to the symlink location) - const resolvedTarget = path.resolve(path.dirname(symlinkPath), linkTarget) + // Resolve the target path relative to the symlink's real parent directory. + // We must use realpath on the parent directory because the symlink itself + // may be inside a symlinked directory. Relative symlink targets are resolved + // by the filesystem relative to the symlink's actual location, not the path + // used to access it. + const symlinkDir = path.dirname(symlinkPath) + let realSymlinkDir: string + try { + realSymlinkDir = await fs.realpath(symlinkDir) + } catch { + realSymlinkDir = symlinkDir + } + const resolvedTarget = path.resolve(realSymlinkDir, linkTarget) // Check if the target is a file const stats = await fs.stat(resolvedTarget)