Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
83 changes: 83 additions & 0 deletions src/__tests__/main/ipc/handlers/process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,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 @@ -2147,6 +2148,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 '~/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
107 changes: 107 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,107 @@
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',
})
);
});
});
10 changes: 5 additions & 5 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 Expand Up @@ -3064,7 +3064,7 @@ describe('NewInstanceModal', () => {
}),
undefined,
undefined,
undefined, // enableMaestroP unset: API is the default for Claude Code (Adaptive Mode off)
false, // duplicated source explicitly had Adaptive Mode off
undefined, // maestroPPath
undefined // maestroPMode unset until the user opts into TUI/Dynamic
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,69 @@ describe('EditAgentModal', () => {
);
});

it('should allow editing workingDirOverride for SSH sessions', async () => {
const sshSession = createSession({
projectRoot: '/Users/jta',
cwd: '/Users/jta',
sessionSshRemoteConfig: {
enabled: true,
remoteId: 'remote-1',
workingDirOverride: '/Users/jta',
},
});

vi.mocked(window.maestro.sshRemote.getConfigs).mockResolvedValue({
success: true,
configs: [
{
id: 'remote-1',
name: 'Dev Server',
host: 'dev.example.com',
port: 22,
username: 'devuser',
privateKeyPath: '/path/to/key',
enabled: true,
},
],
});

render(
<EditAgentModal
isOpen={true}
onClose={onClose}
onSave={onSave}
theme={theme}
session={sshSession}
existingSessions={[]}
/>
);

const remoteDirInput = (await screen.findByDisplayValue('/Users/jta')) as HTMLInputElement;
fireEvent.change(remoteDirInput, { target: { value: '~/git-projects' } });
fireEvent.click(screen.getByText('Save Changes'));

expect(onSave).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
undefined,
'Be concise',
undefined,
'/custom/claude',
'--verbose',
{ API_KEY: 'test-key' },
'claude-sonnet',
100000,
expect.objectContaining({
enabled: true,
remoteId: 'remote-1',
workingDirOverride: '~/git-projects',
}),
undefined,
undefined,
undefined
);
});

it('should preserve shareHistoryToProjectDir when toggling the SSH dropdown (regression: remote-controlled flag was silently dropped)', async () => {
// Regression test: the SSH dropdown's onChange used to rebuild the config
// with only enabled/remoteId/syncHistory, silently dropping
Expand Down
Loading