fix(ssh): stabilize and complete SSH remote execution support across wizard, IPC, and agent detection

- Added full SSH remote support to the wizard, including propagation of SSH config through all phases
- Ensured SSH config flows correctly through IPC bridge (renderer → preload → main)
- Improved agent detection with optional sshRemoteId and remote `which` resolution
- Fixed SSH config transfer during manual agent creation and auto-selection flows
- Added extensive debugging and structured logging for SSH execution, agent availability, and wizard message flow
- Improved stdin/JSON streaming for large prompts and remote execution
- Added input/output JSON stream handling and enhanced logging for SSH transport
- Added sourcing of bash profiles to correctly resolve claude-code binary paths on remote hosts
This commit is contained in:
chr1syy
2026-01-12 07:33:05 +00:00
parent 1f938021fb
commit ab7bf27d03
7 changed files with 550 additions and 335 deletions

View File

@@ -305,12 +305,112 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
})
);
// Get a specific agent by ID
// Get a specific agent by ID (supports SSH remote detection via optional sshRemoteId)
ipcMain.handle(
'agents:get',
withIpcErrorLogging(handlerOpts('get'), async (agentId: string) => {
withIpcErrorLogging(handlerOpts('get'), async (agentId: string, sshRemoteId?: string) => {
logger.debug(`Getting agent: ${agentId}`, LOG_CONTEXT, { sshRemoteId });
// If SSH remote ID provided, detect agent on remote host
if (sshRemoteId) {
const sshConfig = getSshRemoteById(settingsStore, sshRemoteId);
if (!sshConfig) {
logger.warn(`SSH remote not found or disabled: ${sshRemoteId}`, LOG_CONTEXT);
// Return the agent definition with unavailable status
const agentDef = AGENT_DEFINITIONS.find((a) => a.id === agentId);
if (!agentDef) {
throw new Error(`Unknown agent: ${agentId}`);
}
return stripAgentFunctions({
...agentDef,
available: false,
path: undefined,
capabilities: getAgentCapabilities(agentDef.id),
error: `SSH remote configuration not found: ${sshRemoteId}`,
});
}
logger.info(`Getting agent ${agentId} on remote host: ${sshConfig.host}`, LOG_CONTEXT);
// Find the agent definition
const agentDef = AGENT_DEFINITIONS.find((a) => a.id === agentId);
if (!agentDef) {
throw new Error(`Unknown agent: ${agentId}`);
}
// Build SSH command to check for the binary using 'which'
const remoteOptions: RemoteCommandOptions = {
command: 'which',
args: [agentDef.binaryName],
};
try {
const sshCommand = await buildSshCommand(sshConfig, remoteOptions);
logger.info(`Executing SSH detection command for '${agentDef.binaryName}'`, LOG_CONTEXT, {
command: sshCommand.command,
args: sshCommand.args,
});
// Execute with timeout
const SSH_TIMEOUT_MS = 10000;
const resultPromise = execFileNoThrow(sshCommand.command, sshCommand.args);
const timeoutPromise = new Promise<{ exitCode: number; stdout: string; stderr: string }>((_, reject) => {
setTimeout(() => reject(new Error(`SSH connection timed out after ${SSH_TIMEOUT_MS / 1000}s`)), SSH_TIMEOUT_MS);
});
const result = await Promise.race([resultPromise, timeoutPromise]);
logger.info(`SSH command result for '${agentDef.binaryName}'`, LOG_CONTEXT, {
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
});
// Check for SSH connection errors
let connectionError: string | undefined;
if (result.stderr && (
result.stderr.includes('Connection refused') ||
result.stderr.includes('Connection timed out') ||
result.stderr.includes('No route to host') ||
result.stderr.includes('Could not resolve hostname') ||
result.stderr.includes('Permission denied')
)) {
connectionError = result.stderr.trim().split('\n')[0];
logger.warn(`SSH connection error for ${sshConfig.host}: ${connectionError}`, LOG_CONTEXT);
}
// Strip ANSI/OSC escape sequences from output
const cleanedOutput = stripAnsi(result.stdout);
const available = result.exitCode === 0 && cleanedOutput.trim().length > 0;
const path = available ? cleanedOutput.trim().split('\n')[0] : undefined;
if (available) {
logger.info(`Agent "${agentDef.name}" found on remote at: ${path}`, LOG_CONTEXT);
} else {
logger.debug(`Agent "${agentDef.name}" not found on remote`, LOG_CONTEXT);
}
return stripAgentFunctions({
...agentDef,
available,
path,
capabilities: getAgentCapabilities(agentDef.id),
error: connectionError,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to check agent "${agentDef.name}" on remote: ${errorMessage}`, LOG_CONTEXT);
return stripAgentFunctions({
...agentDef,
available: false,
capabilities: getAgentCapabilities(agentDef.id),
error: `Failed to connect: ${errorMessage}`,
});
}
}
// Local detection
const agentDetector = requireDependency(getAgentDetector, 'Agent detector');
logger.debug(`Getting agent: ${agentId}`, LOG_CONTEXT);
const agent = await agentDetector.getAgent(agentId);
// Strip argBuilder functions before sending over IPC
return stripAgentFunctions(agent);

View File

@@ -20,6 +20,7 @@ import { buildSshCommand } from '../../utils/ssh-command-builder';
import type { SshRemoteConfig } from '../../../shared/types';
import { powerManager } from '../../power-manager';
import { MaestroSettings } from './persistence';
import { getAgentCapabilities } from '../../agent-capabilities';
const LOG_CONTEXT = '[ProcessManager]';
@@ -125,15 +126,19 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
hasPrompt: !!config.prompt,
promptLength: config.prompt?.length,
// On Windows, show prompt preview to help debug truncation issues
promptPreview:
config.prompt && isWindows
? {
promptPreview: config.prompt && isWindows ? {
first50: config.prompt.substring(0, 50),
last50: config.prompt.substring(Math.max(0, config.prompt.length - 50)),
containsHash: config.prompt.includes('#'),
containsNewline: config.prompt.includes('\n'),
}
: undefined,
} : undefined,
// SSH remote config logging
hasSessionSshRemoteConfig: !!config.sessionSshRemoteConfig,
sessionSshRemoteConfig: config.sessionSshRemoteConfig ? {
enabled: config.sessionSshRemoteConfig.enabled,
remoteId: config.sessionSshRemoteConfig.remoteId,
hasWorkingDirOverride: !!config.sessionSshRemoteConfig.workingDirOverride,
} : null,
});
let finalArgs = buildAgentArgs(agent, {
baseArgs: config.args,
@@ -257,12 +262,22 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
// Terminal sessions are always local (they need PTY for shell interaction)
// ========================================================================
let sshRemoteUsed: SshRemoteConfig | null = null;
let shouldSendPromptViaStdin = false;
// Only consider SSH remote for non-terminal AI agent sessions
// SSH is session-level ONLY - no agent-level or global defaults
// Log SSH evaluation on Windows for debugging
if (isWindows) {
logger.info(`Evaluating SSH remote config`, LOG_CONTEXT, {
toolType: config.toolType,
isTerminal: config.toolType === 'terminal',
hasSessionSshRemoteConfig: !!config.sessionSshRemoteConfig,
willUseSsh: config.toolType !== 'terminal' && !!config.sessionSshRemoteConfig,
});
}
if (config.toolType !== 'terminal' && config.sessionSshRemoteConfig) {
// Session-level SSH config provided - resolve and use it
logger.debug(`Using session-level SSH config`, LOG_CONTEXT, {
logger.info(`Using session-level SSH config`, LOG_CONTEXT, {
sessionId: config.sessionId,
enabled: config.sessionSshRemoteConfig.enabled,
remoteId: config.sessionSshRemoteConfig.remoteId,
@@ -278,11 +293,22 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
// SSH remote is configured - wrap the command for remote execution
sshRemoteUsed = sshResult.config;
// For SSH execution, we need to include the prompt in the args here
// because ProcessManager.spawn() won't add it (we pass prompt: undefined for SSH)
// Use promptArgs if available (e.g., OpenCode -p), otherwise use positional arg
// For SSH execution with stream-json capable agents (like Claude Code),
// we send the prompt via stdin instead of as a CLI arg to avoid command
// line length limits and escaping issues. For other agents, we include
// the prompt in the args as before.
const capabilities = getAgentCapabilities(config.toolType);
let sshArgs = finalArgs;
if (config.prompt) {
// If agent supports stream-json input, send prompt via stdin
// ProcessManager will detect this and send the prompt as a JSON message
if (capabilities.supportsStreamJsonInput) {
shouldSendPromptViaStdin = true;
// Add --input-format stream-json flag so Claude knows to read from stdin
sshArgs = [...finalArgs, '--input-format', 'stream-json'];
} else {
// For agents that don't support stream-json, add prompt to args
if (agent?.promptArgs) {
sshArgs = [...finalArgs, ...agent.promptArgs(config.prompt)];
} else if (agent?.noPromptSeparator) {
@@ -290,6 +316,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
} else {
sshArgs = [...finalArgs, '--', config.prompt];
}
}
}
// Build the SSH command that wraps the agent execution
@@ -321,9 +348,9 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
localCommand: config.command,
remoteCommand: remoteCommand,
customPath: config.sessionCustomPath || null,
hasCustomEnvVars:
!!effectiveCustomEnvVars && Object.keys(effectiveCustomEnvVars).length > 0,
hasCustomEnvVars: !!effectiveCustomEnvVars && Object.keys(effectiveCustomEnvVars).length > 0,
sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`,
promptViaStdin: shouldSendPromptViaStdin,
});
}
}
@@ -339,9 +366,10 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
// 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,
// When using SSH, the prompt was already added to sshArgs above before
// building the SSH command, so don't let ProcessManager add it again
prompt: sshRemoteUsed ? undefined : config.prompt,
// When using SSH with stream-json capable agents, pass the prompt so
// ProcessManager can send it via stdin. For other SSH cases, prompt was
// already added to sshArgs, so pass undefined to prevent double-adding.
prompt: sshRemoteUsed && shouldSendPromptViaStdin ? config.prompt : sshRemoteUsed ? undefined : config.prompt,
shell: shellToUse,
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
@@ -354,6 +382,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
// Stats tracking: use cwd as projectPath if not explicitly provided
projectPath: config.cwd,
// SSH remote context (for SSH-specific error messages)
sshRemoteConfig: sshRemoteUsed,
sshRemoteId: sshRemoteUsed?.id,
sshRemoteHost: sshRemoteUsed?.host,
});

View File

@@ -238,18 +238,19 @@ export async function buildSshCommand(
});
// Wrap the command to execute via the user's login shell.
// $SHELL -ilc ensures the user's full PATH (including homebrew, nvm, etc.) is available.
// -i forces interactive mode (critical for .bashrc to not bail out)
// -l loads login profile for PATH
// We use bash -lc to ensure the user's full PATH (including homebrew, nvm, etc.) is available.
// -l loads login profile for PATH (sources /etc/profile, ~/.bash_profile, etc.)
// -c executes the command
// Using $SHELL respects the user's configured shell (bash, zsh, etc.)
//
// WHY -i IS CRITICAL:
// On Ubuntu (and many Linux distros), .bashrc has a guard at the top:
// IMPORTANT: We don't use -i (interactive) flag because:
// 1. It can cause issues with shells that check if stdin is a TTY
// 2. When using -tt flag, the shell already thinks it's interactive enough
// 3. On Ubuntu, we work around the "case $- in *i*)" guard by sourcing ~/.bashrc explicitly
//
// For bash specifically, we source ~/.bashrc to ensure user PATH additions are loaded.
// This handles the common Ubuntu pattern where .bashrc has:
// case $- in *i*) ;; *) return;; esac
// This checks if the shell is interactive before running. Without -i,
// .bashrc exits early and user PATH additions (like ~/.local/bin) never load.
// The -i flag sets 'i' in $-, allowing .bashrc to run fully.
// By explicitly sourcing it, we bypass this guard.
//
// CRITICAL: When Node.js spawn() passes this to SSH without shell:true, SSH runs
// the command through the remote's default shell. The key is escaping:
@@ -261,12 +262,15 @@ export async function buildSshCommand(
// Example transformation for spawn():
// Input: cd '/path' && MYVAR='value' claude --print
// After escaping: cd '/path' && MYVAR='value' claude --print (no $ to escape here)
// Wrapped: $SHELL -ilc "cd '/path' && MYVAR='value' claude --print"
// Wrapped: bash -lc "source ~/.bashrc 2>/dev/null; cd '/path' && MYVAR='value' claude --print"
// SSH receives this as one argument, passes to remote shell
// Remote shell expands $SHELL, executes: /bin/zsh -ilc "cd '/path' ..."
// The login shell runs with full PATH from ~/.zprofile AND ~/.bashrc
// The login shell runs with full PATH from /etc/profile, ~/.bash_profile, AND ~/.bashrc
const escapedCommand = shellEscapeForDoubleQuotes(remoteCommand);
const wrappedCommand = `$SHELL -ilc "${escapedCommand}"`;
// Source login/profile files first so PATH modifications made in
// ~/.bash_profile or ~/.profile are available for non-interactive
// remote commands, then source ~/.bashrc to cover interactive
// additions that might also be present.
const wrappedCommand = `bash -lc "source ~/.bash_profile 2>/dev/null || source ~/.profile 2>/dev/null; source ~/.bashrc 2>/dev/null; ${escapedCommand}"`;
args.push(wrappedCommand);
// Debug logging to trace the exact command being built

View File

@@ -596,6 +596,17 @@ export function NewInstanceModal({
}
}, [isOpen]);
// Transfer pending SSH config to selected agent automatically
// This ensures SSH config is preserved when agent is auto-selected or manually clicked
useEffect(() => {
if (selectedAgent && agentSshRemoteConfigs['_pending_'] && !agentSshRemoteConfigs[selectedAgent]) {
setAgentSshRemoteConfigs(prev => ({
...prev,
[selectedAgent]: prev['_pending_'],
}));
}
}, [selectedAgent, agentSshRemoteConfigs]);
// Track the current SSH remote ID for re-detection
// Uses _pending_ key when no agent is selected, which is the shared SSH config
const currentSshRemoteId = useMemo(() => {

View File

@@ -685,6 +685,7 @@ export function ConversationScreen({
directoryPath: state.directoryPath,
projectName: state.agentName || 'My Project',
existingDocs: existingDocs.length > 0 ? existingDocs : undefined,
sessionSshRemoteConfig: state.sessionSshRemoteConfig,
});
if (mounted) {
@@ -1066,6 +1067,7 @@ export function ConversationScreen({
directoryPath: state.directoryPath,
projectName: state.agentName || 'My Project',
existingDocs: existingDocs.length > 0 ? existingDocs : undefined,
sessionSshRemoteConfig: state.sessionSshRemoteConfig,
});
}

View File

@@ -37,6 +37,12 @@ export interface ConversationConfig {
projectName: string;
/** Existing Auto Run documents (when continuing from previous session) */
existingDocs?: ExistingDocument[];
/** SSH remote configuration for remote agent execution */
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
}
/**
@@ -112,6 +118,12 @@ interface ConversationSession {
toolExecutionListenerCleanup?: () => void;
/** Timeout ID for response timeout (for cleanup) */
responseTimeoutId?: NodeJS.Timeout;
/** SSH remote configuration for remote execution */
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
}
/**
@@ -162,6 +174,7 @@ class ConversationManager {
isActive: true,
systemPrompt,
outputBuffer: '',
sessionSshRemoteConfig: config.sessionSshRemoteConfig,
};
// Log conversation start
@@ -172,6 +185,8 @@ class ConversationManager {
projectName: config.projectName,
hasExistingDocs: !!config.existingDocs,
existingDocsCount: config.existingDocs?.length || 0,
hasRemoteSsh: !!config.sessionSshRemoteConfig?.enabled,
remoteId: config.sessionSshRemoteConfig?.remoteId || null,
});
return sessionId;
@@ -219,12 +234,51 @@ class ConversationManager {
try {
// Get the agent configuration
const agent = await window.maestro.agents.get(this.session.agentType);
// Pass SSH remote ID if SSH is enabled for this session
const sshRemoteId = this.session.sessionSshRemoteConfig?.enabled
? this.session.sessionSshRemoteConfig.remoteId
: undefined;
wizardDebugLogger.log('info', 'Fetching agent configuration', {
agentType: this.session.agentType,
sessionId: this.session.sessionId,
hasRemoteSsh: !!this.session.sessionSshRemoteConfig?.enabled,
remoteId: this.session.sessionSshRemoteConfig?.remoteId || null,
passingSshRemoteId: sshRemoteId || null,
});
const agent = await window.maestro.agents.get(this.session.agentType, sshRemoteId || undefined);
// Log to main process (writes to maestro-debug.log on Windows)
console.log('[Wizard] Agent fetch result:', {
agentType: this.session.agentType,
agentExists: !!agent,
agentAvailable: agent?.available,
agentPath: agent?.path,
agentCommand: agent?.command,
hasRemoteSsh: !!this.session.sessionSshRemoteConfig?.enabled,
});
if (!agent || !agent.available) {
const error = `Agent ${this.session.agentType} is not available`;
// Log detailed info about why agent is unavailable
console.error('[Wizard] Agent not available - Details:', {
agentType: this.session.agentType,
agentExists: !!agent,
agentAvailable: agent?.available,
agentPath: agent?.path,
agentError: (agent as any)?.error,
sessionSshConfig: this.session.sessionSshRemoteConfig,
});
wizardDebugLogger.log('error', 'Agent not available', {
agentType: this.session.agentType,
agent: agent ? { available: agent.available } : null,
agent: agent ? {
available: agent.available,
path: agent.path,
error: (agent as any).error
} : null,
});
return {
success: false,
@@ -232,6 +286,8 @@ class ConversationManager {
};
}
console.log('[Wizard] Agent is available, building prompt...');
// Build the full prompt with conversation context
const fullPrompt = this.buildPromptWithContext(userMessage, conversationHistory);
@@ -487,6 +543,8 @@ class ConversationManager {
agentCommand: agent.command,
args: argsForSpawn,
cwd: this.session!.directoryPath,
hasRemoteSsh: !!this.session!.sessionSshRemoteConfig?.enabled,
remoteId: this.session!.sessionSshRemoteConfig?.remoteId || null,
});
window.maestro.process
@@ -497,6 +555,7 @@ class ConversationManager {
command: commandToUse,
args: argsForSpawn,
prompt: prompt,
sessionSshRemoteConfig: this.session!.sessionSshRemoteConfig,
})
.then(() => {
wizardDebugLogger.log('spawn', 'Agent process spawned successfully', {
@@ -519,6 +578,7 @@ class ConversationManager {
});
}
/**
* Build CLI args for the agent based on its type and capabilities.
*

View File

@@ -28,6 +28,12 @@ export interface GenerationConfig {
conversationHistory: WizardMessage[];
/** Optional subfolder within Auto Run Docs (e.g., "Initiation") */
subfolder?: string;
/** SSH remote configuration for remote agent execution */
sessionSshRemoteConfig?: {
enabled: boolean;
remoteId: string | null;
workingDirOverride?: string;
};
}
/**
@@ -1063,6 +1069,8 @@ class PhaseGenerator {
agentCommand: agent.command,
argsCount: argsForSpawn.length,
promptLength: prompt.length,
hasRemoteSsh: !!config.sessionSshRemoteConfig?.enabled,
remoteId: config.sessionSshRemoteConfig?.remoteId || null,
});
window.maestro.process
.spawn({
@@ -1072,6 +1080,7 @@ class PhaseGenerator {
command: commandToUse,
args: argsForSpawn,
prompt,
sessionSshRemoteConfig: config.sessionSshRemoteConfig,
})
.then(() => {
console.log('[PhaseGenerator] Agent spawned successfully');