mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 15:51:18 +00:00
- Added extensive DEBUG-level logging for SSH command execution, spawn details, exit codes, and configuration flow - Improved Wizard SSH remote support: - Debounced remote directory validation to reduce excessive SSH calls - Fixed git.isRepo() to correctly pass remoteCwd for remote checks - Persisted SSH config in SerializableWizardState and validated directories over SSH - Ensured ConversationScreen and ConversationSession consistently pass SSH config for remote agent execution - Fixed "agent not available" errors by forwarding stdin via exec and enabling stream-json mode for large prompts - Enhanced remote agent execution logic in ProcessManager with stdin streaming, exec-based forwarding, and useStdin flag - Improved SSH file browser behavior: - Added resolveSshPath() to locate SSH binaries on Windows (Electron spawn PATH issue) - Corrected getSshContext() handling of enabled/remoteId states - Ensured synopsis background tasks run via SSH instead of local paths - Added Windows development improvements: dev:win script and PowerShell launcher for separate renderer/main terminals - Added additional SSH directory debugging logs for remote-fs and wizard flows Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
/**
|
|
* SSH Command Builder utilities for remote agent execution.
|
|
*
|
|
* Provides functions to construct SSH command invocations that wrap
|
|
* agent commands for remote execution. These utilities work with
|
|
* SshRemoteManager and ProcessManager to enable executing AI agents
|
|
* on remote hosts via SSH.
|
|
*/
|
|
|
|
import { SshRemoteConfig } from '../../shared/types';
|
|
import { shellEscape, buildShellCommand, shellEscapeForDoubleQuotes } from './shell-escape';
|
|
import { expandTilde } from '../../shared/pathUtils';
|
|
import { logger } from './logger';
|
|
import { resolveSshPath } from './cliDetection';
|
|
|
|
/**
|
|
* Result of building an SSH command.
|
|
* Contains the command and arguments to pass to spawn().
|
|
*/
|
|
export interface SshCommandResult {
|
|
/** The command to execute ('ssh') */
|
|
command: string;
|
|
/** Arguments for the SSH command */
|
|
args: string[];
|
|
}
|
|
|
|
/**
|
|
* Options for building the remote command.
|
|
*/
|
|
export interface RemoteCommandOptions {
|
|
/** The command to execute on the remote host */
|
|
command: string;
|
|
/** Arguments for the command */
|
|
args: string[];
|
|
/** Working directory on the remote host (optional) */
|
|
cwd?: string;
|
|
/** Environment variables to set on the remote (optional) */
|
|
env?: Record<string, string>;
|
|
/** Indicates the caller will send input via stdin to the remote command (optional) */
|
|
useStdin?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Default SSH options for all connections.
|
|
* These options ensure non-interactive, key-based authentication.
|
|
*/
|
|
const DEFAULT_SSH_OPTIONS: Record<string, string> = {
|
|
BatchMode: 'yes', // Disable password prompts (key-only)
|
|
StrictHostKeyChecking: 'accept-new', // Auto-accept new host keys
|
|
ConnectTimeout: '10', // Connection timeout in seconds
|
|
ClearAllForwardings: 'yes', // Disable port forwarding from SSH config (avoids "Address already in use" errors)
|
|
RequestTTY: 'no', // Default: do NOT request a TTY. We only force a TTY for specific remote modes (e.g., --print)
|
|
LogLevel: 'ERROR', // Suppress SSH warnings like "Pseudo-terminal will not be allocated..."
|
|
};
|
|
|
|
/**
|
|
* Build the remote shell command string from command, args, cwd, and env.
|
|
*
|
|
* This function constructs a properly escaped shell command that:
|
|
* 1. Changes to the specified working directory (if provided)
|
|
* 2. Sets environment variables (if provided)
|
|
* 3. Executes the command with its arguments
|
|
*
|
|
* The result is a single shell command string that can be passed to SSH.
|
|
* All user-provided values are properly escaped to prevent shell injection.
|
|
*
|
|
* @param options Command options including command, args, cwd, and env
|
|
* @returns Properly escaped shell command string for remote execution
|
|
*
|
|
* @example
|
|
* buildRemoteCommand({
|
|
* command: 'claude',
|
|
* args: ['--print', '--verbose'],
|
|
* cwd: '/home/user/project',
|
|
* env: { ANTHROPIC_API_KEY: 'sk-...' }
|
|
* })
|
|
* // => "cd '/home/user/project' && ANTHROPIC_API_KEY='sk-...' 'claude' '--print' '--verbose'"
|
|
*/
|
|
export function buildRemoteCommand(options: RemoteCommandOptions): string {
|
|
const { command, args, cwd, env } = options;
|
|
|
|
const parts: string[] = [];
|
|
|
|
// Add cd command if working directory is specified
|
|
if (cwd) {
|
|
parts.push(`cd ${shellEscape(cwd)}`);
|
|
}
|
|
|
|
// Build environment variable exports
|
|
const envExports: string[] = [];
|
|
if (env && Object.keys(env).length > 0) {
|
|
for (const [key, value] of Object.entries(env)) {
|
|
// Environment variable names are validated (alphanumeric + underscore)
|
|
// but we still escape the value to be safe
|
|
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
|
envExports.push(`${key}=${shellEscape(value)}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build the command with arguments
|
|
const commandWithArgs = buildShellCommand(command, args);
|
|
|
|
// If command expects JSON via stdin (stream-json), use exec to replace the
|
|
// shell process so stdin is delivered directly to the agent binary and no
|
|
// intermediate shell produces control sequences that could corrupt the stream.
|
|
const hasStreamJsonInput = options.useStdin ? true : (Array.isArray(args) && args.includes('--input-format') && args.includes('stream-json'));
|
|
const finalCommandWithArgs = hasStreamJsonInput ? `exec ${commandWithArgs}` : commandWithArgs;
|
|
|
|
// Combine env exports with command
|
|
let fullCommand: string;
|
|
if (envExports.length > 0) {
|
|
// Prepend env vars inline: VAR1='val1' VAR2='val2' command args
|
|
fullCommand = `${envExports.join(' ')} ${finalCommandWithArgs}`;
|
|
} else {
|
|
fullCommand = finalCommandWithArgs;
|
|
}
|
|
|
|
parts.push(fullCommand);
|
|
|
|
// Join with && to ensure cd succeeds before running command
|
|
return parts.join(' && ');
|
|
}
|
|
|
|
/**
|
|
* Build SSH command and arguments for remote execution.
|
|
*
|
|
* This function constructs the complete SSH invocation to execute
|
|
* a command on a remote host. It uses the SSH config for authentication
|
|
* details and builds a properly escaped remote command string.
|
|
*
|
|
* When config.useSshConfig is true, the function relies on ~/.ssh/config
|
|
* for connection settings (User, IdentityFile, Port, HostName) and only
|
|
* passes the Host pattern to SSH. This allows leveraging existing SSH
|
|
* configurations including ProxyJump for bastion hosts.
|
|
*
|
|
* @param config SSH remote configuration
|
|
* @param remoteOptions Options for the remote command (command, args, cwd, env)
|
|
* @returns Object with 'ssh' command and arguments array
|
|
*
|
|
* @example
|
|
* // Direct connection (no SSH config)
|
|
* buildSshCommand(
|
|
* { host: 'dev.example.com', port: 22, username: 'user', privateKeyPath: '~/.ssh/id_ed25519', ... },
|
|
* { command: 'claude', args: ['--print', 'hello'], cwd: '/home/user/project' }
|
|
* )
|
|
* // => {
|
|
* // command: 'ssh',
|
|
* // args: [
|
|
* // '-i', '/Users/me/.ssh/id_ed25519',
|
|
* // '-o', 'BatchMode=yes',
|
|
* // '-o', 'StrictHostKeyChecking=accept-new',
|
|
* // '-o', 'ConnectTimeout=10',
|
|
* // '-p', '22',
|
|
* // 'user@dev.example.com',
|
|
* // "cd '/home/user/project' && 'claude' '--print' 'hello'"
|
|
* // ]
|
|
* // }
|
|
*
|
|
* @example
|
|
* // Using SSH config (useSshConfig: true)
|
|
* buildSshCommand(
|
|
* { host: 'dev-server', useSshConfig: true, ... },
|
|
* { command: 'claude', args: ['--print', 'hello'] }
|
|
* )
|
|
* // => {
|
|
* // command: 'ssh',
|
|
* // args: [
|
|
* // '-o', 'BatchMode=yes',
|
|
* // '-o', 'StrictHostKeyChecking=accept-new',
|
|
* // '-o', 'ConnectTimeout=10',
|
|
* // 'dev-server', // SSH will look up settings from ~/.ssh/config
|
|
* // "'claude' '--print' 'hello'"
|
|
* // ]
|
|
* // }
|
|
*/
|
|
export async function buildSshCommand(
|
|
config: SshRemoteConfig,
|
|
remoteOptions: RemoteCommandOptions
|
|
): Promise<SshCommandResult> {
|
|
const args: string[] = [];
|
|
|
|
// Resolve the SSH binary path (handles packaged Electron apps where PATH is limited)
|
|
const sshPath = await resolveSshPath();
|
|
|
|
// Decide whether we need to force a TTY for the remote command.
|
|
// Historically we forced a TTY for Claude Code when running with `--print`.
|
|
// However, for stream-json input (sending JSON via stdin) a TTY injects terminal
|
|
// control sequences that corrupt the stream. Only enable forced TTY for cases
|
|
// that explicitly require it (e.g., `--print` without `--input-format stream-json`).
|
|
const remoteArgs = remoteOptions.args || [];
|
|
const hasPrintFlag = remoteArgs.includes('--print');
|
|
const hasStreamJsonInput = remoteOptions.useStdin ? true : (remoteArgs.includes('--input-format') && remoteArgs.includes('stream-json'));
|
|
const forceTty = Boolean(hasPrintFlag && !hasStreamJsonInput);
|
|
|
|
// Log the decision so callers can debug why a TTY was or was not forced
|
|
logger.debug('SSH TTY decision', '[ssh-command-builder]', {
|
|
host: config.host,
|
|
useStdinFlag: !!remoteOptions.useStdin,
|
|
hasPrintFlag,
|
|
hasStreamJsonInput,
|
|
forceTty,
|
|
});
|
|
|
|
if (forceTty) {
|
|
// -tt must come first for reliable forced allocation in some SSH implementations
|
|
args.push('-tt');
|
|
}
|
|
|
|
// When using SSH config, we let SSH handle authentication settings
|
|
// Only add explicit overrides if provided
|
|
if (config.useSshConfig) {
|
|
// Only specify identity file if explicitly provided (override SSH config)
|
|
if (config.privateKeyPath && config.privateKeyPath.trim()) {
|
|
args.push('-i', expandTilde(config.privateKeyPath));
|
|
}
|
|
} else {
|
|
// Direct connection: require private key
|
|
args.push('-i', expandTilde(config.privateKeyPath));
|
|
}
|
|
|
|
// Default SSH options for non-interactive operation
|
|
// These are always needed to ensure BatchMode behavior. If `forceTty` is true,
|
|
// override RequestTTY to `force` so SSH will allocate a TTY even in non-interactive contexts.
|
|
for (const [key, value] of Object.entries(DEFAULT_SSH_OPTIONS)) {
|
|
// If we will force a TTY for this command, override the RequestTTY option
|
|
if (key === 'RequestTTY' && forceTty) {
|
|
args.push('-o', `${key}=force`);
|
|
} else {
|
|
args.push('-o', `${key}=${value}`);
|
|
}
|
|
}
|
|
|
|
// Port specification - only add if not default and not using SSH config
|
|
// (when using SSH config, let SSH config handle the port)
|
|
if (!config.useSshConfig || config.port !== 22) {
|
|
args.push('-p', config.port.toString());
|
|
}
|
|
|
|
// Build the destination (user@host or just host for SSH config)
|
|
if (config.useSshConfig) {
|
|
// When using SSH config, just pass the Host pattern
|
|
// SSH will look up User, HostName, Port, IdentityFile from config
|
|
// But if username is explicitly provided, use it as override
|
|
if (config.username && config.username.trim()) {
|
|
args.push(`${config.username}@${config.host}`);
|
|
} else {
|
|
args.push(config.host);
|
|
}
|
|
} else {
|
|
// Direct connection: always include username
|
|
args.push(`${config.username}@${config.host}`);
|
|
}
|
|
|
|
// Merge remote config's environment with the command-specific environment
|
|
// Command-specific env takes precedence over remote config env
|
|
const mergedEnv: Record<string, string> = {
|
|
...(config.remoteEnv || {}),
|
|
...(remoteOptions.env || {}),
|
|
};
|
|
|
|
// Use working directory from remoteOptions if provided
|
|
// No cd if not specified - agent will start in remote home directory
|
|
const effectiveCwd = remoteOptions.cwd;
|
|
|
|
// Build the remote command string
|
|
const remoteCommand = buildRemoteCommand({
|
|
command: remoteOptions.command,
|
|
args: remoteOptions.args,
|
|
cwd: effectiveCwd,
|
|
env: Object.keys(mergedEnv).length > 0 ? mergedEnv : undefined,
|
|
});
|
|
|
|
// 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
|
|
// -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:
|
|
// case $- in *i*) ;; *) return;; esac
|
|
// 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:
|
|
// 1. Double quotes around the command are NOT escaped - they delimit the -c argument
|
|
// 2. $ signs inside the command MUST be escaped as \$ so they defer to the login shell
|
|
// (shellEscapeForDoubleQuotes handles this)
|
|
// 3. Single quotes inside the command pass through unchanged
|
|
//
|
|
// Example transformation for spawn():
|
|
// Input: cd '/path' && MYVAR='value' claude --print
|
|
// After escaping: cd '/path' && MYVAR='value' claude --print (no $ to escape here)
|
|
// Wrapped: bash -lc "source ~/.bashrc 2>/dev/null; cd '/path' && MYVAR='value' claude --print"
|
|
// SSH receives this as one argument, passes to remote shell
|
|
// The login shell runs with full PATH from /etc/profile, ~/.bash_profile, AND ~/.bashrc
|
|
const escapedCommand = shellEscapeForDoubleQuotes(remoteCommand);
|
|
// 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
|
|
logger.debug('Built SSH command', '[ssh-command-builder]', {
|
|
host: config.host,
|
|
username: config.username,
|
|
port: config.port,
|
|
useSshConfig: config.useSshConfig,
|
|
privateKeyPath: config.privateKeyPath ? '***configured***' : undefined,
|
|
remoteCommand,
|
|
wrappedCommand,
|
|
sshPath,
|
|
sshArgs: args,
|
|
fullCommand: `${sshPath} ${args.join(' ')}`,
|
|
// Show the exact command string that will execute on the remote
|
|
remoteExecutionString: wrappedCommand,
|
|
});
|
|
|
|
return {
|
|
command: sshPath,
|
|
args,
|
|
};
|
|
}
|