- Updated error handling in both registerUtilityRoutes and handleReadMentionedFile functions to return null for expected ENOENT errors, indicating that the file simply does not exist. This improves robustness in scenarios with stale or misdetected file references. - Enhanced the isValidFileRef function to require either 3+ segments or a file extension to avoid false positives for npm scoped packages, ensuring more accurate file reference validation.
282 lines
8.4 KiB
TypeScript
282 lines
8.4 KiB
TypeScript
/**
|
|
* IPC Handlers for Utility Operations.
|
|
*
|
|
* Handlers:
|
|
* - shell:openPath: Opens a folder or file in the system's default application
|
|
* - read-claude-md-files: Reads all global CLAUDE.md files for a project
|
|
* - read-directory-claude-md: Reads a specific directory's CLAUDE.md file
|
|
* - read-mentioned-file: Validates mentioned files for context injection
|
|
*/
|
|
|
|
import { createLogger } from '@shared/utils/logger';
|
|
import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron';
|
|
import * as fsp from 'fs/promises';
|
|
|
|
import {
|
|
type ClaudeMdFileInfo,
|
|
readAgentConfigs,
|
|
readAllClaudeMdFiles,
|
|
readDirectoryClaudeMd,
|
|
} from '../services';
|
|
|
|
import type { AgentConfig } from '@shared/types/api';
|
|
|
|
const logger = createLogger('IPC:utility');
|
|
import {
|
|
validateFilePath,
|
|
validateOpenPath,
|
|
validateOpenPathUserSelected,
|
|
} from '../utils/pathValidation';
|
|
import { countTokens } from '../utils/tokenizer';
|
|
|
|
/**
|
|
* Registers all utility-related IPC handlers.
|
|
*/
|
|
export function registerUtilityHandlers(ipcMain: IpcMain): void {
|
|
ipcMain.handle('get-app-version', handleGetAppVersion);
|
|
ipcMain.handle('shell:openPath', handleShellOpenPath);
|
|
ipcMain.handle('shell:showInFolder', handleShellShowInFolder);
|
|
ipcMain.handle('shell:openExternal', handleShellOpenExternal);
|
|
ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles);
|
|
ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd);
|
|
ipcMain.handle('read-mentioned-file', handleReadMentionedFile);
|
|
ipcMain.handle('read-agent-configs', handleReadAgentConfigs);
|
|
|
|
logger.info('Utility handlers registered');
|
|
}
|
|
|
|
/**
|
|
* Removes all utility IPC handlers.
|
|
*/
|
|
export function removeUtilityHandlers(ipcMain: IpcMain): void {
|
|
ipcMain.removeHandler('get-app-version');
|
|
ipcMain.removeHandler('shell:openPath');
|
|
ipcMain.removeHandler('shell:showInFolder');
|
|
ipcMain.removeHandler('shell:openExternal');
|
|
ipcMain.removeHandler('read-claude-md-files');
|
|
ipcMain.removeHandler('read-directory-claude-md');
|
|
ipcMain.removeHandler('read-mentioned-file');
|
|
ipcMain.removeHandler('read-agent-configs');
|
|
|
|
logger.info('Utility handlers removed');
|
|
}
|
|
|
|
// =============================================================================
|
|
// Handler Implementations
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Handler for 'get-app-version' IPC call.
|
|
* Returns the app version from package.json.
|
|
*/
|
|
function handleGetAppVersion(): string {
|
|
return app.getVersion();
|
|
}
|
|
|
|
/**
|
|
* Handler for 'shell:showInFolder' IPC call.
|
|
* Reveals a file in the system file manager (Finder/Explorer).
|
|
*/
|
|
async function handleShellShowInFolder(
|
|
_event: IpcMainInvokeEvent,
|
|
filePath: string
|
|
): Promise<void> {
|
|
if (typeof filePath !== 'string' || filePath.length === 0) return;
|
|
try {
|
|
await fsp.access(filePath);
|
|
shell.showItemInFolder(filePath);
|
|
} catch {
|
|
// File doesn't exist — silently ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'shell:openExternal' IPC call.
|
|
* Opens a URL in the system's default browser.
|
|
*/
|
|
async function handleShellOpenExternal(
|
|
_event: IpcMainInvokeEvent,
|
|
url: string
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
let parsedUrl: URL;
|
|
try {
|
|
parsedUrl = new URL(url);
|
|
} catch {
|
|
return { success: false, error: 'Invalid URL' };
|
|
}
|
|
|
|
const protocol = parsedUrl.protocol.toLowerCase();
|
|
if (protocol !== 'http:' && protocol !== 'https:' && protocol !== 'mailto:') {
|
|
logger.error(`shell:openExternal - invalid URL scheme: ${url}`);
|
|
return { success: false, error: 'Only http, https, and mailto URLs are allowed' };
|
|
}
|
|
|
|
await shell.openExternal(parsedUrl.toString());
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in shell:openExternal:', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'shell:openPath' IPC call.
|
|
* Opens a folder or file in the system's default application (Finder on macOS).
|
|
* Validates path security before opening.
|
|
* When userSelectedFromDialog is true, path was chosen via system folder picker —
|
|
* only sensitive-pattern checks apply, not project/claude directory restriction.
|
|
*/
|
|
async function handleShellOpenPath(
|
|
_event: IpcMainInvokeEvent,
|
|
targetPath: string,
|
|
projectRoot?: string,
|
|
userSelectedFromDialog?: boolean
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
try {
|
|
const validation = userSelectedFromDialog
|
|
? validateOpenPathUserSelected(targetPath)
|
|
: validateOpenPath(targetPath, projectRoot ?? null);
|
|
if (!validation.valid) {
|
|
logger.error(`shell:openPath - validation failed: ${validation.error ?? 'Unknown error'}`);
|
|
return { success: false, error: validation.error };
|
|
}
|
|
|
|
const safePath = validation.normalizedPath!;
|
|
|
|
// Check if path exists (async to avoid blocking main thread)
|
|
try {
|
|
await fsp.access(safePath);
|
|
} catch {
|
|
logger.error(`shell:openPath - path does not exist: ${safePath}`);
|
|
return { success: false, error: 'Path does not exist' };
|
|
}
|
|
|
|
// Open in default application (Finder on macOS)
|
|
const errorMessage = await shell.openPath(safePath);
|
|
if (errorMessage) {
|
|
logger.error(`shell:openPath - failed: ${errorMessage}`);
|
|
return { success: false, error: errorMessage };
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
logger.error('Error in shell:openPath:', error);
|
|
return { success: false, error: String(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'read-claude-md-files' IPC call.
|
|
* Reads all global CLAUDE.md files for a project.
|
|
*/
|
|
async function handleReadClaudeMdFiles(
|
|
_event: IpcMainInvokeEvent,
|
|
projectRoot: string
|
|
): Promise<Record<string, ClaudeMdFileInfo>> {
|
|
try {
|
|
const result = await readAllClaudeMdFiles(projectRoot);
|
|
// Convert Map to object for IPC serialization
|
|
const files: Record<string, ClaudeMdFileInfo> = {};
|
|
result.files.forEach((info, key) => {
|
|
files[key] = info;
|
|
});
|
|
|
|
return files;
|
|
} catch (error) {
|
|
logger.error(`Error in read-claude-md-files:`, error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'read-directory-claude-md' IPC call.
|
|
* Reads a specific directory's CLAUDE.md file.
|
|
*/
|
|
async function handleReadDirectoryClaudeMd(
|
|
_event: IpcMainInvokeEvent,
|
|
dirPath: string
|
|
): Promise<ClaudeMdFileInfo> {
|
|
try {
|
|
const info = await readDirectoryClaudeMd(dirPath);
|
|
return info;
|
|
} catch (error) {
|
|
logger.error(`Error in read-directory-claude-md:`, error);
|
|
return {
|
|
path: dirPath,
|
|
exists: false,
|
|
charCount: 0,
|
|
estimatedTokens: 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'read-mentioned-file' IPC call.
|
|
* Validates mentioned files for context injection.
|
|
* Returns file info if file exists, is a regular file, within allowed directories, and within token limits.
|
|
*
|
|
* Security: Validates path against allowed directories and sensitive file patterns.
|
|
*/
|
|
async function handleReadMentionedFile(
|
|
_event: IpcMainInvokeEvent,
|
|
absolutePath: string,
|
|
projectRoot: string,
|
|
maxTokens: number = 25000
|
|
): Promise<{ path: string; exists: boolean; charCount: number; estimatedTokens: number } | null> {
|
|
try {
|
|
// Validate path security
|
|
const validation = validateFilePath(absolutePath, projectRoot || null);
|
|
if (!validation.valid) {
|
|
return null;
|
|
}
|
|
|
|
const safePath = validation.normalizedPath!;
|
|
|
|
// Single async stat + read — no TOCTOU, doesn't block main thread
|
|
const stats = await fsp.stat(safePath);
|
|
if (!stats.isFile()) {
|
|
return null;
|
|
}
|
|
|
|
const content = await fsp.readFile(safePath, 'utf8');
|
|
|
|
// Calculate tokens
|
|
const estimatedTokens = countTokens(content);
|
|
|
|
// Check token limit
|
|
if (estimatedTokens > maxTokens) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
path: safePath,
|
|
exists: true,
|
|
charCount: content.length,
|
|
estimatedTokens,
|
|
};
|
|
} catch (error) {
|
|
// ENOENT is expected — file simply doesn't exist (e.g. stale or misdetected references)
|
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
return null;
|
|
}
|
|
logger.error(`Error in read-mentioned-file for ${absolutePath}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handler for 'read-agent-configs' IPC call.
|
|
* Reads agent definitions from project's .claude/agents/ directory.
|
|
*/
|
|
async function handleReadAgentConfigs(
|
|
_event: IpcMainInvokeEvent,
|
|
projectRoot: string
|
|
): Promise<Record<string, AgentConfig>> {
|
|
try {
|
|
return await readAgentConfigs(projectRoot);
|
|
} catch (error) {
|
|
logger.error('Error in read-agent-configs:', error);
|
|
return {};
|
|
}
|
|
}
|