Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 119 additions & 20 deletions src/__tests__/main/ipc/handlers/process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '../../../../main/ipc/handlers/process';
import { getDefaultShell } from '../../../../main/stores/defaults';
import { stripThinkingFromTranscript } from '../../../../main/agents/claude-transcript-sanitizer';
import { buildAgentArgs } from '../../../../main/utils/agent-args';

// Mock electron's ipcMain
vi.mock('electron', () => ({
Expand Down Expand Up @@ -76,6 +77,11 @@ vi.mock('../../../../main/process-manager/utils/streamJsonBuilder', () => ({
vi.mock('../../../../main/utils/ssh-command-builder', () => ({
buildSshCommandWithStdin: vi.fn().mockImplementation(async (config, remoteOptions) => {
const args: string[] = [];
const escapeRemotePath = (remotePath: string) => {
if (remotePath === '~') return '"$HOME"';
if (remotePath.startsWith('~/')) return `"$HOME"/'${remotePath.slice(2)}'`;
return `'${remotePath}'`;
};

// Add identity file if provided
if (config.privateKeyPath) {
Expand Down Expand Up @@ -110,7 +116,7 @@ vi.mock('../../../../main/utils/ssh-command-builder', () => ({
);

if (remoteOptions.cwd) {
scriptLines.push(`cd '${remoteOptions.cwd}' || exit 1`);
scriptLines.push(`cd ${escapeRemotePath(remoteOptions.cwd)} || exit 1`);
}

// Add env vars if present
Expand Down Expand Up @@ -1126,6 +1132,21 @@ describe('process IPC handlers', () => {

it('does not sanitize SSH-enabled spawns (transcript lives on remote, not local disk)', async () => {
mockAgentDetector.getAgent.mockResolvedValue(claudeCodeAgent);
mockSettingsStore.get.mockImplementation((key, defaultValue) => {
if (key === 'sshRemotes') {
return [
{
id: 'remote-1',
name: 'Dev Server',
host: 'dev.example.com',
port: 22,
username: 'devuser',
enabled: true,
},
];
}
return defaultValue;
});
mockProcessManager.spawn.mockReturnValue({ pid: 4250, success: true });

const handler = handlers.get('process:spawn');
Expand Down Expand Up @@ -2183,7 +2204,7 @@ describe('process IPC handlers', () => {
);
});

it('should run locally when no SSH remotes are configured', async () => {
it('should fail when SSH is enabled but no SSH remote resolves', async () => {
const mockAgent = {
id: 'claude-code',
requiresPty: true,
Expand All @@ -2197,26 +2218,22 @@ describe('process IPC handlers', () => {
mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true });

const handler = handlers.get('process:spawn');
await handler!({} as any, {
sessionId: 'session-1',
toolType: 'claude-code',
cwd: '/local/project',
command: 'claude',
args: ['--print'],
// Session config points to non-existent remote
sessionSshRemoteConfig: {
enabled: true,
remoteId: 'remote-1',
},
});

// No matching SSH remote, should run locally
expect(mockProcessManager.spawn).toHaveBeenCalledWith(
expect.objectContaining({
await expect(
handler!({} as any, {
sessionId: 'session-1',
toolType: 'claude-code',
cwd: '/local/project',
command: 'claude',
requiresPty: true, // Preserved when running locally
args: ['--print'],
// Session config points to non-existent remote
sessionSshRemoteConfig: {
enabled: true,
remoteId: 'remote-1',
},
})
);
).rejects.toThrow('SSH remote configuration could not be resolved');

expect(mockProcessManager.spawn).not.toHaveBeenCalled();
});

it('should use local home directory as cwd when spawning SSH (fixes ENOENT for remote-only paths)', async () => {
Expand Down Expand Up @@ -2264,6 +2281,88 @@ describe('process IPC handlers', () => {
expect(spawnCall.sshStdinScript).toContain('/home/remoteuser/remote-project');
});

it('should use session workingDirOverride as SSH remote cwd', async () => {
const mockAgent = {
id: 'claude-code',
requiresPty: false,
capabilities: {
supportsStreamJsonInput: true,
},
};

mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
mockSettingsStore.get.mockImplementation((key, defaultValue) => {
if (key === 'sshRemotes') return [mockSshRemote];
return defaultValue;
});
mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true });

const handler = handlers.get('process:spawn');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Test assertion diverges from real shellEscapeRemotePath output

The buildSshCommandWithStdin mock (line 113) generates cd '${remoteOptions.cwd}' || exit 1 — bare single-quoted path — rather than calling shellEscapeRemotePath. For a tilde path the real implementation produces cd "$HOME"/'git-projects' || exit 1, not cd '~/git-projects' || exit 1. This test verifies that the handler correctly routes workingDirOverride to the builder (good), but the assertion as written would still pass even if shellEscapeRemotePath were removed or broken. Tilde-expansion correctness is already covered in ssh-command-builder.test.ts.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ecf0c7690. The buildSshCommandWithStdin mock now matches the real shellEscapeRemotePath tilde output for ~/git-projects, and the assertion expects cd "$HOME"/'git-projects' || exit 1. The production tilde contract remains covered in ssh-command-builder.test.ts; this handler test now verifies routing while staying faithful to the builder output.

await handler!({} as any, {
sessionId: 'session-1',
toolType: 'claude-code',
cwd: '/Users/tester/git-projects',
command: 'claude',
args: ['--print'],
sessionSshRemoteConfig: {
enabled: true,
remoteId: 'remote-1',
workingDirOverride: '~/git-projects',
},
});

const spawnCall = mockProcessManager.spawn.mock.calls[0][0];
expect(spawnCall.command).toBe('ssh');
expect(spawnCall.sshStdinScript).toContain('cd "$HOME"/\'git-projects\' || exit 1');
expect(spawnCall.sshStdinScript).not.toContain('/Users/tester/git-projects');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('should not pass local cwd through Codex -C when spawning over SSH', async () => {
const mockAgent = {
id: 'codex',
requiresPty: false,
binaryName: 'codex',
workingDirArgs: (dir: string) => ['-C', dir],
batchModePrefix: ['exec'],
batchModeArgs: ['--skip-git-repo-check'],
jsonOutputArgs: ['--json'],
capabilities: {},
};

mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
mockSettingsStore.get.mockImplementation((key, defaultValue) => {
if (key === 'sshRemotes') return [mockSshRemote];
return defaultValue;
});
mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true });

const handler = handlers.get('process:spawn');
await handler!({} as any, {
sessionId: 'session-1',
toolType: 'codex',
cwd: '/Users/tester/git-projects/agents/rai',
command: 'codex',
args: [],
prompt: 'hello',
sessionSshRemoteConfig: {
enabled: true,
remoteId: 'remote-1',
workingDirOverride: '/home/rai/git-projects/agents/rai',
},
});

const spawnCall = mockProcessManager.spawn.mock.calls[0][0];
expect(spawnCall.command).toBe('ssh');
expect(spawnCall.sshStdinScript).toContain(
"cd '/home/rai/git-projects/agents/rai' || exit 1"
);
expect(buildAgentArgs).toHaveBeenCalledWith(
expect.objectContaining({ id: 'codex' }),
expect.objectContaining({ cwd: '.' })
);
expect(spawnCall.sshStdinScript).not.toContain('/Users/tester/git-projects/agents/rai');
});

it('should use agent binaryName for SSH remote instead of local path (fixes Codex/Claude remote path issue)', async () => {
// This test verifies the fix for GitHub issue #161
// The bug: When executing agents on remote hosts, Maestro was using the locally-detected
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/main/utils/ssh-command-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ describe('ssh-command-builder', () => {
expect(result).toBe("cd '/home/user/project' && claude '--print'");
});

it('expands remote tilde cwd with HOME', async () => {
const result = buildRemoteCommand({
command: 'claude',
args: ['--print'],
cwd: '~/git-projects',
});
expect(result).toBe("cd \"$HOME\"/'git-projects' && claude '--print'");
});

it('builds a command with environment variables', async () => {
const result = buildRemoteCommand({
command: 'claude',
Expand Down Expand Up @@ -773,6 +782,16 @@ describe('ssh-command-builder', () => {
expect(result.stdinScript).toContain("cd '/home/user/project'");
});

it('expands remote tilde cwd in stdin script', async () => {
const result = await buildSshCommandWithStdin(baseConfig, {
command: 'opencode',
args: ['run'],
cwd: '~/git-projects',
});

expect(result.stdinScript).toContain('cd "$HOME"/\'git-projects\' || exit 1');
});

it('includes environment variables in stdin script', async () => {
const result = await buildSshCommandWithStdin(baseConfig, {
command: 'opencode',
Expand Down
133 changes: 133 additions & 0 deletions src/__tests__/main/utils/ssh-spawn-wrapper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { wrapSpawnWithSsh } from '../../../main/utils/ssh-spawn-wrapper';
import { buildSshCommand, buildSshCommandWithStdin } from '../../../main/utils/ssh-command-builder';
import type { SshRemoteConfig } from '../../../shared/types';

vi.mock('../../../main/utils/ssh-command-builder', () => ({
buildSshCommand: vi.fn(),
buildSshCommandWithStdin: vi.fn(),
}));

const remote: SshRemoteConfig = {
id: 'remote-1',
name: 'Remote One',
host: 'remote.example.com',
port: 22,
username: 'dev',
enabled: true,
};

const sshStore = {
getSshRemotes: () => [remote],
};

describe('ssh-spawn-wrapper', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(buildSshCommand).mockResolvedValue({
command: 'ssh',
args: ['remote.example.com', 'claude'],
remoteCommandLine: 'claude',
});
vi.mocked(buildSshCommandWithStdin).mockResolvedValue({
command: 'ssh',
args: ['remote.example.com', '/bin/bash'],
stdinScript: 'exec claude',
remoteCommandLine: 'claude',
});
});

it('uses session workingDirOverride as remote cwd for command-line prompts', async () => {
await wrapSpawnWithSsh(
{
command: 'claude',
args: ['--print'],
cwd: '/Users/jta/git-projects',
prompt: 'hi',
agentBinaryName: 'claude',
},
{ enabled: true, remoteId: 'remote-1', workingDirOverride: '~/git-projects' },
sshStore
);

expect(buildSshCommand).toHaveBeenCalledWith(
remote,
expect.objectContaining({
cwd: '~/git-projects',
})
);
});

it('uses session workingDirOverride as remote cwd for stdin prompts', async () => {
await wrapSpawnWithSsh(
{
command: 'claude',
args: ['--print'],
cwd: '/Users/jta/git-projects',
prompt: 'x'.repeat(4001),
agentBinaryName: 'claude',
},
{ enabled: true, remoteId: 'remote-1', workingDirOverride: '~/git-projects' },
sshStore
);

expect(buildSshCommandWithStdin).toHaveBeenCalledWith(
remote,
expect.objectContaining({
cwd: '~/git-projects',
})
);
});

it('normalizes local working directory args to current directory for SSH commands', async () => {
await wrapSpawnWithSsh(
{
command: 'codex',
args: ['-C', '/Users/jta/git-projects/agents/rai', 'exec'],
cwd: '/Users/jta/git-projects/agents/rai',
prompt: 'hi',
agentBinaryName: 'codex',
},
{
enabled: true,
remoteId: 'remote-1',
workingDirOverride: '/home/rai/git-projects/agents/rai',
},
sshStore
);

expect(buildSshCommand).toHaveBeenCalledWith(
remote,
expect.objectContaining({
args: ['-C', '.', 'exec', '--', 'hi'],
cwd: '/home/rai/git-projects/agents/rai',
})
);
});

it('normalizes inline local working directory args to current directory for SSH commands', async () => {
await wrapSpawnWithSsh(
{
command: 'codex',
args: ['--cwd=/Users/jta/git-projects/agents/rai', 'exec'],
cwd: '/Users/jta/git-projects/agents/rai',
prompt: 'hi',
agentBinaryName: 'codex',
},
{
enabled: true,
remoteId: 'remote-1',
workingDirOverride: '/home/rai/git-projects/agents/rai',
},
sshStore
);

expect(buildSshCommand).toHaveBeenCalledWith(
remote,
expect.objectContaining({
args: ['--cwd=.', 'exec', '--', 'hi'],
cwd: '/home/rai/git-projects/agents/rai',
})
);
});
});
8 changes: 4 additions & 4 deletions src/__tests__/renderer/components/NewInstanceModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2913,7 +2913,7 @@ describe('NewInstanceModal', () => {
target: { value: 'Remote Agent' },
});
fireEvent.change(screen.getByLabelText('Working Directory'), {
target: { value: '/home/devuser/my-project' },
target: { value: '~/git-projects' },
});

// Wait for remote path validation
Expand All @@ -2932,7 +2932,7 @@ describe('NewInstanceModal', () => {
// The critical assertion: workingDirOverride MUST match the remote path
expect(onCreate).toHaveBeenCalledWith(
'claude-code',
'/home/devuser/my-project',
'/home/testuser/git-projects',
'Remote Agent',
undefined,
undefined,
Expand All @@ -2945,11 +2945,11 @@ describe('NewInstanceModal', () => {
expect.objectContaining({
enabled: true,
remoteId: 'remote-1',
workingDirOverride: '/home/devuser/my-project',
workingDirOverride: '~/git-projects',
}),
undefined,
undefined,
undefined, // enableMaestroP unset: API is the default for Claude Code (Adaptive Mode off)
undefined, // enableMaestroP unset until the user opts into Adaptive Mode
undefined, // maestroPPath
undefined // maestroPMode unset until the user opts into TUI/Dynamic
);
Expand Down
Loading