## CHANGES

- Removed `remoteWorkingDir` SSH setting—remote sessions now start in `~` by default 🏠
- Simplified SSH command building: only `cwd` triggers remote `cd` injection 🧭
- Dropped remote-CWD integration assertions to reduce flaky SSH test failures 🧪
- Added `stripAnsi` utility to clean ANSI/OSC noise from SSH outputs 🧼
- Hardened remote agent detection against iTerm2 shell-integration escape sequences 🛰️
- Updated remote git execution to require explicit `remoteCwd` for correctness 🔧
- Streamlined SSH remote IPC payloads—no more working-directory field transmitted 📡
- Cleaned up SSH remote settings UI by removing the Remote Working Directory input 🧰
- Refined remote `cd` handling: `~` and bare `cd` map to session base dir 📁
- Tightened shared types and tests to match the new SSH remote config shape 🧩
This commit is contained in:
Pedram Amini
2026-01-11 17:05:44 -06:00
parent c1a0e5d513
commit 882470786f
18 changed files with 166 additions and 205 deletions

View File

@@ -67,20 +67,17 @@ const TEST_CWD = process.cwd();
*
* Optional:
* SSH_TEST_PORT - SSH port (default: 22)
* SSH_TEST_REMOTE_CWD - Remote working directory (default: home directory)
* SSH_TEST_USE_CONFIG - Set to "true" to use ~/.ssh/config host patterns
*
* Example:
* SSH_TEST_HOST=pedtome SSH_TEST_USER=pedram SSH_TEST_KEY=~/.ssh/id_rsa \
* SSH_TEST_REMOTE_CWD=/Users/pedram/Projects/Podsidian SSH_TEST_USE_CONFIG=true \
* RUN_SSH_INTEGRATION_TESTS=true npm run test:integration
* SSH_TEST_USE_CONFIG=true RUN_SSH_INTEGRATION_TESTS=true npm run test:integration
*/
function getSshTestConfig(): SshRemoteConfig | null {
const host = process.env.SSH_TEST_HOST;
const username = process.env.SSH_TEST_USER || '';
const privateKeyPath = process.env.SSH_TEST_KEY || '';
const port = parseInt(process.env.SSH_TEST_PORT || '22', 10);
const remoteWorkingDir = process.env.SSH_TEST_REMOTE_CWD;
const useSshConfig = process.env.SSH_TEST_USE_CONFIG === 'true';
if (!host) {
@@ -100,7 +97,6 @@ function getSshTestConfig(): SshRemoteConfig | null {
port,
username,
privateKeyPath,
remoteWorkingDir,
enabled: true,
useSshConfig,
sshConfigHost: useSshConfig ? host : undefined,
@@ -684,7 +680,7 @@ function runProvider(
* @param provider - Provider config
* @param sshConfig - SSH remote configuration
* @param args - Provider arguments
* @param cwd - Remote working directory (optional, uses sshConfig.remoteWorkingDir if not provided)
* @param cwd - Remote working directory (optional, defaults to ~ on remote)
* @param stdinContent - Optional content to write to stdin
*/
async function runProviderViaSsh(
@@ -698,7 +694,7 @@ async function runProviderViaSsh(
const sshCommand = await buildSshCommand(sshConfig, {
command: provider.command,
args,
cwd: cwd || sshConfig.remoteWorkingDir,
cwd,
});
console.log(`🌐 SSH Command: ${sshCommand.command} ${sshCommand.args.join(' ')}`);
@@ -1740,13 +1736,11 @@ Rules:
* SSH_TEST_USER - SSH username (optional if using SSH config)
* SSH_TEST_KEY - Path to SSH private key (optional if using SSH config)
* SSH_TEST_PORT - SSH port (default: 22)
* SSH_TEST_REMOTE_CWD - Remote working directory
* SSH_TEST_USE_CONFIG - Set to "true" to use ~/.ssh/config
*
* Example:
* SSH_TEST_HOST=pedtome SSH_TEST_USER=pedram SSH_TEST_KEY=~/.ssh/id_rsa \
* SSH_TEST_REMOTE_CWD=/Users/pedram/Projects/Podsidian SSH_TEST_USE_CONFIG=true \
* RUN_SSH_INTEGRATION_TESTS=true npm run test:integration
* SSH_TEST_USE_CONFIG=true RUN_SSH_INTEGRATION_TESTS=true npm run test:integration
*/
describe.skipIf(SKIP_SSH_INTEGRATION)('SSH Provider Integration Tests', () => {
let sshConfig: SshRemoteConfig | null = null;
@@ -1766,7 +1760,6 @@ describe.skipIf(SKIP_SSH_INTEGRATION)('SSH Provider Integration Tests', () => {
console.log(` User: ${sshConfig.username || '(from SSH config)'}`);
console.log(` Port: ${sshConfig.port}`);
console.log(` Key: ${sshConfig.privateKeyPath || '(from SSH config)'}`);
console.log(` Remote CWD: ${sshConfig.remoteWorkingDir || '(default)'}`);
console.log(` Use SSH Config: ${sshConfig.useSshConfig}`);
// Test SSH connection first
@@ -1801,7 +1794,6 @@ describe.skipIf(SKIP_SSH_INTEGRATION)('SSH Provider Integration Tests', () => {
const sshCommand = await buildSshCommand(sshConfig, {
command: 'pwd',
args: [],
cwd: sshConfig.remoteWorkingDir,
});
const result = await new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve) => {
@@ -1997,59 +1989,6 @@ Reply with just the two numbers separated by a comma.`;
).toBe(true);
}, SSH_PROVIDER_TIMEOUT);
it('should execute in correct remote working directory', async () => {
if (!sshConfig || !sshConnectionOk) {
console.log('Skipping: SSH not configured or connection failed');
return;
}
if (!providerAvailableRemote) {
console.log(`Skipping: ${provider.name} not available on remote`);
return;
}
if (!sshConfig.remoteWorkingDir) {
console.log('Skipping: No remote working directory configured');
return;
}
// Ask the agent to read a file that should exist in the remote cwd
// or at least confirm the working directory
const prompt = 'What is the current working directory? Just tell me the path, nothing else.';
const args = provider.buildInitialArgs(prompt);
console.log(`\n🌐 Testing remote CWD for ${provider.name}`);
console.log(`📁 Expected CWD: ${sshConfig.remoteWorkingDir}`);
const result = await runProviderViaSsh(provider, sshConfig, args);
console.log(`📤 Exit code: ${result.exitCode}`);
// Check for auth errors on remote
if (hasRemoteAuthError(result.stdout)) {
console.log(`⚠️ Authentication error on remote - skipping remaining assertions`);
expect(provider.parseSessionId(result.stdout), 'Should still get session ID').toBeTruthy();
return;
}
expect(
provider.isSuccessful(result.stdout, result.exitCode),
`${provider.name} via SSH should succeed`
).toBe(true);
const response = provider.parseResponse(result.stdout);
console.log(`💬 Response: ${response?.substring(0, 300)}`);
expect(response, `${provider.name} should return a response`).toBeTruthy();
// Response should mention the remote working directory
// (The agent might phrase it differently, so just check if the path appears)
const expectedPathPart = sshConfig.remoteWorkingDir.split('/').pop() || '';
expect(
response?.includes(expectedPathPart) || response?.includes(sshConfig.remoteWorkingDir),
`${provider.name} should be in remote CWD ${sshConfig.remoteWorkingDir}. Got: "${response}"`
).toBe(true);
}, SSH_PROVIDER_TIMEOUT);
it('should parse session ID correctly from SSH output', async () => {
if (!sshConfig || !sshConnectionOk) {
console.log('Skipping: SSH not configured or connection failed');

View File

@@ -78,10 +78,9 @@ vi.mock('../../../../main/utils/ssh-command-builder', () => ({
// Build the remote command parts
const commandParts: string[] = [];
// Add cd if cwd or remoteWorkingDir is set
const effectiveCwd = remoteOptions.cwd || config.remoteWorkingDir;
if (effectiveCwd) {
commandParts.push(`cd '${effectiveCwd}'`);
// Add cd if cwd is set
if (remoteOptions.cwd) {
commandParts.push(`cd '${remoteOptions.cwd}'`);
}
// Add env vars if present
@@ -998,51 +997,6 @@ describe('process IPC handlers', () => {
);
});
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];
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'],
// Session-level SSH config
sessionSshRemoteConfig: {
enabled: true,
remoteId: 'remote-1',
},
});
// 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');
});
it('should use local home directory as cwd when spawning SSH (fixes ENOENT for remote-only paths)', async () => {
// This test verifies the fix for: spawn /usr/bin/ssh ENOENT
// The bug occurred because when session.cwd is a remote path (e.g., /home/user/project),

View File

@@ -273,29 +273,26 @@ describe('ssh-command-builder', () => {
expect(result.args[portIndex + 1]).toBe('2222');
});
it('uses remoteWorkingDir from config when no cwd in options', async () => {
const config = { ...baseConfig, remoteWorkingDir: '/opt/projects' };
const result = await buildSshCommand(config, {
it('uses cwd from options when provided', async () => {
const result = await buildSshCommand(baseConfig, {
command: 'claude',
args: ['--print'],
cwd: '/opt/projects',
});
// The remote command should include cd to the remote working dir
// The remote command should include cd to the working dir
const remoteCommand = result.args[result.args.length - 1];
expect(remoteCommand).toContain("cd '/opt/projects'");
});
it('prefers option cwd over config remoteWorkingDir', async () => {
const config = { ...baseConfig, remoteWorkingDir: '/opt/projects' };
const result = await buildSshCommand(config, {
it('does not include cd when no cwd is provided', async () => {
const result = await buildSshCommand(baseConfig, {
command: 'claude',
args: [],
cwd: '/home/user/specific-project',
args: ['--print'],
});
const remoteCommand = result.args[result.args.length - 1];
expect(remoteCommand).toContain("cd '/home/user/specific-project'");
expect(remoteCommand).not.toContain('/opt/projects');
expect(remoteCommand).not.toContain('cd');
});
it('merges remote config env with option env', async () => {
@@ -318,7 +315,7 @@ describe('ssh-command-builder', () => {
expect(remoteCommand).not.toContain("SHARED_VAR='config-value'");
});
it('handles config without remoteEnv or remoteWorkingDir', async () => {
it('handles config without remoteEnv', async () => {
const result = await buildSshCommand(baseConfig, {
command: 'claude',
args: ['--print', 'hello'],

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { stripAnsi } from '../../../main/utils/stripAnsi';
describe('stripAnsi', () => {
it('returns plain text unchanged', () => {
expect(stripAnsi('hello world')).toBe('hello world');
expect(stripAnsi('/opt/homebrew/bin/codex')).toBe('/opt/homebrew/bin/codex');
});
it('strips standard ANSI color codes', () => {
expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red');
expect(stripAnsi('\x1b[1;32mbold green\x1b[0m')).toBe('bold green');
});
it('strips iTerm2 shell integration OSC sequences - real world example', () => {
// Real-world example from SSH with interactive shell
// The sequences are: ]1337;RemoteHost=..., ]1337;CurrentDir=..., ]1337;ShellIntegrationVersion=...;shell=zsh
// followed immediately by the actual path
const input = ']1337;RemoteHost=pedram@PedTome.local]1337;CurrentDir=/Users/pedram]1337;ShellIntegrationVersion=13;shell=zsh/opt/homebrew/bin/codex';
expect(stripAnsi(input)).toBe('/opt/homebrew/bin/codex');
});
it('strips OSC sequences with ESC prefix and BEL terminator', () => {
const input = '\x1b]1337;RemoteHost=user@host\x07/usr/bin/claude';
expect(stripAnsi(input)).toBe('/usr/bin/claude');
});
it('strips bare OSC sequences terminated with BEL', () => {
const input = ']1337;CurrentDir=/home/user\x07/usr/local/bin/codex';
expect(stripAnsi(input)).toBe('/usr/local/bin/codex');
});
it('handles multiple consecutive OSC sequences', () => {
// Three consecutive sequences followed by a path
// Note: CurrentDir value ends at ] not at /, so /home/user is the value
const input = ']1337;RemoteHost=user@host]1337;CurrentDir=/home/user]1337;ShellIntegrationVersion=13;shell=bash/path/to/binary';
expect(stripAnsi(input)).toBe('/path/to/binary');
});
it('handles mixed ANSI and OSC sequences with ESC prefix', () => {
const input = '\x1b[32m\x1b]1337;CurrentDir=/home\x07\x1b[0m/usr/bin/test';
expect(stripAnsi(input)).toBe('/usr/bin/test');
});
it('handles empty string', () => {
expect(stripAnsi('')).toBe('');
});
it('handles string with only escape sequences', () => {
// Two sequences with no actual content at the end
const input = '\x1b]1337;RemoteHost=user@host\x07\x1b]1337;CurrentDir=/home\x07';
expect(stripAnsi(input)).toBe('');
});
it('preserves newlines in output', () => {
// Multiple lines with clean paths
const input = '/usr/bin/codex\n/usr/bin/claude';
expect(stripAnsi(input)).toBe('/usr/bin/codex\n/usr/bin/claude');
});
it('handles sequences before each line in multiline output', () => {
// Each line has its own OSC prefix
const input = '\x1b]1337;CurrentDir=/home\x07/usr/bin/codex\n\x1b]1337;CurrentDir=/home\x07/usr/bin/claude';
expect(stripAnsi(input)).toBe('/usr/bin/codex\n/usr/bin/claude');
});
});

View File

@@ -10,7 +10,6 @@ const createMockConfig = (overrides: Partial<SshRemoteConfig> = {}): SshRemoteCo
port: 22,
username: 'testuser',
privateKeyPath: '/home/testuser/.ssh/id_rsa',
remoteWorkingDir: '/home/testuser/projects',
enabled: true,
...overrides,
});

View File

@@ -6,6 +6,7 @@ import { execFileNoThrow } from '../../utils/execFile';
import { logger } from '../../utils/logger';
import { withIpcErrorLogging, requireDependency, CreateHandlerOptions } from '../../utils/ipcHandler';
import { buildSshCommand, RemoteCommandOptions } from '../../utils/ssh-command-builder';
import { stripAnsi } from '../../utils/stripAnsi';
import { SshRemoteConfig } from '../../../shared/types';
import { MaestroSettings } from './persistence';
@@ -134,8 +135,10 @@ async function detectAgentsRemote(sshRemote: SshRemoteConfig): Promise<any[]> {
connectionSucceeded = true;
}
const available = result.exitCode === 0 && result.stdout.trim().length > 0;
const path = available ? result.stdout.trim().split('\n')[0] : undefined;
// Strip ANSI/OSC escape sequences from output (shell integration sequences from interactive shells)
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);

View File

@@ -265,8 +265,6 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
}
// 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
//
// Determine the command to run on the remote host:
// 1. If user set a session-specific custom path, use that (they configured it for the remote)
@@ -277,9 +275,8 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
const sshCommand = await buildSshCommand(sshResult.config, {
command: remoteCommand,
args: sshArgs,
// 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,
// Use the cwd from config - this is the project directory on the remote
cwd: config.cwd,
// Pass custom environment variables to the remote command
env: effectiveCustomEnvVars,
});
@@ -352,7 +349,6 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
id: sshRemoteUsed.id,
name: sshRemoteUsed.name,
host: sshRemoteUsed.host,
remoteWorkingDir: sshRemoteUsed.remoteWorkingDir,
} : null;
mainWindow.webContents.send('process:ssh-remote', config.sessionId, sshRemoteInfo);
}
@@ -364,7 +360,6 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
id: sshRemoteUsed.id,
name: sshRemoteUsed.name,
host: sshRemoteUsed.host,
remoteWorkingDir: sshRemoteUsed.remoteWorkingDir,
} : undefined,
};
})

View File

@@ -108,7 +108,6 @@ export function registerSshRemoteHandlers(deps: SshRemoteHandlerDependencies): v
port: config.port ?? 22,
username: config.username || '',
privateKeyPath: config.privateKeyPath || '',
remoteWorkingDir: config.remoteWorkingDir,
remoteEnv: config.remoteEnv,
enabled: config.enabled ?? true,
useSshConfig: config.useSshConfig,

View File

@@ -159,9 +159,8 @@ contextBridge.exposeInMainWorld('maestro', {
},
// SSH remote execution status
// Emitted when a process starts executing via SSH on a remote host
// Includes remoteWorkingDir for session-wide SSH context (file explorer, git, auto run, etc.)
onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => void) => {
const handler = (_: any, sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => callback(sessionId, sshRemote);
onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => void) => {
const handler = (_: any, sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => callback(sessionId, sshRemote);
ipcRenderer.on('process:ssh-remote', handler);
return () => ipcRenderer.removeListener('process:ssh-remote', handler);
},
@@ -642,7 +641,6 @@ contextBridge.exposeInMainWorld('maestro', {
port?: number;
username?: string;
privateKeyPath?: string;
remoteWorkingDir?: string;
remoteEnv?: Record<string, string>;
enabled?: boolean;
}) => ipcRenderer.invoke('ssh-remote:saveConfig', config),
@@ -657,7 +655,6 @@ contextBridge.exposeInMainWorld('maestro', {
port: number;
username: string;
privateKeyPath: string;
remoteWorkingDir?: string;
remoteEnv?: Record<string, string>;
enabled: boolean;
}, agentCommand?: string) => ipcRenderer.invoke('ssh-remote:test', configOrId, agentCommand),
@@ -1808,7 +1805,7 @@ export interface MaestroAPI {
onSlashCommands: (callback: (sessionId: string, slashCommands: string[]) => void) => () => void;
onThinkingChunk: (callback: (sessionId: string, content: string) => void) => () => void;
onToolExecution: (callback: (sessionId: string, toolEvent: { toolName: string; state?: unknown; timestamp: number }) => void) => () => void;
onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => void) => () => void;
onSshRemote: (callback: (sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => void) => () => void;
onRemoteCommand: (callback: (sessionId: string, command: string) => void) => () => void;
onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => () => void;
onRemoteInterrupt: (callback: (sessionId: string) => void) => () => void;
@@ -2021,8 +2018,7 @@ export interface MaestroAPI {
port?: number;
username?: string;
privateKeyPath?: string;
remoteWorkingDir?: string;
remoteEnv?: Record<string, string>;
remoteEnv?: Record<string, string>;
enabled?: boolean;
}) => Promise<{
success: boolean;
@@ -2033,8 +2029,7 @@ export interface MaestroAPI {
port: number;
username: string;
privateKeyPath: string;
remoteWorkingDir?: string;
remoteEnv?: Record<string, string>;
remoteEnv?: Record<string, string>;
enabled: boolean;
};
error?: string;
@@ -2049,8 +2044,7 @@ export interface MaestroAPI {
port: number;
username: string;
privateKeyPath: string;
remoteWorkingDir?: string;
remoteEnv?: Record<string, string>;
remoteEnv?: Record<string, string>;
enabled: boolean;
}>;
error?: string;
@@ -2065,8 +2059,7 @@ export interface MaestroAPI {
port: number;
username: string;
privateKeyPath: string;
remoteWorkingDir?: string;
remoteEnv?: Record<string, string>;
remoteEnv?: Record<string, string>;
enabled: boolean;
},
agentCommand?: string

View File

@@ -1810,10 +1810,9 @@ export class ProcessManager extends EventEmitter {
}
// Determine the working directory on the remote
// Priority: passed cwd (from session's remoteCwd) > remoteWorkingDir from SSH config
// The cwd parameter now contains the session's tracked remoteCwd which updates when user runs cd
// Fall back to home directory (~) if neither is set
const remoteCwd = cwd || sshConfig.remoteWorkingDir || '~';
// The cwd parameter contains the session's tracked remoteCwd which updates when user runs cd
// Fall back to home directory (~) if not set
const remoteCwd = cwd || '~';
// Merge environment variables: SSH config's remoteEnv + shell env vars
const mergedEnv: Record<string, string> = {

View File

@@ -21,7 +21,7 @@ const LOG_CONTEXT = '[RemoteGit]';
export interface RemoteGitOptions {
/** SSH remote configuration */
sshRemote: SshRemoteConfig;
/** Working directory on the remote host (optional - uses sshRemote.remoteWorkingDir if not provided) */
/** Working directory on the remote host */
remoteCwd?: string;
}
@@ -51,10 +51,7 @@ export async function execGitRemote(
): Promise<ExecResult> {
const { sshRemote, remoteCwd } = options;
// Determine the effective working directory on the remote
const effectiveCwd = remoteCwd || sshRemote.remoteWorkingDir;
if (!effectiveCwd) {
if (!remoteCwd) {
logger.warn('No remote working directory specified for git command', LOG_CONTEXT);
}
@@ -62,7 +59,7 @@ export async function execGitRemote(
const remoteOptions: RemoteCommandOptions = {
command: 'git',
args,
cwd: effectiveCwd,
cwd: remoteCwd,
// Pass any remote environment variables from the SSH config
env: sshRemote.remoteEnv,
};
@@ -72,7 +69,7 @@ export async function execGitRemote(
logger.debug(`Executing remote git command: ${args.join(' ')}`, LOG_CONTEXT, {
host: sshRemote.host,
cwd: effectiveCwd,
cwd: remoteCwd,
});
// Execute the SSH command
@@ -96,7 +93,7 @@ export async function execGitRemote(
* @param args Git command arguments
* @param localCwd Local working directory (used for local execution)
* @param sshRemote Optional SSH remote configuration (triggers remote execution if provided)
* @param remoteCwd Optional remote working directory (overrides sshRemote.remoteWorkingDir)
* @param remoteCwd Remote working directory (required for remote execution)
* @returns Execution result
*/
export async function execGit(

View File

@@ -225,11 +225,9 @@ export async function buildSshCommand(
...(remoteOptions.env || {}),
};
// Determine the working directory:
// 1. Use remoteOptions.cwd if provided (command-specific)
// 2. Fall back to config.remoteWorkingDir if available
// 3. No cd if neither is specified
const effectiveCwd = remoteOptions.cwd || config.remoteWorkingDir;
// 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({

View File

@@ -0,0 +1,49 @@
/**
* Strip ANSI escape sequences and OSC (Operating System Command) sequences from a string.
*
* This handles:
* - Standard ANSI escape sequences (colors, cursor movement, etc.)
* - OSC sequences like iTerm2 shell integration (]1337;...)
* - Other terminal control sequences
*
* This is necessary when running commands via SSH with interactive shells (-i flag),
* as the remote shell's .bashrc/.zshrc may emit shell integration escape sequences.
*/
// Match ANSI escape sequences: ESC[ followed by parameters and a letter
const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[a-zA-Z]/g;
// Match OSC sequences: ESC] followed by content until BEL (\x07) or ST (ESC\)
// This handles iTerm2 shell integration sequences like ]1337;RemoteHost=...
const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g;
// Match iTerm2 shell integration sequences without ESC prefix
// Format: ]1337;Key=Value where Key is one of: RemoteHost, CurrentDir, ShellIntegrationVersion, etc.
// Real example: ]1337;RemoteHost=user@host]1337;CurrentDir=/home]1337;ShellIntegrationVersion=13;shell=zsh
//
// The pattern matches sequences terminated by the next ] or BEL (\x07)
// For sequences that ARE followed by another ], the value can contain / (like CurrentDir=/Users/pedram)
// For the LAST sequence (not followed by ]), the value ends at the first /
const ITERM2_OSC_WITH_NEXT = /\]1337;(?:RemoteHost|CurrentDir|ShellIntegrationVersion|User|HostName|LocalPwd|FileInfo|Mark|Dir|ClearCapturedOutput|AddAnnotation|File|Copy|SetMark|StealFocus|SetBadge|ReportCellSize|ReportDirectory|ReportVariables|RequestAttention|SetBackgroundImageFile|SetHotstringEnd|SetKeyLabel|SetProfile|SetUserVar|SetPrecolorScheme|SetColors)=[^\]\x07]*(?=\])/g;
// Match the LAST sequence (followed by a path starting with /)
// This one can't contain / in its value since that would be ambiguous with the actual path output
const ITERM2_OSC_LAST = /\]1337;(?:ShellIntegrationVersion|RemoteHost|User|HostName|FileInfo|Mark|ClearCapturedOutput|AddAnnotation|File|Copy|SetMark|StealFocus|SetBadge|ReportCellSize|ReportDirectory|ReportVariables|RequestAttention|SetBackgroundImageFile|SetHotstringEnd|SetKeyLabel|SetProfile|SetUserVar|SetPrecolorScheme|SetColors)=[^\]\x07/]*(?=\/)/g;
// Match bare OSC sequences terminated by BEL (\x07) - no ESC prefix
// This handles sequences like ]1337;CurrentDir=/home/user\x07
const BARE_OSC_WITH_BEL = /\]1337;[^\x07]*\x07/g;
/**
* Strip all ANSI and OSC escape sequences from a string.
* @param str - The string potentially containing escape sequences
* @returns The cleaned string with escape sequences removed
*/
export function stripAnsi(str: string): string {
return str
.replace(OSC_PATTERN, '')
.replace(BARE_OSC_WITH_BEL, '')
.replace(ITERM2_OSC_WITH_NEXT, '')
.replace(ITERM2_OSC_LAST, '')
.replace(ANSI_ESCAPE_PATTERN, '');
}

View File

@@ -2578,7 +2578,7 @@ function MaestroConsoleInner() {
// Also populates session-wide SSH context (sshRemoteId, remoteCwd) for file explorer, git, auto run, etc.
// IMPORTANT: When SSH connection is established, we also recheck isGitRepo since the initial
// check may have failed or been done before SSH was ready.
const unsubscribeSshRemote = window.maestro.process.onSshRemote?.((sessionId: string, sshRemote: { id: string; name: string; host: string; remoteWorkingDir?: string } | null) => {
const unsubscribeSshRemote = window.maestro.process.onSshRemote?.((sessionId: string, sshRemote: { id: string; name: string; host: string } | null) => {
// Parse sessionId to get actual session ID (format: {id}-ai-{tabId} or {id}-terminal)
let actualSessionId: string;
const aiTabMatch = sessionId.match(/^(.+)-ai-(.+)$/);
@@ -2590,7 +2590,7 @@ function MaestroConsoleInner() {
actualSessionId = sessionId;
}
// Update session with SSH remote info and session-wide SSH context
// Update session with SSH remote info
setSessions(prev => prev.map(s => {
if (s.id !== actualSessionId) return s;
// Only update if the value actually changed (avoid unnecessary re-renders)
@@ -2600,9 +2600,7 @@ function MaestroConsoleInner() {
return {
...s,
sshRemote: sshRemote ?? undefined,
// Populate session-wide SSH context for all operations (file explorer, git, auto run, etc.)
sshRemoteId: sshRemote?.id,
remoteCwd: sshRemote?.remoteWorkingDir,
};
}));
@@ -2614,7 +2612,7 @@ function MaestroConsoleInner() {
// Only check if session hasn't been detected as git repo yet
// (avoids redundant checks if SSH reconnects)
if (session && !session.isGitRepo) {
const remoteCwd = sshRemote.remoteWorkingDir || session.sessionSshRemoteConfig?.workingDirOverride || session.cwd;
const remoteCwd = session.sessionSshRemoteConfig?.workingDirOverride || session.cwd;
(async () => {
try {
const isGitRepo = await gitService.isRepo(remoteCwd, sshRemote.id);

View File

@@ -138,7 +138,6 @@ export function SshRemoteModal({
const [port, setPort] = useState('22');
const [username, setUsername] = useState('');
const [privateKeyPath, setPrivateKeyPath] = useState('');
const [remoteWorkingDir, setRemoteWorkingDir] = useState('');
const [envVars, setEnvVars] = useState<EnvVarEntry[]>([]);
const [enabled, setEnabled] = useState(true);
const [nextEnvVarId, setNextEnvVarId] = useState(0);
@@ -257,7 +256,6 @@ export function SshRemoteModal({
setPort(String(initialConfig.port));
setUsername(initialConfig.username);
setPrivateKeyPath(initialConfig.privateKeyPath);
setRemoteWorkingDir(initialConfig.remoteWorkingDir || '');
const entries = envVarsToArray(initialConfig.remoteEnv);
setEnvVars(entries);
setNextEnvVarId(entries.length);
@@ -272,7 +270,6 @@ export function SshRemoteModal({
setPort('22');
setUsername('');
setPrivateKeyPath('');
setRemoteWorkingDir('');
setEnvVars([]);
setNextEnvVarId(0);
setEnabled(true);
@@ -308,7 +305,6 @@ export function SshRemoteModal({
port: parseInt(port, 10),
username: username.trim(),
privateKeyPath: privateKeyPath.trim(),
remoteWorkingDir: remoteWorkingDir.trim() || undefined,
remoteEnv: Object.keys(envVarsToObject(envVars)).length > 0
? envVarsToObject(envVars)
: undefined,
@@ -316,7 +312,7 @@ export function SshRemoteModal({
useSshConfig,
sshConfigHost,
};
}, [initialConfig, name, host, port, username, privateKeyPath, remoteWorkingDir, envVars, enabled, useSshConfig, sshConfigHost]);
}, [initialConfig, name, host, port, username, privateKeyPath, envVars, enabled, useSshConfig, sshConfigHost]);
// Handle selecting an SSH config host
// This imports values as a template - user can edit freely and choose whether to use SSH config mode
@@ -699,17 +695,6 @@ export function SshRemoteModal({
helperText="Leave empty to use SSH config or ssh-agent"
/>
{/* Remote Working Directory (optional) */}
<FormInput
theme={theme}
label="Remote Working Directory (optional)"
value={remoteWorkingDir}
onChange={setRemoteWorkingDir}
placeholder="/home/user/projects"
monospace
helperText="Default directory on the remote host for agent execution"
/>
{/* Environment Variables */}
<div>
<div className="flex items-center justify-between mb-2">

View File

@@ -615,8 +615,7 @@ interface MaestroAPI {
port?: number;
username?: string;
privateKeyPath?: string;
remoteWorkingDir?: string;
remoteEnv?: Record<string, string>;
remoteEnv?: Record<string, string>;
enabled?: boolean;
}) => Promise<{
success: boolean;
@@ -627,8 +626,7 @@ interface MaestroAPI {
port: number;
username: string;
privateKeyPath: string;
remoteWorkingDir?: string;
remoteEnv?: Record<string, string>;
remoteEnv?: Record<string, string>;
enabled: boolean;
};
error?: string;
@@ -643,8 +641,7 @@ interface MaestroAPI {
port: number;
username: string;
privateKeyPath: string;
remoteWorkingDir?: string;
remoteEnv?: Record<string, string>;
remoteEnv?: Record<string, string>;
enabled: boolean;
}>;
error?: string;
@@ -659,8 +656,7 @@ interface MaestroAPI {
port: number;
username: string;
privateKeyPath: string;
remoteWorkingDir?: string;
remoteEnv?: Record<string, string>;
remoteEnv?: Record<string, string>;
enabled: boolean;
},
agentCommand?: string

View File

@@ -431,8 +431,7 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
// Handle bare "cd" command - go to session's original directory (or remote working dir for SSH)
if (trimmedInput === 'cd') {
if (isRemoteSession) {
// For remote sessions, bare cd goes to remote working directory from SSH config
// We can't easily get the remote $HOME, so use the configured remoteWorkingDir or cwd
// For remote sessions, bare cd goes to the session's configured working directory
remoteCwdChanged = true;
newRemoteCwd = activeSession.sessionSshRemoteConfig?.workingDirOverride || activeSession.cwd;
} else {
@@ -445,10 +444,8 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
const targetPath = cdMatch[1].trim().replace(/^['"]|['"]$/g, ''); // Remove quotes
let candidatePath: string;
if (targetPath === '~' || targetPath.startsWith('~/')) {
// For remote sessions, ~ should expand to remote home
// Since we can't easily get remote $HOME, use remoteWorkingDir as fallback
// For remote sessions, ~ should expand to session's base directory
if (isRemoteSession) {
// Use remoteWorkingDir or fall back to /home/<username> pattern
const basePath = activeSession.sessionSshRemoteConfig?.workingDirOverride || activeSession.cwd;
if (targetPath === '~') {
candidatePath = basePath;

View File

@@ -280,9 +280,6 @@ export interface SshRemoteConfig {
*/
privateKeyPath: string;
/** Default working directory on remote (optional) */
remoteWorkingDir?: string;
/** Environment variables to set on remote */
remoteEnv?: Record<string, string>;