MAESTRO: Add SSH remote support to Auto Run IPC handlers

Add SSH support to core Auto Run document operations for remote file
access when sessions are running on SSH remote hosts.

Changes:
- autorun.ts: Added imports for Store, SshRemoteConfig, remote-fs utils
- autorun.ts: Added AutorunHandlerDependencies interface with settingsStore
- autorun.ts: Created getSshRemoteById helper and scanDirectoryRemote function
- autorun.ts: Updated listDocs handler to scan remote directories via SSH
- autorun.ts: Updated readDoc handler to read files via readFileRemote()
- autorun.ts: Updated writeDoc handler to write files via writeFileRemote()
- autorun.ts: Updated watchFolder to return isRemote: true for remote sessions
  (chokidar cannot watch remote directories, UI should poll instead)
- index.ts: Pass settingsStore to registerAutorunHandlers()
- preload.ts: Added sshRemoteId parameter to listDocs, readDoc, writeDoc, watchFolder
- global.d.ts: Updated type signatures with sshRemoteId parameter

This enables Phase 5 tasks 5.1 and 5.3 of SSH remote support. Task 5.2
(updating AutoRun.tsx component) remains to wire up the SSH context.
This commit is contained in:
Pedram Amini
2025-12-30 02:32:06 -06:00
parent 8b0fd296ac
commit 0c0f810a10
4 changed files with 215 additions and 20 deletions

View File

@@ -1004,6 +1004,7 @@ function setupIpcHandlers() {
mainWindow,
getMainWindow: () => mainWindow,
app,
settingsStore: store,
});
// Playbook operations - extracted to src/main/ipc/handlers/playbooks.ts

View File

@@ -2,8 +2,18 @@ import { ipcMain, BrowserWindow, App } from 'electron';
import fs from 'fs/promises';
import path from 'path';
import chokidar, { FSWatcher } from 'chokidar';
import Store from 'electron-store';
import { logger } from '../../utils/logger';
import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler';
import { SshRemoteConfig } from '../../../shared/types';
import { MaestroSettings } from './persistence';
import {
readDirRemote,
readFileRemote,
writeFileRemote,
existsRemote,
mkdirRemote,
} from '../../utils/remote-fs';
const LOG_CONTEXT = '[AutoRun]';
@@ -14,6 +24,31 @@ const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions
logSuccess,
});
/**
* Dependencies required for Auto Run handler registration.
* Optional for backward compatibility - SSH remote support requires settingsStore.
*/
export interface AutorunHandlerDependencies {
/** The settings store (MaestroSettings) - required for SSH remote lookup */
settingsStore?: Store<MaestroSettings>;
}
/**
* Get SSH remote configuration by ID from the settings store.
* Returns undefined if not found or store not provided.
*/
function getSshRemoteById(
store: Store<MaestroSettings> | undefined,
sshRemoteId: string
): SshRemoteConfig | undefined {
if (!store) {
logger.warn(`${LOG_CONTEXT} Settings store not available for SSH remote lookup`, LOG_CONTEXT);
return undefined;
}
const sshRemotes = store.get('sshRemotes', []) as SshRemoteConfig[];
return sshRemotes.find((r) => r.id === sshRemoteId);
}
// State managed by this module
const autoRunWatchers = new Map<string, FSWatcher>();
let autoRunWatchDebounceTimer: NodeJS.Timeout | null = null;
@@ -79,6 +114,61 @@ async function scanDirectory(dirPath: string, relativePath: string = ''): Promis
return nodes;
}
/**
* Recursively scan directory for markdown files on a remote host via SSH.
* This is the SSH version of scanDirectory.
*/
async function scanDirectoryRemote(
dirPath: string,
sshRemote: SshRemoteConfig,
relativePath: string = ''
): Promise<TreeNode[]> {
const result = await readDirRemote(dirPath, sshRemote);
if (!result.success || !result.data) {
logger.warn(`${LOG_CONTEXT} Failed to read remote directory: ${result.error}`, LOG_CONTEXT);
return [];
}
const nodes: TreeNode[] = [];
// Sort entries: folders first, then files, both alphabetically
const sortedEntries = result.data
.filter((entry) => !entry.name.startsWith('.'))
.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
for (const entry of sortedEntries) {
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
if (entry.isDirectory) {
// Recursively scan subdirectory
// Use forward slashes for remote paths (Unix style)
const children = await scanDirectoryRemote(`${dirPath}/${entry.name}`, sshRemote, entryRelativePath);
// Only include folders that contain .md files (directly or in subfolders)
if (children.length > 0) {
nodes.push({
name: entry.name,
type: 'folder',
path: entryRelativePath,
children,
});
}
} else if (!entry.isDirectory && !entry.isSymlink && entry.name.toLowerCase().endsWith('.md')) {
// Add .md file (without extension in name, but keep in path)
nodes.push({
name: entry.name.slice(0, -3),
type: 'file',
path: entryRelativePath.slice(0, -3), // Remove .md from path too
});
}
}
return nodes;
}
/**
* Flatten tree structure to flat list of paths.
*
@@ -119,19 +209,37 @@ function validatePathWithinFolder(filePath: string, folderPath: string): boolean
* - Image management (save, delete, list)
* - Folder watching for external changes
* - Folder deletion (wizard "start fresh" feature)
*
* SSH remote support: Handlers accept optional sshRemoteId parameter for remote file operations.
*/
export function registerAutorunHandlers(deps: {
mainWindow: BrowserWindow | null;
getMainWindow: () => BrowserWindow | null;
app: App;
}): void {
const { getMainWindow, app } = deps;
} & AutorunHandlerDependencies): void {
const { getMainWindow, app, settingsStore } = deps;
// List markdown files in a directory for Auto Run (with recursive subfolder support)
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle(
'autorun:listDocs',
createIpcHandler(handlerOpts('listDocs'), async (folderPath: string) => {
// Validate the folder path exists
createIpcHandler(handlerOpts('listDocs'), async (folderPath: string, sshRemoteId?: string) => {
// SSH remote: dispatch to remote operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(settingsStore, sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
logger.debug(`${LOG_CONTEXT} listDocs via SSH: ${folderPath}`, LOG_CONTEXT);
const tree = await scanDirectoryRemote(folderPath, sshConfig);
const files = flattenTree(tree);
logger.info(`Listed ${files.length} remote markdown files in ${folderPath} (with subfolders)`, LOG_CONTEXT);
return { files, tree };
}
// Local: Validate the folder path exists
const folderStat = await fs.stat(folderPath);
if (!folderStat.isDirectory()) {
throw new Error('Path is not a directory');
@@ -146,9 +254,10 @@ export function registerAutorunHandlers(deps: {
);
// Read a markdown document for Auto Run (supports subdirectories)
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle(
'autorun:readDoc',
createIpcHandler(handlerOpts('readDoc'), async (folderPath: string, filename: string) => {
createIpcHandler(handlerOpts('readDoc'), async (folderPath: string, filename: string, sshRemoteId?: string) => {
// Reject obvious traversal attempts
if (filename.includes('..')) {
throw new Error('Invalid filename');
@@ -157,6 +266,27 @@ export function registerAutorunHandlers(deps: {
// Ensure filename has .md extension
const fullFilename = filename.endsWith('.md') ? filename : `${filename}.md`;
// SSH remote: dispatch to remote operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(settingsStore, sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
// Construct remote path (use forward slashes)
const remotePath = `${folderPath}/${fullFilename}`;
logger.debug(`${LOG_CONTEXT} readDoc via SSH: ${remotePath}`, LOG_CONTEXT);
const result = await readFileRemote(remotePath, sshConfig);
if (!result.success || result.data === undefined) {
throw new Error(result.error || 'Failed to read remote file');
}
logger.info(`Read remote Auto Run doc: ${fullFilename}`, LOG_CONTEXT);
return { content: result.data };
}
// Local: Validate and read
const filePath = path.join(folderPath, fullFilename);
// Validate the file is within the folder path (prevent traversal)
@@ -180,9 +310,10 @@ export function registerAutorunHandlers(deps: {
);
// Write a markdown document for Auto Run (supports subdirectories)
// Supports SSH remote execution via optional sshRemoteId parameter
ipcMain.handle(
'autorun:writeDoc',
createIpcHandler(handlerOpts('writeDoc'), async (folderPath: string, filename: string, content: string) => {
createIpcHandler(handlerOpts('writeDoc'), async (folderPath: string, filename: string, content: string, sshRemoteId?: string) => {
// DEBUG: Log all write attempts to trace cross-session contamination
logger.info(
`[DEBUG] writeDoc called: folder=${folderPath}, file=${filename}, content.length=${content.length}, content.slice(0,50)="${content.slice(0, 50).replace(/\n/g, '\\n')}"`,
@@ -198,6 +329,40 @@ export function registerAutorunHandlers(deps: {
// Ensure filename has .md extension
const fullFilename = filename.endsWith('.md') ? filename : `${filename}.md`;
// SSH remote: dispatch to remote operations
if (sshRemoteId) {
const sshConfig = getSshRemoteById(settingsStore, sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
// Construct remote path (use forward slashes)
const remotePath = `${folderPath}/${fullFilename}`;
// Ensure parent directory exists on remote
const remoteParentDir = remotePath.substring(0, remotePath.lastIndexOf('/'));
if (remoteParentDir && remoteParentDir !== folderPath) {
const parentExists = await existsRemote(remoteParentDir, sshConfig);
if (!parentExists.success || !parentExists.data) {
const mkdirResult = await mkdirRemote(remoteParentDir, sshConfig, true);
if (!mkdirResult.success) {
throw new Error(mkdirResult.error || 'Failed to create remote parent directory');
}
}
}
logger.debug(`${LOG_CONTEXT} writeDoc via SSH: ${remotePath}`, LOG_CONTEXT);
const result = await writeFileRemote(remotePath, content, sshConfig);
if (!result.success) {
throw new Error(result.error || 'Failed to write remote file');
}
logger.info(`Wrote remote Auto Run doc: ${fullFilename}`, LOG_CONTEXT);
return {};
}
// Local: Validate and write
const filePath = path.join(folderPath, fullFilename);
// Validate the file is within the folder path (prevent traversal)
@@ -402,10 +567,35 @@ export function registerAutorunHandlers(deps: {
);
// Start watching an Auto Run folder for changes
// Supports SSH remote execution via optional sshRemoteId parameter
// For remote sessions, file watching is not supported (chokidar can't watch remote directories)
// Returns isRemote: true to indicate the UI should poll using listDocs instead
ipcMain.handle(
'autorun:watchFolder',
createIpcHandler(handlerOpts('watchFolder'), async (folderPath: string) => {
// Stop any existing watcher for this folder
createIpcHandler(handlerOpts('watchFolder'), async (folderPath: string, sshRemoteId?: string) => {
// SSH remote: Cannot use chokidar for remote directories
// Return success with isRemote flag so UI can fall back to polling
if (sshRemoteId) {
const sshConfig = getSshRemoteById(settingsStore, sshRemoteId);
if (!sshConfig) {
throw new Error(`SSH remote not found: ${sshRemoteId}`);
}
// Ensure remote folder exists (create if not)
const folderExists = await existsRemote(folderPath, sshConfig);
if (!folderExists.success || !folderExists.data) {
const mkdirResult = await mkdirRemote(folderPath, sshConfig, true);
if (!mkdirResult.success) {
throw new Error(mkdirResult.error || 'Failed to create remote Auto Run folder');
}
logger.info(`Created remote Auto Run folder: ${folderPath}`, LOG_CONTEXT);
}
logger.info(`Remote Auto Run folder ready (polling mode): ${folderPath}`, LOG_CONTEXT);
return { isRemote: true, message: 'File watching not available for remote sessions. Use polling.' };
}
// Local: Stop any existing watcher for this folder
if (autoRunWatchers.has(folderPath)) {
autoRunWatchers.get(folderPath)?.close();
autoRunWatchers.delete(folderPath);

View File

@@ -1175,13 +1175,14 @@ contextBridge.exposeInMainWorld('maestro', {
},
// Auto Run API (file-system-based document runner)
// SSH remote support: Core operations accept optional sshRemoteId for remote file operations
autorun: {
listDocs: (folderPath: string) =>
ipcRenderer.invoke('autorun:listDocs', folderPath),
readDoc: (folderPath: string, filename: string) =>
ipcRenderer.invoke('autorun:readDoc', folderPath, filename),
writeDoc: (folderPath: string, filename: string, content: string) =>
ipcRenderer.invoke('autorun:writeDoc', folderPath, filename, content),
listDocs: (folderPath: string, sshRemoteId?: string) =>
ipcRenderer.invoke('autorun:listDocs', folderPath, sshRemoteId),
readDoc: (folderPath: string, filename: string, sshRemoteId?: string) =>
ipcRenderer.invoke('autorun:readDoc', folderPath, filename, sshRemoteId),
writeDoc: (folderPath: string, filename: string, content: string, sshRemoteId?: string) =>
ipcRenderer.invoke('autorun:writeDoc', folderPath, filename, content, sshRemoteId),
saveImage: (
folderPath: string,
docName: string,
@@ -1202,8 +1203,9 @@ contextBridge.exposeInMainWorld('maestro', {
deleteFolder: (projectPath: string) =>
ipcRenderer.invoke('autorun:deleteFolder', projectPath),
// File watching for live updates
watchFolder: (folderPath: string) =>
ipcRenderer.invoke('autorun:watchFolder', folderPath),
// For remote sessions (sshRemoteId provided), returns isRemote: true indicating polling should be used
watchFolder: (folderPath: string, sshRemoteId?: string): Promise<{ isRemote?: boolean; message?: string }> =>
ipcRenderer.invoke('autorun:watchFolder', folderPath, sshRemoteId),
unwatchFolder: (folderPath: string) =>
ipcRenderer.invoke('autorun:unwatchFolder', folderPath),
onFileChanged: (handler: (data: { folderPath: string; filename: string; eventType: string }) => void) => {

View File

@@ -872,21 +872,23 @@ interface MaestroAPI {
getPath: (sessionId: string) => Promise<{ success: boolean; path: string }>;
};
// Auto Run file operations
// SSH remote support: Core operations accept optional sshRemoteId for remote file operations
autorun: {
listDocs: (folderPath: string) => Promise<{
listDocs: (folderPath: string, sshRemoteId?: string) => Promise<{
success: boolean;
files: string[];
tree?: AutoRunTreeNode[];
error?: string;
}>;
readDoc: (folderPath: string, filename: string) => Promise<{ success: boolean; content?: string; error?: string }>;
writeDoc: (folderPath: string, filename: string, content: string) => Promise<{ success: boolean; error?: string }>;
readDoc: (folderPath: string, filename: string, sshRemoteId?: string) => Promise<{ success: boolean; content?: string; error?: string }>;
writeDoc: (folderPath: string, filename: string, content: string, sshRemoteId?: string) => Promise<{ success: boolean; error?: string }>;
saveImage: (folderPath: string, docName: string, base64Data: string, extension: string) => Promise<{ success: boolean; relativePath?: string; error?: string }>;
deleteImage: (folderPath: string, relativePath: string) => Promise<{ success: boolean; error?: string }>;
listImages: (folderPath: string, docName: string) => Promise<{ success: boolean; images?: Array<{ filename: string; relativePath: string }>; error?: string }>;
deleteFolder: (projectPath: string) => Promise<{ success: boolean; error?: string }>;
// File watching for live updates
watchFolder: (folderPath: string) => Promise<{ success: boolean; error?: string }>;
// For remote sessions (sshRemoteId provided), returns isRemote: true indicating polling should be used
watchFolder: (folderPath: string, sshRemoteId?: string) => Promise<{ success: boolean; isRemote?: boolean; message?: string; error?: string }>;
unwatchFolder: (folderPath: string) => Promise<{ success: boolean; error?: string }>;
onFileChanged: (handler: (data: { folderPath: string; filename: string; eventType: string }) => void) => () => void;
// Backup operations for reset-on-completion documents (legacy)