Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 150 additions & 153 deletions src/core/config/CustomModesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,49 +558,55 @@ export class CustomModesManager {
*/
public async checkRulesDirectoryHasContent(slug: string): Promise<boolean> {
try {
// Get workspace path
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return false
}
// First, find the mode to determine its source
const allModes = await this.getCustomModes()
const mode = allModes.find((m) => m.slug === slug)

// Check if .roomodes file exists and contains this mode
// This ensures we can only consolidate rules for modes that have been customized
const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
try {
const roomodesExists = await fileExistsAtPath(roomodesPath)
if (roomodesExists) {
const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
const roomodesData = yaml.parse(roomodesContent)
const roomodesModes = roomodesData?.customModes || []

// Check if this specific mode exists in .roomodes
const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug)
if (!modeInRoomodes) {
return false // Mode not customized in .roomodes, cannot consolidate
}
} else {
// If no .roomodes file exists, check if it's in global custom modes
const allModes = await this.getCustomModes()
const mode = allModes.find((m) => m.slug === slug)
if (!mode) {
// If not in custom modes, check if it's in .roomodes (project-specific)
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return false
}

if (!mode) {
return false // Not a custom mode, cannot consolidate
const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
try {
const roomodesExists = await fileExistsAtPath(roomodesPath)
if (roomodesExists) {
const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
const roomodesData = yaml.parse(roomodesContent)
const roomodesModes = roomodesData?.customModes || []

// Check if this specific mode exists in .roomodes
const modeInRoomodes = roomodesModes.find((m: any) => m.slug === slug)
if (!modeInRoomodes) {
return false // Mode not found anywhere
}
} else {
return false // No .roomodes file and not in custom modes
}
} catch (error) {
return false // Cannot read .roomodes and not in custom modes
}
} catch (error) {
// If we can't read .roomodes, fall back to checking custom modes
const allModes = await this.getCustomModes()
const mode = allModes.find((m) => m.slug === slug)
}

if (!mode) {
return false // Not a custom mode, cannot consolidate
// Determine the correct rules directory based on mode source
let modeRulesDir: string
const isGlobalMode = mode?.source === "global"

if (isGlobalMode) {
// For global modes, check in global .roo directory
const globalRooDir = getGlobalRooDirectory()
modeRulesDir = path.join(globalRooDir, `rules-${slug}`)
} else {
// For project modes, check in workspace .roo directory
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return false
}
modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
}

// Check for .roo/rules-{slug}/ directory
const modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)

try {
const stats = await fs.stat(modeRulesDir)
if (!stats.isDirectory()) {
Expand Down Expand Up @@ -655,24 +661,23 @@ export class CustomModesManager {

// If mode not found in custom modes, check if it's a built-in mode that has been customized
if (!mode) {
// Only check workspace-based modes if workspace is available
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return { success: false, error: "No workspace found" }
}

const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
try {
const roomodesExists = await fileExistsAtPath(roomodesPath)
if (roomodesExists) {
const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
const roomodesData = yaml.parse(roomodesContent)
const roomodesModes = roomodesData?.customModes || []

// Find the mode in .roomodes
mode = roomodesModes.find((m: any) => m.slug === slug)
if (workspacePath) {
const roomodesPath = path.join(workspacePath, ROOMODES_FILENAME)
try {
const roomodesExists = await fileExistsAtPath(roomodesPath)
if (roomodesExists) {
const roomodesContent = await fs.readFile(roomodesPath, "utf-8")
const roomodesData = yaml.parse(roomodesContent)
const roomodesModes = roomodesData?.customModes || []

// Find the mode in .roomodes
mode = roomodesModes.find((m: any) => m.slug === slug)
}
} catch (error) {
// Continue to check built-in modes
}
} catch (error) {
// Continue to check built-in modes
}

// If still not found, check if it's a built-in mode
Expand All @@ -687,14 +692,25 @@ export class CustomModesManager {
}
}

// Get workspace path
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return { success: false, error: "No workspace found" }
// Determine the base directory based on mode source
const isGlobalMode = mode.source === "global"
let baseDir: string
if (isGlobalMode) {
// For global modes, use the global .roo directory
baseDir = getGlobalRooDirectory()
} else {
// For project modes, use the workspace directory
const workspacePath = getWorkspacePath()
if (!workspacePath) {
return { success: false, error: "No workspace found" }
}
baseDir = workspacePath
}

// Check for .roo/rules-{slug}/ directory
const modeRulesDir = path.join(workspacePath, ".roo", `rules-${slug}`)
// Check for .roo/rules-{slug}/ directory (or rules-{slug}/ for global)
const modeRulesDir = isGlobalMode
? path.join(baseDir, `rules-${slug}`)
: path.join(baseDir, ".roo", `rules-${slug}`)

let rulesFiles: RuleFile[] = []
try {
Expand All @@ -709,8 +725,10 @@ export class CustomModesManager {
const filePath = path.join(modeRulesDir, entry.name)
const content = await fs.readFile(filePath, "utf-8")
if (content.trim()) {
// Calculate relative path from .roo directory
const relativePath = path.relative(path.join(workspacePath, ".roo"), filePath)
// Calculate relative path based on mode source
const relativePath = isGlobalMode
? path.relative(baseDir, filePath)
: path.relative(path.join(baseDir, ".roo"), filePath)
rulesFiles.push({ relativePath, content: content.trim() })
}
}
Expand Down Expand Up @@ -755,6 +773,77 @@ export class CustomModesManager {
}
}

/**
* Helper method to import rules files for a mode
* @param importMode - The mode being imported
* @param rulesFiles - The rules files to import
* @param source - The import source ("global" or "project")
*/
private async importRulesFiles(
importMode: ExportedModeConfig,
rulesFiles: RuleFile[],
source: "global" | "project",
): Promise<void> {
// Determine base directory and rules folder path based on source
let baseDir: string
let rulesFolderPath: string

if (source === "global") {
baseDir = getGlobalRooDirectory()
rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
} else {
const workspacePath = getWorkspacePath()
baseDir = path.join(workspacePath, ".roo")
rulesFolderPath = path.join(baseDir, `rules-${importMode.slug}`)
}

// Always remove the existing rules folder for this mode if it exists
// This ensures that if the imported mode has no rules, the folder is cleaned up
try {
await fs.rm(rulesFolderPath, { recursive: true, force: true })
logger.info(`Removed existing ${source} rules folder for mode ${importMode.slug}`)
} catch (error) {
// It's okay if the folder doesn't exist
logger.debug(`No existing ${source} rules folder to remove for mode ${importMode.slug}`)
}

// Only proceed with file creation if there are rules files to import
if (!rulesFiles || !Array.isArray(rulesFiles) || rulesFiles.length === 0) {
return
}

// Import the new rules files with path validation
for (const ruleFile of rulesFiles) {
if (ruleFile.relativePath && ruleFile.content) {
// Validate the relative path to prevent path traversal attacks
const normalizedRelativePath = path.normalize(ruleFile.relativePath)

// Ensure the path doesn't contain traversal sequences
if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}

const targetPath = path.join(baseDir, normalizedRelativePath)
const normalizedTargetPath = path.normalize(targetPath)
const expectedBasePath = path.normalize(baseDir)

// Ensure the resolved path stays within the base directory
if (!normalizedTargetPath.startsWith(expectedBasePath)) {
logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}

// Ensure directory exists
const targetDir = path.dirname(targetPath)
await fs.mkdir(targetDir, { recursive: true })

// Write the file
await fs.writeFile(targetPath, ruleFile.content, "utf-8")
}
}
}

/**
* Imports modes from YAML content, including their associated rules files
* @param yamlContent - The YAML content containing mode configurations
Expand Down Expand Up @@ -821,100 +910,8 @@ export class CustomModesManager {
source: source, // Use the provided source parameter
})

// Handle project-level imports
if (source === "project") {
const workspacePath = getWorkspacePath()

// Always remove the existing rules folder for this mode if it exists
// This ensures that if the imported mode has no rules, the folder is cleaned up
const rulesFolderPath = path.join(workspacePath, ".roo", `rules-${importMode.slug}`)
try {
await fs.rm(rulesFolderPath, { recursive: true, force: true })
logger.info(`Removed existing rules folder for mode ${importMode.slug}`)
} catch (error) {
// It's okay if the folder doesn't exist
logger.debug(`No existing rules folder to remove for mode ${importMode.slug}`)
}

// Only create new rules files if they exist in the import
if (rulesFiles && Array.isArray(rulesFiles) && rulesFiles.length > 0) {
// Import the new rules files with path validation
for (const ruleFile of rulesFiles) {
if (ruleFile.relativePath && ruleFile.content) {
// Validate the relative path to prevent path traversal attacks
const normalizedRelativePath = path.normalize(ruleFile.relativePath)

// Ensure the path doesn't contain traversal sequences
if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}

const targetPath = path.join(workspacePath, ".roo", normalizedRelativePath)
const normalizedTargetPath = path.normalize(targetPath)
const expectedBasePath = path.normalize(path.join(workspacePath, ".roo"))

// Ensure the resolved path stays within the .roo directory
if (!normalizedTargetPath.startsWith(expectedBasePath)) {
logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}

// Ensure directory exists
const targetDir = path.dirname(targetPath)
await fs.mkdir(targetDir, { recursive: true })

// Write the file
await fs.writeFile(targetPath, ruleFile.content, "utf-8")
}
}
}
} else if (source === "global" && rulesFiles && Array.isArray(rulesFiles)) {
// For global imports, preserve the rules files structure in the global .roo directory
const globalRooDir = getGlobalRooDirectory()

// Always remove the existing rules folder for this mode if it exists
// This ensures that if the imported mode has no rules, the folder is cleaned up
const rulesFolderPath = path.join(globalRooDir, `rules-${importMode.slug}`)
try {
await fs.rm(rulesFolderPath, { recursive: true, force: true })
logger.info(`Removed existing global rules folder for mode ${importMode.slug}`)
} catch (error) {
// It's okay if the folder doesn't exist
logger.debug(`No existing global rules folder to remove for mode ${importMode.slug}`)
}

// Import the new rules files with path validation
for (const ruleFile of rulesFiles) {
if (ruleFile.relativePath && ruleFile.content) {
// Validate the relative path to prevent path traversal attacks
const normalizedRelativePath = path.normalize(ruleFile.relativePath)

// Ensure the path doesn't contain traversal sequences
if (normalizedRelativePath.includes("..") || path.isAbsolute(normalizedRelativePath)) {
logger.error(`Invalid file path detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}

const targetPath = path.join(globalRooDir, normalizedRelativePath)
const normalizedTargetPath = path.normalize(targetPath)
const expectedBasePath = path.normalize(globalRooDir)

// Ensure the resolved path stays within the global .roo directory
if (!normalizedTargetPath.startsWith(expectedBasePath)) {
logger.error(`Path traversal attempt detected: ${ruleFile.relativePath}`)
continue // Skip this file but continue with others
}

// Ensure directory exists
const targetDir = path.dirname(targetPath)
await fs.mkdir(targetDir, { recursive: true })

// Write the file
await fs.writeFile(targetPath, ruleFile.content, "utf-8")
}
}
}
// Import rules files (this also handles cleanup of existing rules folders)
await this.importRulesFiles(importMode, rulesFiles || [], source)
}

// Refresh the modes after import
Expand Down
Loading