mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user