mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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'],
|
||||
|
||||
66
src/__tests__/main/utils/stripAnsi.test.ts
Normal file
66
src/__tests__/main/utils/stripAnsi.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
49
src/main/utils/stripAnsi.ts
Normal file
49
src/main/utils/stripAnsi.ts
Normal 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, '');
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
12
src/renderer/global.d.ts
vendored
12
src/renderer/global.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user