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)
This commit is contained in:
Pedram Amini
2025-12-27 04:05:11 -06:00
parent f85615208d
commit 3b74191af7
3 changed files with 394 additions and 6 deletions

View File

@@ -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
---

View File

@@ -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');
});
});
});

View File

@@ -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<string, string>; // 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