From 3b74191af7cf0b2b93bdf786941d62baefd46885 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sat, 27 Dec 2025 04:05:11 -0600 Subject: [PATCH] MAESTRO: Integrate SSH remote execution in process spawn (Phase 4 Tasks T027-T029) Add SSH remote detection and command wrapping to the process:spawn IPC handler. When an SSH remote is configured (global default or agent-specific override), agent commands are wrapped with SSH for remote execution. Changes: - Import SSH utilities (getSshRemoteConfig, createSshRemoteStoreAdapter, buildSshCommand) - Update MaestroSettings interface with sshRemotes and defaultSshRemoteId fields - Add SSH remote resolution after agent args are built - Wrap command with buildSshCommand when SSH remote is configured - Disable PTY when using SSH (SSH handles terminal emulation) - Pass custom env vars via remote command string, not locally - Terminal sessions always run locally (need PTY for shell interaction) Tests: - 8 new unit tests for SSH remote execution scenarios - All existing tests pass (12,232 tests) --- specs/ssh-remote-agents-tasks.md | 30 +- .../main/ipc/handlers/process.test.ts | 302 ++++++++++++++++++ src/main/ipc/handlers/process.ts | 68 +++- 3 files changed, 394 insertions(+), 6 deletions(-) diff --git a/specs/ssh-remote-agents-tasks.md b/specs/ssh-remote-agents-tasks.md index 9349c609..bc8c4813 100644 --- a/specs/ssh-remote-agents-tasks.md +++ b/specs/ssh-remote-agents-tasks.md @@ -157,9 +157,33 @@ - Falls back through the priority chain gracefully when remotes are missing or disabled - Added 18 unit tests in `src/__tests__/main/utils/ssh-remote-resolver.test.ts` - Tests cover: no remotes configured, global default, agent override, priority ordering, disabled remotes, store adapter -- [ ] T027 [US2] Modify spawn() to detect SSH remote config in src/main/process-manager.ts -- [ ] T028 [US2] Wrap agent command with buildSshCommand when SSH enabled in src/main/process-manager.ts -- [ ] T029 [US2] Pass agent config env vars to remote command in src/main/process-manager.ts +- [x] T027 [US2] Modify spawn() to detect SSH remote config in src/main/process-manager.ts +- [x] T028 [US2] Wrap agent command with buildSshCommand when SSH enabled in src/main/process-manager.ts +- [x] T029 [US2] Pass agent config env vars to remote command in src/main/process-manager.ts + +**Phase 4 (T027-T029) Notes (2025-12-27):** +- Modified `src/main/ipc/handlers/process.ts` to integrate SSH remote execution: + - Added imports for `getSshRemoteConfig`, `createSshRemoteStoreAdapter`, and `buildSshCommand` + - Updated `MaestroSettings` interface to include `sshRemotes` and `defaultSshRemoteId` fields + - Integrated SSH remote detection in `process:spawn` handler after all agent args are built +- SSH remote execution logic: + - Terminal sessions (`toolType === 'terminal'`) are always local (need PTY for shell interaction) + - For AI agents, resolves effective SSH remote using priority chain (agent override > global default) + - When SSH remote is configured, wraps command with `buildSshCommand()` + - Disables PTY when using SSH (SSH handles terminal emulation) + - Passes custom environment variables via the SSH remote command string, not locally + - Uses `remoteWorkingDir` from SSH config when available, otherwise uses local cwd +- Logging: + - Added info log when SSH remote execution is configured with remote details + - Logs original command, wrapped SSH command, and resolution source +- Added 8 unit tests in `src/__tests__/main/ipc/handlers/process.test.ts`: + - Wrap command with SSH when global default remote is configured + - Use agent-specific SSH remote override + - Terminal sessions should not use SSH + - Pass custom env vars to SSH remote command + - Not wrap command when SSH is disabled for agent + - Run locally when no SSH remote is configured + - Use remoteWorkingDir from SSH config when available --- diff --git a/src/__tests__/main/ipc/handlers/process.test.ts b/src/__tests__/main/ipc/handlers/process.test.ts index 5f84321c..5879fb58 100644 --- a/src/__tests__/main/ipc/handlers/process.test.ts +++ b/src/__tests__/main/ipc/handlers/process.test.ts @@ -599,4 +599,306 @@ describe('process IPC handlers', () => { })).rejects.toThrow('Agent detector'); }); }); + + describe('SSH remote execution', () => { + const mockSshRemote = { + id: 'remote-1', + name: 'Dev Server', + host: 'dev.example.com', + port: 22, + username: 'devuser', + privateKeyPath: '~/.ssh/id_ed25519', + enabled: true, + remoteEnv: { REMOTE_VAR: 'remote-value' }, + }; + + it('should wrap agent command with SSH when global default remote is configured', async () => { + const mockAgent = { + id: 'claude-code', + name: 'Claude Code', + requiresPty: false, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockSettingsStore.get.mockImplementation((key, defaultValue) => { + if (key === 'sshRemotes') return [mockSshRemote]; + if (key === 'defaultSshRemoteId') return 'remote-1'; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'claude-code', + cwd: '/local/project', + command: 'claude', + args: ['--print', '--verbose'], + }); + + // Should spawn with 'ssh' command instead of 'claude' + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'ssh', + // SSH args should include authentication and remote command + args: expect.arrayContaining([ + '-i', expect.stringContaining('.ssh/id_ed25519'), + '-p', '22', + 'devuser@dev.example.com', + ]), + // SSH remote execution disables PTY + requiresPty: false, + }) + ); + }); + + it('should use agent-specific SSH remote override', async () => { + const agentSpecificRemote = { + ...mockSshRemote, + id: 'agent-remote', + name: 'Agent-Specific Server', + host: 'agent.example.com', + }; + + const mockAgent = { + id: 'claude-code', + requiresPty: true, // Note: should be disabled when using SSH + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockAgentConfigsStore.get.mockReturnValue({ + 'claude-code': { + sshRemote: { + enabled: true, + remoteId: 'agent-remote', + }, + }, + }); + mockSettingsStore.get.mockImplementation((key, defaultValue) => { + if (key === 'sshRemotes') return [mockSshRemote, agentSpecificRemote]; + if (key === 'defaultSshRemoteId') return 'remote-1'; // Global default is different + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'claude-code', + cwd: '/local/project', + command: 'claude', + args: ['--print'], + }); + + // Should use agent-specific remote, not global default + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'ssh', + args: expect.arrayContaining([ + 'devuser@agent.example.com', // agent-specific host + ]), + // PTY should be disabled for SSH + requiresPty: false, + }) + ); + }); + + it('should not use SSH for terminal sessions', async () => { + const mockAgent = { + id: 'terminal', + requiresPty: true, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockSettingsStore.get.mockImplementation((key, defaultValue) => { + if (key === 'sshRemotes') return [mockSshRemote]; + if (key === 'defaultSshRemoteId') return 'remote-1'; + if (key === 'defaultShell') return 'zsh'; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'terminal', + cwd: '/local/project', + command: '/bin/zsh', + args: [], + }); + + // Terminal sessions should NOT use SSH - they need local PTY + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/bin/zsh', + requiresPty: true, + }) + ); + expect(mockProcessManager.spawn).not.toHaveBeenCalledWith( + expect.objectContaining({ + command: 'ssh', + }) + ); + }); + + it('should pass custom env vars to SSH remote command', async () => { + const mockAgent = { + id: 'claude-code', + requiresPty: false, + }; + + // Mock applyAgentConfigOverrides to return custom env vars + const { applyAgentConfigOverrides } = await import('../../../../main/utils/agent-args'); + vi.mocked(applyAgentConfigOverrides).mockReturnValue({ + args: ['--print'], + modelSource: 'none', + customArgsSource: 'none', + customEnvSource: 'session', + effectiveCustomEnvVars: { CUSTOM_API_KEY: 'secret123' }, + }); + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockSettingsStore.get.mockImplementation((key, defaultValue) => { + if (key === 'sshRemotes') return [mockSshRemote]; + if (key === 'defaultSshRemoteId') return 'remote-1'; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'claude-code', + cwd: '/local/project', + command: 'claude', + args: ['--print'], + sessionCustomEnvVars: { CUSTOM_API_KEY: 'secret123' }, + }); + + // When using SSH, customEnvVars should be undefined (passed via remote command) + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'ssh', + customEnvVars: undefined, // Env vars passed in SSH command, not locally + }) + ); + + // The SSH args should contain the remote command with env vars + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + const remoteCommandArg = spawnCall.args[spawnCall.args.length - 1]; + expect(remoteCommandArg).toContain('CUSTOM_API_KEY='); + }); + + it('should not wrap command when SSH is disabled for agent', async () => { + const mockAgent = { + id: 'claude-code', + requiresPty: false, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockAgentConfigsStore.get.mockReturnValue({ + 'claude-code': { + sshRemote: { + enabled: false, // Explicitly disabled for this agent + remoteId: null, + }, + }, + }); + mockSettingsStore.get.mockImplementation((key, defaultValue) => { + if (key === 'sshRemotes') return [mockSshRemote]; + if (key === 'defaultSshRemoteId') return 'remote-1'; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'claude-code', + cwd: '/local/project', + command: 'claude', + args: ['--print'], + }); + + // Agent has SSH disabled, should run locally + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'claude', // Original command, not 'ssh' + }) + ); + }); + + it('should run locally when no SSH remote is configured', async () => { + const mockAgent = { + id: 'claude-code', + requiresPty: true, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockSettingsStore.get.mockImplementation((key, defaultValue) => { + if (key === 'sshRemotes') return []; // No remotes configured + if (key === 'defaultSshRemoteId') return null; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'claude-code', + cwd: '/local/project', + command: 'claude', + args: ['--print'], + }); + + // No SSH remote, should run locally with original command + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'claude', + requiresPty: true, // Preserved when running locally + }) + ); + }); + + it('should use remoteWorkingDir from SSH config when available', async () => { + const sshRemoteWithWorkDir = { + ...mockSshRemote, + remoteWorkingDir: '/home/devuser/projects', + }; + + const mockAgent = { + id: 'claude-code', + requiresPty: false, + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + mockSettingsStore.get.mockImplementation((key, defaultValue) => { + if (key === 'sshRemotes') return [sshRemoteWithWorkDir]; + if (key === 'defaultSshRemoteId') return 'remote-1'; + return defaultValue; + }); + mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true }); + + const handler = handlers.get('process:spawn'); + await handler!({} as any, { + sessionId: 'session-1', + toolType: 'claude-code', + cwd: '/local/project', // Local cwd should be ignored when remoteWorkingDir is set + command: 'claude', + args: ['--print'], + }); + + // The SSH command should use the remote working directory + expect(mockProcessManager.spawn).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'ssh', + }) + ); + + // Check that the remote command includes the remote working directory + const spawnCall = mockProcessManager.spawn.mock.calls[0][0]; + const remoteCommandArg = spawnCall.args[spawnCall.args.length - 1]; + expect(remoteCommandArg).toContain('/home/devuser/projects'); + }); + }); }); diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index 84803fcc..c492638a 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -10,6 +10,9 @@ import { requireDependency, CreateHandlerOptions, } from '../../utils/ipcHandler'; +import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver'; +import { buildSshCommand } from '../../utils/ssh-command-builder'; +import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../../shared/types'; const LOG_CONTEXT = '[ProcessManager]'; @@ -40,6 +43,9 @@ interface MaestroSettings { customShellPath?: string; // Custom path to shell binary (overrides auto-detected path) shellArgs?: string; // Additional CLI arguments for shell sessions shellEnvVars?: Record; // Environment variables for shell sessions + // SSH remote execution + sshRemotes: SshRemoteConfig[]; + defaultSshRemoteId: string | null; [key: string]: any; } @@ -207,16 +213,72 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // Falls back to the agent's configOptions default (e.g., 400000 for Codex, 128000 for OpenCode) const contextWindow = getContextWindowValue(agent, agentConfigValues, config.sessionCustomContextWindow); + // ======================================================================== + // SSH Remote Execution: Detect and wrap command for remote execution + // Terminal sessions are always local (they need PTY for shell interaction) + // ======================================================================== + let commandToSpawn = config.command; + let argsToSpawn = finalArgs; + let sshRemoteUsed: SshRemoteConfig | null = null; + + // Only consider SSH remote for non-terminal AI agent sessions + if (config.toolType !== 'terminal') { + // Get agent-specific SSH config from agent configs store + const agentSshConfig = agentConfigValues.sshRemote as AgentSshRemoteConfig | undefined; + + // Resolve effective SSH remote configuration + const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore); + const sshResult = getSshRemoteConfig(sshStoreAdapter, { + agentSshConfig, + agentId: config.toolType, + }); + + if (sshResult.config) { + // SSH remote is configured - wrap the command for remote execution + sshRemoteUsed = sshResult.config; + + // Build the SSH command that wraps the agent execution + // The cwd is the local project path which may not exist on remote + // Remote should use remoteWorkingDir from SSH config if set + const sshCommand = buildSshCommand(sshResult.config, { + command: config.command, + args: finalArgs, + // Use the local cwd - the SSH command builder will handle remote path resolution + // If SSH config has remoteWorkingDir, that takes precedence + cwd: sshResult.config.remoteWorkingDir ? undefined : config.cwd, + // Pass custom environment variables to the remote command + env: effectiveCustomEnvVars, + }); + + commandToSpawn = sshCommand.command; + argsToSpawn = sshCommand.args; + + logger.info(`SSH remote execution configured`, LOG_CONTEXT, { + sessionId: config.sessionId, + toolType: config.toolType, + remoteName: sshResult.config.name, + remoteHost: sshResult.config.host, + source: sshResult.source, + originalCommand: config.command, + sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`, + }); + } + } + const result = processManager.spawn({ ...config, - args: finalArgs, - requiresPty: agent?.requiresPty, + command: commandToSpawn, + args: argsToSpawn, + // When using SSH, disable PTY (SSH provides its own terminal handling) + // and env vars are passed via the remote command string + requiresPty: sshRemoteUsed ? false : agent?.requiresPty, prompt: config.prompt, shell: shellToUse, shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions) shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions) contextWindow, // Pass configured context window to process manager - customEnvVars: effectiveCustomEnvVars, // Pass custom env vars (session-level or agent-level) + // When using SSH, env vars are passed in the remote command string, not locally + customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars, imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode) noPromptSeparator: agent?.noPromptSeparator, // OpenCode doesn't support '--' before prompt // Stats tracking: use cwd as projectPath if not explicitly provided