mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Merge pull request #111 from pedramamini/1-ssh-tunnel-agents
feat: SSH Remote Execution for AI Agents
This commit is contained in:
@@ -17,6 +17,7 @@ Settings are organized into tabs:
|
||||
| **Appearance** | Font size, UI density |
|
||||
| **Notifications** | Sound alerts, text-to-speech settings |
|
||||
| **AI Commands** | View and edit slash commands, [Spec-Kit](./speckit-commands), and [OpenSpec](./openspec-commands) prompts |
|
||||
| **SSH Remotes** | Configure remote hosts for [SSH agent execution](./ssh-remote-execution) |
|
||||
|
||||
## Checking for Updates
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"git-worktrees",
|
||||
"group-chat",
|
||||
"remote-access",
|
||||
"ssh-remote-execution",
|
||||
"configuration"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ icon: sparkles
|
||||
- 🏪 **[Playbook Exchange](./playbook-exchange)** - Browse and import community-contributed playbooks directly into your Auto Run folder. Categories, search, and one-click import get you started with proven workflows for security audits, code reviews, documentation, and more.
|
||||
- 💬 **[Group Chat](./group-chat)** - Coordinate multiple AI agents in a single conversation. A moderator AI orchestrates discussions, routing questions to the right agents and synthesizing their responses for cross-project questions and architecture discussions.
|
||||
- 🌐 **[Remote Access](./remote-access)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere.
|
||||
- 🔗 **[SSH Remote Execution](./ssh-remote-execution)** - Run AI agents on remote hosts via SSH. Leverage powerful cloud VMs, access tools not installed locally, or work with projects requiring specific environments — all while controlling everything from your local Maestro instance.
|
||||
- 💻 **[Command Line Interface](./cli)** - Full CLI (`maestro-cli`) for headless operation. List agents/groups, run playbooks from cron jobs or CI/CD pipelines, with human-readable or JSONL output for scripting.
|
||||
- 🚀 **Multi-Agent Management** - Run unlimited agents in parallel. Each agent has its own workspace, conversation history, and isolated context.
|
||||
- 📬 **Message Queueing** - Queue messages while AI is busy; they're sent automatically when the agent becomes ready. Never lose a thought.
|
||||
|
||||
138
docs/ssh-remote-execution.md
Normal file
138
docs/ssh-remote-execution.md
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
title: SSH Remote Execution
|
||||
description: Run AI agents on remote hosts via SSH for access to powerful machines or specialized tools.
|
||||
icon: server
|
||||
---
|
||||
|
||||
Run AI agents on remote machines via SSH instead of locally. This enables you to leverage powerful remote servers, access tools not installed on your local machine, or work with projects that must run in specific environments.
|
||||
|
||||
## Overview
|
||||
|
||||
SSH Remote Execution wraps agent commands in SSH, executing them on a configured remote host while streaming output back to Maestro. Your local Maestro instance remains the control center, but the AI agent runs remotely.
|
||||
|
||||
**Use cases:**
|
||||
- Run agents on a powerful cloud VM with more CPU/RAM
|
||||
- Access tools or SDKs installed only on specific servers
|
||||
- Work with codebases that require particular OS or architecture
|
||||
- Execute agents in secure/isolated environments
|
||||
|
||||
## Configuring SSH Remotes
|
||||
|
||||
### Adding a Remote Host
|
||||
|
||||
1. Open **Settings** (`Cmd+,` / `Ctrl+,`)
|
||||
2. Scroll to the **SSH Remote Hosts** section under Remote Execution
|
||||
3. Click **Add SSH Remote**
|
||||
4. Configure the connection:
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **Name** | Display name for this remote (e.g., "Dev Server", "GPU Box") |
|
||||
| **Host** | Hostname or IP address |
|
||||
| **Port** | SSH port (default: 22) |
|
||||
| **Username** | SSH username for authentication |
|
||||
| **Private Key Path** | Path to your SSH private key (e.g., `~/.ssh/id_rsa`) |
|
||||
| **Remote Working Directory** | Optional default working directory on the remote host |
|
||||
| **Environment Variables** | Optional key-value pairs to set on the remote |
|
||||
| **Enabled** | Toggle to temporarily disable without deleting |
|
||||
|
||||
5. Click **Test Connection** to verify connectivity
|
||||
6. Click **Save** to store the configuration
|
||||
|
||||
### Connection Testing
|
||||
|
||||
Before saving, you can test your SSH configuration:
|
||||
|
||||
- **Basic test**: Verifies SSH connectivity and authentication
|
||||
- **Agent test**: Checks if the AI agent command is available on the remote host
|
||||
|
||||
A successful test shows the remote hostname. Failed tests display specific error messages to help diagnose issues.
|
||||
|
||||
### Setting a Global Default
|
||||
|
||||
Click the checkmark icon next to any remote to set it as the **global default**. When set:
|
||||
- All agents use this remote by default
|
||||
- Individual agents can override this setting
|
||||
- The default badge appears next to the remote name
|
||||
|
||||
Click the checkmark again to clear the default and return to local execution.
|
||||
|
||||
## Per-Agent Configuration
|
||||
|
||||
Each agent can have its own SSH remote setting, overriding the global default.
|
||||
|
||||
### Configuring an Agent
|
||||
|
||||
1. Open the agent's configuration panel (gear icon in session header, or via Settings → Agents)
|
||||
2. Find the **SSH Remote** dropdown
|
||||
3. Select an option:
|
||||
|
||||
| Option | Behavior |
|
||||
|--------|----------|
|
||||
| **Use Global Default** | Follows the global setting (shows which remote if one is set) |
|
||||
| **Force Local Execution** | Always runs locally, ignoring any global default |
|
||||
| **[Specific Remote]** | Always uses this remote, regardless of global setting |
|
||||
|
||||
### Resolution Order
|
||||
|
||||
When spawning an agent, Maestro resolves which SSH remote to use:
|
||||
|
||||
1. **Per-agent explicit remote** → Uses that specific remote
|
||||
2. **Per-agent "Force Local"** → Runs locally (ignores global)
|
||||
3. **Per-agent "Use Global Default"** → Falls through to global setting
|
||||
4. **Global default set** → Uses the global default remote
|
||||
5. **No global default** → Runs locally
|
||||
|
||||
## Status Visibility
|
||||
|
||||
When a session is running via SSH remote:
|
||||
- The session displays the remote host name in the status area
|
||||
- Connection state reflects SSH connectivity
|
||||
- Errors are detected and displayed with SSH-specific context
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Errors
|
||||
|
||||
| Error | Solution |
|
||||
|-------|----------|
|
||||
| "Permission denied (publickey)" | Ensure your SSH key is added to the remote's `~/.ssh/authorized_keys` |
|
||||
| "Host key verification failed" | Add the host to known_hosts: `ssh-keyscan hostname >> ~/.ssh/known_hosts` |
|
||||
| "Enter passphrase for key" | Use a key without a passphrase, or add it to ssh-agent: `ssh-add ~/.ssh/your_key` |
|
||||
|
||||
### Connection Errors
|
||||
|
||||
| Error | Solution |
|
||||
|-------|----------|
|
||||
| "Connection refused" | Verify SSH server is running on the remote host |
|
||||
| "Connection timed out" | Check network connectivity and firewall rules |
|
||||
| "Could not resolve hostname" | Verify the hostname/IP is correct |
|
||||
| "No route to host" | Check network path to the remote host |
|
||||
|
||||
### Agent Errors
|
||||
|
||||
| Error | Solution |
|
||||
|-------|----------|
|
||||
| "Command not found" | Install the AI agent on the remote host |
|
||||
| "Agent binary not found" | Ensure the agent is in the remote's PATH |
|
||||
|
||||
### Tips
|
||||
|
||||
- **Use SSH config**: Add hosts to `~/.ssh/config` for simpler configuration
|
||||
- **Key management**: Use `ssh-agent` to avoid passphrase prompts
|
||||
- **Keep-alive**: Configure `ServerAliveInterval` in SSH config for long sessions
|
||||
- **Test manually first**: Verify `ssh user@host 'claude --version'` works before configuring in Maestro
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- SSH keys should have appropriate permissions (`chmod 600`)
|
||||
- Use dedicated keys for Maestro if desired
|
||||
- Remote working directories should have appropriate access controls
|
||||
- Environment variables may contain sensitive data; they're passed via SSH command line
|
||||
|
||||
## Limitations
|
||||
|
||||
- PTY (pseudo-terminal) features are not available over SSH
|
||||
- Some interactive agent features may behave differently
|
||||
- Network latency affects perceived responsiveness
|
||||
- The remote host must have the agent CLI installed and configured
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "maestro",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "maestro",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL 3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -107,12 +107,21 @@ describe('process IPC handlers', () => {
|
||||
set: vi.fn(),
|
||||
};
|
||||
|
||||
// Create mock main window for SSH remote event emission
|
||||
const mockMainWindow = {
|
||||
isDestroyed: vi.fn().mockReturnValue(false),
|
||||
webContents: {
|
||||
send: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Create dependencies
|
||||
deps = {
|
||||
getProcessManager: () => mockProcessManager as any,
|
||||
getAgentDetector: () => mockAgentDetector as any,
|
||||
agentConfigsStore: mockAgentConfigsStore as any,
|
||||
settingsStore: mockSettingsStore as any,
|
||||
getMainWindow: () => mockMainWindow as any,
|
||||
};
|
||||
|
||||
// Capture all registered handlers
|
||||
@@ -599,4 +608,306 @@ describe('process IPC handlers', () => {
|
||||
})).rejects.toThrow('Agent detector');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSH remote execution', () => {
|
||||
const mockSshRemote = {
|
||||
id: 'remote-1',
|
||||
name: 'Dev Server',
|
||||
host: 'dev.example.com',
|
||||
port: 22,
|
||||
username: 'devuser',
|
||||
privateKeyPath: '~/.ssh/id_ed25519',
|
||||
enabled: true,
|
||||
remoteEnv: { REMOTE_VAR: 'remote-value' },
|
||||
};
|
||||
|
||||
it('should wrap agent command with SSH when global default remote is configured', async () => {
|
||||
const mockAgent = {
|
||||
id: 'claude-code',
|
||||
name: 'Claude Code',
|
||||
requiresPty: false,
|
||||
};
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
mockSettingsStore.get.mockImplementation((key, defaultValue) => {
|
||||
if (key === 'sshRemotes') return [mockSshRemote];
|
||||
if (key === 'defaultSshRemoteId') return 'remote-1';
|
||||
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',
|
||||
command: 'claude',
|
||||
args: ['--print', '--verbose'],
|
||||
});
|
||||
|
||||
// Should spawn with 'ssh' command instead of 'claude'
|
||||
expect(mockProcessManager.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'ssh',
|
||||
// SSH args should include authentication and remote command
|
||||
args: expect.arrayContaining([
|
||||
'-i', expect.stringContaining('.ssh/id_ed25519'),
|
||||
'-p', '22',
|
||||
'devuser@dev.example.com',
|
||||
]),
|
||||
// SSH remote execution disables PTY
|
||||
requiresPty: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use agent-specific SSH remote override', async () => {
|
||||
const agentSpecificRemote = {
|
||||
...mockSshRemote,
|
||||
id: 'agent-remote',
|
||||
name: 'Agent-Specific Server',
|
||||
host: 'agent.example.com',
|
||||
};
|
||||
|
||||
const mockAgent = {
|
||||
id: 'claude-code',
|
||||
requiresPty: true, // Note: should be disabled when using SSH
|
||||
};
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
mockAgentConfigsStore.get.mockReturnValue({
|
||||
'claude-code': {
|
||||
sshRemote: {
|
||||
enabled: true,
|
||||
remoteId: 'agent-remote',
|
||||
},
|
||||
},
|
||||
});
|
||||
mockSettingsStore.get.mockImplementation((key, defaultValue) => {
|
||||
if (key === 'sshRemotes') return [mockSshRemote, agentSpecificRemote];
|
||||
if (key === 'defaultSshRemoteId') return 'remote-1'; // Global default is different
|
||||
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',
|
||||
command: 'claude',
|
||||
args: ['--print'],
|
||||
});
|
||||
|
||||
// Should use agent-specific remote, not global default
|
||||
expect(mockProcessManager.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'ssh',
|
||||
args: expect.arrayContaining([
|
||||
'devuser@agent.example.com', // agent-specific host
|
||||
]),
|
||||
// PTY should be disabled for SSH
|
||||
requiresPty: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not use SSH for terminal sessions', async () => {
|
||||
const mockAgent = {
|
||||
id: 'terminal',
|
||||
requiresPty: true,
|
||||
};
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
mockSettingsStore.get.mockImplementation((key, defaultValue) => {
|
||||
if (key === 'sshRemotes') return [mockSshRemote];
|
||||
if (key === 'defaultSshRemoteId') return 'remote-1';
|
||||
if (key === 'defaultShell') return 'zsh';
|
||||
return defaultValue;
|
||||
});
|
||||
mockProcessManager.spawn.mockReturnValue({ pid: 12345, success: true });
|
||||
|
||||
const handler = handlers.get('process:spawn');
|
||||
await handler!({} as any, {
|
||||
sessionId: 'session-1',
|
||||
toolType: 'terminal',
|
||||
cwd: '/local/project',
|
||||
command: '/bin/zsh',
|
||||
args: [],
|
||||
});
|
||||
|
||||
// Terminal sessions should NOT use SSH - they need local PTY
|
||||
expect(mockProcessManager.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: '/bin/zsh',
|
||||
requiresPty: true,
|
||||
})
|
||||
);
|
||||
expect(mockProcessManager.spawn).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'ssh',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass custom env vars to SSH remote command', async () => {
|
||||
const mockAgent = {
|
||||
id: 'claude-code',
|
||||
requiresPty: false,
|
||||
};
|
||||
|
||||
// Mock applyAgentConfigOverrides to return custom env vars
|
||||
const { applyAgentConfigOverrides } = await import('../../../../main/utils/agent-args');
|
||||
vi.mocked(applyAgentConfigOverrides).mockReturnValue({
|
||||
args: ['--print'],
|
||||
modelSource: 'none',
|
||||
customArgsSource: 'none',
|
||||
customEnvSource: 'session',
|
||||
effectiveCustomEnvVars: { CUSTOM_API_KEY: 'secret123' },
|
||||
});
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
mockSettingsStore.get.mockImplementation((key, defaultValue) => {
|
||||
if (key === 'sshRemotes') return [mockSshRemote];
|
||||
if (key === 'defaultSshRemoteId') return 'remote-1';
|
||||
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',
|
||||
command: 'claude',
|
||||
args: ['--print'],
|
||||
sessionCustomEnvVars: { CUSTOM_API_KEY: 'secret123' },
|
||||
});
|
||||
|
||||
// When using SSH, customEnvVars should be undefined (passed via remote command)
|
||||
expect(mockProcessManager.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'ssh',
|
||||
customEnvVars: undefined, // Env vars passed in SSH command, not locally
|
||||
})
|
||||
);
|
||||
|
||||
// The SSH args should contain the remote command with env vars
|
||||
const spawnCall = mockProcessManager.spawn.mock.calls[0][0];
|
||||
const remoteCommandArg = spawnCall.args[spawnCall.args.length - 1];
|
||||
expect(remoteCommandArg).toContain('CUSTOM_API_KEY=');
|
||||
});
|
||||
|
||||
it('should not wrap command when SSH is disabled for agent', async () => {
|
||||
const mockAgent = {
|
||||
id: 'claude-code',
|
||||
requiresPty: false,
|
||||
};
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
mockAgentConfigsStore.get.mockReturnValue({
|
||||
'claude-code': {
|
||||
sshRemote: {
|
||||
enabled: false, // Explicitly disabled for this agent
|
||||
remoteId: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
mockSettingsStore.get.mockImplementation((key, defaultValue) => {
|
||||
if (key === 'sshRemotes') return [mockSshRemote];
|
||||
if (key === 'defaultSshRemoteId') return 'remote-1';
|
||||
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',
|
||||
command: 'claude',
|
||||
args: ['--print'],
|
||||
});
|
||||
|
||||
// Agent has SSH disabled, should run locally
|
||||
expect(mockProcessManager.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'claude', // Original command, not 'ssh'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should run locally when no SSH remote is configured', async () => {
|
||||
const mockAgent = {
|
||||
id: 'claude-code',
|
||||
requiresPty: true,
|
||||
};
|
||||
|
||||
mockAgentDetector.getAgent.mockResolvedValue(mockAgent);
|
||||
mockSettingsStore.get.mockImplementation((key, defaultValue) => {
|
||||
if (key === 'sshRemotes') return []; // No remotes configured
|
||||
if (key === 'defaultSshRemoteId') return null;
|
||||
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',
|
||||
command: 'claude',
|
||||
args: ['--print'],
|
||||
});
|
||||
|
||||
// No SSH remote, should run locally with original command
|
||||
expect(mockProcessManager.spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'claude',
|
||||
requiresPty: true, // Preserved when running locally
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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];
|
||||
if (key === 'defaultSshRemoteId') return 'remote-1';
|
||||
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'],
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
336
src/__tests__/main/ipc/handlers/ssh-remote.test.ts
Normal file
336
src/__tests__/main/ipc/handlers/ssh-remote.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* Tests for SSH Remote IPC Handlers
|
||||
*
|
||||
* Tests the IPC handlers for managing SSH remote configurations:
|
||||
* - ssh-remote:saveConfig
|
||||
* - ssh-remote:deleteConfig
|
||||
* - ssh-remote:getConfigs
|
||||
* - ssh-remote:getDefaultId
|
||||
* - ssh-remote:setDefaultId
|
||||
* - ssh-remote:test
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { ipcMain } from 'electron';
|
||||
import { registerSshRemoteHandlers } from '../../../../main/ipc/handlers/ssh-remote';
|
||||
import { SshRemoteConfig } from '../../../../shared/types';
|
||||
import * as sshRemoteManagerModule from '../../../../main/ssh-remote-manager';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../../../../main/utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock electron's ipcMain
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Capture registered handlers
|
||||
const registeredHandlers: Map<string, (...args: unknown[]) => Promise<unknown>> = new Map();
|
||||
|
||||
describe('SSH Remote IPC Handlers', () => {
|
||||
let mockSettingsStore: {
|
||||
get: Mock;
|
||||
set: Mock;
|
||||
};
|
||||
|
||||
const createMockConfig = (overrides: Partial<SshRemoteConfig> = {}): SshRemoteConfig => ({
|
||||
id: 'test-id',
|
||||
name: 'Test Remote',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'testuser',
|
||||
privateKeyPath: '/home/user/.ssh/id_rsa',
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
registeredHandlers.clear();
|
||||
|
||||
// Capture handler registrations
|
||||
(ipcMain.handle as Mock).mockImplementation((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
|
||||
registeredHandlers.set(channel, handler);
|
||||
});
|
||||
|
||||
// Create mock settings store
|
||||
mockSettingsStore = {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
};
|
||||
|
||||
// Default store state
|
||||
mockSettingsStore.get.mockImplementation((key: string, defaultValue: unknown) => {
|
||||
if (key === 'sshRemotes') return [];
|
||||
if (key === 'defaultSshRemoteId') return null;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
// Register handlers
|
||||
registerSshRemoteHandlers({ settingsStore: mockSettingsStore as unknown as Parameters<typeof registerSshRemoteHandlers>[0]['settingsStore'] });
|
||||
});
|
||||
|
||||
// Helper to invoke a registered handler
|
||||
async function invokeHandler(channel: string, ...args: unknown[]): Promise<unknown> {
|
||||
const handler = registeredHandlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`No handler registered for channel: ${channel}`);
|
||||
}
|
||||
// IPC handlers receive (event, ...args), but our wrapper strips the event
|
||||
return handler({}, ...args);
|
||||
}
|
||||
|
||||
describe('handler registration', () => {
|
||||
it('registers all SSH remote handlers', () => {
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith('ssh-remote:saveConfig', expect.any(Function));
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith('ssh-remote:deleteConfig', expect.any(Function));
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith('ssh-remote:getConfigs', expect.any(Function));
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith('ssh-remote:getDefaultId', expect.any(Function));
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith('ssh-remote:setDefaultId', expect.any(Function));
|
||||
expect(ipcMain.handle).toHaveBeenCalledWith('ssh-remote:test', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh-remote:getConfigs', () => {
|
||||
it('returns empty array when no configs exist', async () => {
|
||||
const result = await invokeHandler('ssh-remote:getConfigs');
|
||||
expect(result).toEqual({ success: true, configs: [] });
|
||||
});
|
||||
|
||||
it('returns existing configs', async () => {
|
||||
const configs = [createMockConfig()];
|
||||
mockSettingsStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'sshRemotes') return configs;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await invokeHandler('ssh-remote:getConfigs');
|
||||
expect(result).toEqual({ success: true, configs });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh-remote:saveConfig', () => {
|
||||
beforeEach(() => {
|
||||
// Mock validateConfig to return valid
|
||||
vi.spyOn(sshRemoteManagerModule.sshRemoteManager, 'validateConfig').mockReturnValue({
|
||||
valid: true,
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a new config with generated ID', async () => {
|
||||
const configData = {
|
||||
name: 'New Remote',
|
||||
host: 'new.example.com',
|
||||
port: 22,
|
||||
username: 'user',
|
||||
privateKeyPath: '/path/to/key',
|
||||
};
|
||||
|
||||
const result = await invokeHandler('ssh-remote:saveConfig', configData) as { success: boolean; config?: SshRemoteConfig };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config).toBeDefined();
|
||||
expect(result.config?.id).toBeDefined();
|
||||
expect(result.config?.name).toBe('New Remote');
|
||||
expect(mockSettingsStore.set).toHaveBeenCalledWith('sshRemotes', expect.any(Array));
|
||||
});
|
||||
|
||||
it('updates existing config when ID matches', async () => {
|
||||
const existingConfig = createMockConfig();
|
||||
mockSettingsStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'sshRemotes') return [existingConfig];
|
||||
return null;
|
||||
});
|
||||
|
||||
const updates = {
|
||||
id: existingConfig.id,
|
||||
name: 'Updated Remote',
|
||||
host: existingConfig.host,
|
||||
port: existingConfig.port,
|
||||
username: existingConfig.username,
|
||||
privateKeyPath: existingConfig.privateKeyPath,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const result = await invokeHandler('ssh-remote:saveConfig', updates) as { success: boolean; config?: SshRemoteConfig };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.config?.name).toBe('Updated Remote');
|
||||
expect(mockSettingsStore.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when validation fails', async () => {
|
||||
vi.spyOn(sshRemoteManagerModule.sshRemoteManager, 'validateConfig').mockReturnValue({
|
||||
valid: false,
|
||||
errors: ['Host is required', 'Username is required'],
|
||||
});
|
||||
|
||||
const result = await invokeHandler('ssh-remote:saveConfig', {}) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid configuration');
|
||||
expect(result.error).toContain('Host is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh-remote:deleteConfig', () => {
|
||||
it('deletes an existing config', async () => {
|
||||
const config = createMockConfig();
|
||||
mockSettingsStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'sshRemotes') return [config];
|
||||
if (key === 'defaultSshRemoteId') return null;
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await invokeHandler('ssh-remote:deleteConfig', config.id);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockSettingsStore.set).toHaveBeenCalledWith('sshRemotes', []);
|
||||
});
|
||||
|
||||
it('clears default ID when deleted config was default', async () => {
|
||||
const config = createMockConfig();
|
||||
mockSettingsStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'sshRemotes') return [config];
|
||||
if (key === 'defaultSshRemoteId') return config.id;
|
||||
return null;
|
||||
});
|
||||
|
||||
await invokeHandler('ssh-remote:deleteConfig', config.id);
|
||||
|
||||
expect(mockSettingsStore.set).toHaveBeenCalledWith('defaultSshRemoteId', null);
|
||||
});
|
||||
|
||||
it('returns error when config not found', async () => {
|
||||
const result = await invokeHandler('ssh-remote:deleteConfig', 'non-existent-id') as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('SSH remote not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh-remote:getDefaultId', () => {
|
||||
it('returns null when no default is set', async () => {
|
||||
const result = await invokeHandler('ssh-remote:getDefaultId');
|
||||
expect(result).toEqual({ success: true, id: null });
|
||||
});
|
||||
|
||||
it('returns the default ID when set', async () => {
|
||||
mockSettingsStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'defaultSshRemoteId') return 'my-default-id';
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await invokeHandler('ssh-remote:getDefaultId');
|
||||
expect(result).toEqual({ success: true, id: 'my-default-id' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh-remote:setDefaultId', () => {
|
||||
it('sets the default ID', async () => {
|
||||
const config = createMockConfig({ id: 'config-to-set' });
|
||||
mockSettingsStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'sshRemotes') return [config];
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await invokeHandler('ssh-remote:setDefaultId', 'config-to-set');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockSettingsStore.set).toHaveBeenCalledWith('defaultSshRemoteId', 'config-to-set');
|
||||
});
|
||||
|
||||
it('clears the default ID when null is passed', async () => {
|
||||
const result = await invokeHandler('ssh-remote:setDefaultId', null);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockSettingsStore.set).toHaveBeenCalledWith('defaultSshRemoteId', null);
|
||||
});
|
||||
|
||||
it('returns error when config ID does not exist', async () => {
|
||||
const result = await invokeHandler('ssh-remote:setDefaultId', 'non-existent-id') as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('SSH remote not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssh-remote:test', () => {
|
||||
beforeEach(() => {
|
||||
// Default mock for testConnection
|
||||
vi.spyOn(sshRemoteManagerModule.sshRemoteManager, 'testConnection').mockResolvedValue({
|
||||
success: true,
|
||||
remoteInfo: {
|
||||
hostname: 'test-host',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('tests connection using config object', async () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
const result = await invokeHandler('ssh-remote:test', config) as { success: boolean; result?: { success: boolean } };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result?.success).toBe(true);
|
||||
expect(sshRemoteManagerModule.sshRemoteManager.testConnection).toHaveBeenCalledWith(config, undefined);
|
||||
});
|
||||
|
||||
it('tests connection by config ID', async () => {
|
||||
const config = createMockConfig();
|
||||
mockSettingsStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'sshRemotes') return [config];
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await invokeHandler('ssh-remote:test', config.id) as { success: boolean; result?: { success: boolean } };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result?.success).toBe(true);
|
||||
expect(sshRemoteManagerModule.sshRemoteManager.testConnection).toHaveBeenCalledWith(config, undefined);
|
||||
});
|
||||
|
||||
it('passes agent command when provided', async () => {
|
||||
const config = createMockConfig();
|
||||
mockSettingsStore.get.mockImplementation((key: string) => {
|
||||
if (key === 'sshRemotes') return [config];
|
||||
return null;
|
||||
});
|
||||
|
||||
await invokeHandler('ssh-remote:test', config.id, 'claude');
|
||||
|
||||
expect(sshRemoteManagerModule.sshRemoteManager.testConnection).toHaveBeenCalledWith(config, 'claude');
|
||||
});
|
||||
|
||||
it('returns error when config ID not found', async () => {
|
||||
const result = await invokeHandler('ssh-remote:test', 'non-existent-id') as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('SSH remote not found');
|
||||
});
|
||||
|
||||
it('returns test failure result', async () => {
|
||||
vi.spyOn(sshRemoteManagerModule.sshRemoteManager, 'testConnection').mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Connection refused',
|
||||
});
|
||||
|
||||
const config = createMockConfig();
|
||||
const result = await invokeHandler('ssh-remote:test', config) as { success: boolean; result?: { success: boolean; error?: string } };
|
||||
|
||||
expect(result.success).toBe(true); // IPC call succeeded
|
||||
expect(result.result?.success).toBe(false); // But test failed
|
||||
expect(result.result?.error).toBe('Connection refused');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
getErrorPatterns,
|
||||
matchErrorPattern,
|
||||
matchSshErrorPattern,
|
||||
getSshErrorPatterns,
|
||||
registerErrorPatterns,
|
||||
clearPatternRegistry,
|
||||
CLAUDE_ERROR_PATTERNS,
|
||||
OPENCODE_ERROR_PATTERNS,
|
||||
CODEX_ERROR_PATTERNS,
|
||||
SSH_ERROR_PATTERNS,
|
||||
type AgentErrorPatterns,
|
||||
} from '../../../main/parsers/error-patterns';
|
||||
|
||||
@@ -516,6 +519,216 @@ describe('error-patterns', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSH_ERROR_PATTERNS', () => {
|
||||
it('should define permission_denied patterns', () => {
|
||||
expect(SSH_ERROR_PATTERNS.permission_denied).toBeDefined();
|
||||
expect(SSH_ERROR_PATTERNS.permission_denied?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should define network_error patterns', () => {
|
||||
expect(SSH_ERROR_PATTERNS.network_error).toBeDefined();
|
||||
expect(SSH_ERROR_PATTERNS.network_error?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should define agent_crashed patterns', () => {
|
||||
expect(SSH_ERROR_PATTERNS.agent_crashed).toBeDefined();
|
||||
expect(SSH_ERROR_PATTERNS.agent_crashed?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe('permission_denied patterns', () => {
|
||||
it('should match "ssh: permission denied"', () => {
|
||||
const result = matchSshErrorPattern('ssh: Permission denied (publickey)');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('permission_denied');
|
||||
expect(result?.recoverable).toBe(false);
|
||||
});
|
||||
|
||||
it('should match "Permission denied (publickey"', () => {
|
||||
const result = matchSshErrorPattern('Permission denied (publickey,keyboard-interactive)');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('permission_denied');
|
||||
});
|
||||
|
||||
it('should match "host key verification failed"', () => {
|
||||
const result = matchSshErrorPattern('Host key verification failed.');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('permission_denied');
|
||||
});
|
||||
|
||||
it('should match "no matching host key type found"', () => {
|
||||
const result = matchSshErrorPattern('no matching host key type found. Their offer: ssh-rsa');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('permission_denied');
|
||||
});
|
||||
|
||||
it('should match "enter passphrase for key"', () => {
|
||||
const result = matchSshErrorPattern('Enter passphrase for key "/home/user/.ssh/id_rsa":');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('permission_denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('network_error patterns', () => {
|
||||
it('should match "ssh: connection refused"', () => {
|
||||
const result = matchSshErrorPattern('ssh: connect to host example.com port 22: Connection refused');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
expect(result?.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should match "ssh: connection timed out"', () => {
|
||||
const result = matchSshErrorPattern('ssh: connect to host example.com port 22: Connection timed out');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "ssh: operation timed out"', () => {
|
||||
const result = matchSshErrorPattern('ssh: connect to host example.com port 22: Operation timed out');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "ssh: could not resolve hostname"', () => {
|
||||
const result = matchSshErrorPattern('ssh: Could not resolve hostname example.com: nodename nor servname provided');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
expect(result?.recoverable).toBe(false); // DNS errors are not recoverable by retry
|
||||
});
|
||||
|
||||
it('should match "ssh: no route to host"', () => {
|
||||
const result = matchSshErrorPattern('ssh: connect to host example.com port 22: No route to host');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "ssh: connection reset"', () => {
|
||||
const result = matchSshErrorPattern('ssh: connect to host example.com port 22: Connection reset by peer');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "ssh: network is unreachable"', () => {
|
||||
const result = matchSshErrorPattern('ssh: connect to host example.com port 22: Network is unreachable');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "ssh: connection closed"', () => {
|
||||
const result = matchSshErrorPattern('ssh: Connection closed by 192.168.1.1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "connect to host...connection refused"', () => {
|
||||
const result = matchSshErrorPattern('connect to host example.com port 22: Connection refused');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent_crashed patterns', () => {
|
||||
it('should match "bash: command not found"', () => {
|
||||
const result = matchSshErrorPattern('bash: claude: command not found');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('agent_crashed');
|
||||
expect(result?.recoverable).toBe(false);
|
||||
});
|
||||
|
||||
it('should match "zsh: command not found"', () => {
|
||||
const result = matchSshErrorPattern('zsh: command not found: claude');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('agent_crashed');
|
||||
});
|
||||
|
||||
it('should match "sh: command not found"', () => {
|
||||
const result = matchSshErrorPattern('sh: claude: command not found');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('agent_crashed');
|
||||
});
|
||||
|
||||
it('should match generic "command not found:"', () => {
|
||||
const result = matchSshErrorPattern('command not found: opencode');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('agent_crashed');
|
||||
});
|
||||
|
||||
it('should match "no such file or directory" for agent binaries', () => {
|
||||
const result = matchSshErrorPattern('/usr/local/bin/claude: No such file or directory');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('agent_crashed');
|
||||
});
|
||||
|
||||
it('should match "ssh: broken pipe"', () => {
|
||||
const result = matchSshErrorPattern('ssh: Broken pipe');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('agent_crashed');
|
||||
expect(result?.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should match "ssh: client_loop: send disconnect"', () => {
|
||||
const result = matchSshErrorPattern('client_loop: send disconnect: Broken pipe');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('agent_crashed');
|
||||
});
|
||||
|
||||
it('should match "ssh: packet corrupt"', () => {
|
||||
const result = matchSshErrorPattern('ssh: packet corrupt');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('agent_crashed');
|
||||
});
|
||||
|
||||
it('should match "ssh: protocol error"', () => {
|
||||
const result = matchSshErrorPattern('ssh: protocol error');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('agent_crashed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-matching lines', () => {
|
||||
it('should return null for normal SSH output', () => {
|
||||
const result = matchSshErrorPattern('Connected to example.com');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
const result = matchSshErrorPattern('');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for normal agent output', () => {
|
||||
const result = matchSshErrorPattern('Hello, how can I help you today?');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for unrelated file errors', () => {
|
||||
// Should not match generic "no such file" - only agent-specific
|
||||
const result = matchSshErrorPattern('cat: somefile.txt: No such file or directory');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchSshErrorPattern', () => {
|
||||
it('should return error info for SSH errors', () => {
|
||||
const result = matchSshErrorPattern('ssh: Connection refused');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
expect(result?.message).toContain('SSH');
|
||||
expect(result?.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should return null for non-SSH errors', () => {
|
||||
const result = matchSshErrorPattern('Some normal output');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSshErrorPatterns', () => {
|
||||
it('should return SSH_ERROR_PATTERNS', () => {
|
||||
expect(getSshErrorPatterns()).toBe(SSH_ERROR_PATTERNS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerErrorPatterns', () => {
|
||||
afterEach(() => {
|
||||
clearPatternRegistry();
|
||||
|
||||
454
src/__tests__/main/ssh-remote-manager.test.ts
Normal file
454
src/__tests__/main/ssh-remote-manager.test.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
SshRemoteManager,
|
||||
sshRemoteManager,
|
||||
SshRemoteManagerDeps,
|
||||
} from '../../main/ssh-remote-manager';
|
||||
import { SshRemoteConfig } from '../../shared/types';
|
||||
import { ExecResult } from '../../main/utils/execFile';
|
||||
|
||||
describe('SshRemoteManager', () => {
|
||||
// Mock dependencies
|
||||
let mockCheckFileAccess: ReturnType<typeof vi.fn>;
|
||||
let mockExecSsh: ReturnType<typeof vi.fn<[string, string[]], Promise<ExecResult>>>;
|
||||
let mockDeps: SshRemoteManagerDeps;
|
||||
let manager: SshRemoteManager;
|
||||
|
||||
// Valid config for reuse in tests
|
||||
const validConfig: SshRemoteConfig = {
|
||||
id: 'test-remote',
|
||||
name: 'Test Remote',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'testuser',
|
||||
privateKeyPath: '~/.ssh/id_rsa',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Create fresh mocks for each test
|
||||
mockCheckFileAccess = vi.fn().mockReturnValue(true);
|
||||
mockExecSsh = vi.fn();
|
||||
mockDeps = {
|
||||
checkFileAccess: mockCheckFileAccess,
|
||||
execSsh: mockExecSsh,
|
||||
};
|
||||
manager = new SshRemoteManager(mockDeps);
|
||||
});
|
||||
|
||||
describe('validateConfig', () => {
|
||||
it('validates a complete valid configuration', () => {
|
||||
const result = manager.validateConfig(validConfig);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('requires id field', () => {
|
||||
const config = { ...validConfig, id: '' };
|
||||
const result = manager.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Configuration ID is required');
|
||||
});
|
||||
|
||||
it('requires name field', () => {
|
||||
const config = { ...validConfig, name: '' };
|
||||
const result = manager.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Name is required');
|
||||
});
|
||||
|
||||
it('requires host field', () => {
|
||||
const config = { ...validConfig, host: '' };
|
||||
const result = manager.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Host is required');
|
||||
});
|
||||
|
||||
it('requires username field', () => {
|
||||
const config = { ...validConfig, username: '' };
|
||||
const result = manager.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Username is required');
|
||||
});
|
||||
|
||||
it('requires privateKeyPath field', () => {
|
||||
const config = { ...validConfig, privateKeyPath: '' };
|
||||
const result = manager.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Private key path is required');
|
||||
});
|
||||
|
||||
it('validates port range - too low', () => {
|
||||
const config = { ...validConfig, port: 0 };
|
||||
const result = manager.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Port must be between 1 and 65535');
|
||||
});
|
||||
|
||||
it('validates port range - too high', () => {
|
||||
const config = { ...validConfig, port: 65536 };
|
||||
const result = manager.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Port must be between 1 and 65535');
|
||||
});
|
||||
|
||||
it('validates port range - valid edge cases', () => {
|
||||
const configPort1 = { ...validConfig, port: 1 };
|
||||
expect(manager.validateConfig(configPort1).valid).toBe(true);
|
||||
|
||||
const configPort65535 = { ...validConfig, port: 65535 };
|
||||
expect(manager.validateConfig(configPort65535).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('detects unreadable private key file', () => {
|
||||
mockCheckFileAccess.mockReturnValue(false);
|
||||
|
||||
const result = manager.validateConfig(validConfig);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Private key not readable: ~/.ssh/id_rsa');
|
||||
});
|
||||
|
||||
it('collects multiple validation errors', () => {
|
||||
mockCheckFileAccess.mockReturnValue(false);
|
||||
|
||||
const config: SshRemoteConfig = {
|
||||
id: '',
|
||||
name: '',
|
||||
host: '',
|
||||
port: 0,
|
||||
username: '',
|
||||
privateKeyPath: '~/.ssh/nonexistent',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const result = manager.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('handles whitespace-only fields as empty', () => {
|
||||
const config = { ...validConfig, name: ' ', host: '\t' };
|
||||
const result = manager.validateConfig(config);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain('Name is required');
|
||||
expect(result.errors).toContain('Host is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSshArgs', () => {
|
||||
it('builds correct SSH arguments for a config', () => {
|
||||
const args = manager.buildSshArgs(validConfig);
|
||||
|
||||
expect(args).toContain('-i');
|
||||
expect(args).toContain('-p');
|
||||
expect(args).toContain('22');
|
||||
expect(args).toContain('testuser@example.com');
|
||||
});
|
||||
|
||||
it('includes default SSH options', () => {
|
||||
const args = manager.buildSshArgs(validConfig);
|
||||
const argsString = args.join(' ');
|
||||
|
||||
expect(argsString).toContain('BatchMode=yes');
|
||||
expect(argsString).toContain('StrictHostKeyChecking=accept-new');
|
||||
expect(argsString).toContain('ConnectTimeout=10');
|
||||
});
|
||||
|
||||
it('expands tilde in private key path', () => {
|
||||
const originalHome = process.env.HOME;
|
||||
process.env.HOME = '/home/testuser';
|
||||
|
||||
try {
|
||||
const args = manager.buildSshArgs(validConfig);
|
||||
const keyIndex = args.indexOf('-i') + 1;
|
||||
|
||||
expect(args[keyIndex]).toBe('/home/testuser/.ssh/id_rsa');
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
});
|
||||
|
||||
it('handles non-standard port', () => {
|
||||
const config = { ...validConfig, port: 2222 };
|
||||
const args = manager.buildSshArgs(config);
|
||||
const portIndex = args.indexOf('-p') + 1;
|
||||
|
||||
expect(args[portIndex]).toBe('2222');
|
||||
});
|
||||
|
||||
it('handles absolute paths without expansion', () => {
|
||||
const config = { ...validConfig, privateKeyPath: '/etc/ssh/custom_key' };
|
||||
const args = manager.buildSshArgs(config);
|
||||
const keyIndex = args.indexOf('-i') + 1;
|
||||
|
||||
expect(args[keyIndex]).toBe('/etc/ssh/custom_key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('returns validation errors if config is invalid', async () => {
|
||||
const invalidConfig = { ...validConfig, host: '' };
|
||||
const result = await manager.testConnection(invalidConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Host is required');
|
||||
});
|
||||
|
||||
it('returns success with remote info on successful connection', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: 'SSH_OK\nremote-hostname\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remoteInfo?.hostname).toBe('remote-hostname');
|
||||
});
|
||||
|
||||
it('detects agent installation when checking with agentCommand', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: 'SSH_OK\nremote-hostname\n/usr/local/bin/claude\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig, 'claude');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remoteInfo?.agentVersion).toBe('installed');
|
||||
});
|
||||
|
||||
it('handles agent not found on remote', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: 'SSH_OK\nremote-hostname\nAGENT_NOT_FOUND\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig, 'claude');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.remoteInfo?.agentVersion).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles permission denied error', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: 'Permission denied (publickey)',
|
||||
exitCode: 255,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Authentication failed');
|
||||
});
|
||||
|
||||
it('handles connection refused error', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: 'ssh: connect to host example.com port 22: Connection refused',
|
||||
exitCode: 255,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Connection refused');
|
||||
});
|
||||
|
||||
it('handles connection timeout error', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: 'ssh: connect to host example.com port 22: Connection timed out',
|
||||
exitCode: 255,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Connection timed out');
|
||||
});
|
||||
|
||||
it('handles hostname resolution failure', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: 'ssh: Could not resolve hostname invalid.host: Name or service not known',
|
||||
exitCode: 255,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Could not resolve hostname');
|
||||
});
|
||||
|
||||
it('handles host key changed error', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr:
|
||||
'WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!\nIt is possible that someone is doing something nasty!',
|
||||
exitCode: 255,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('SSH host key changed');
|
||||
});
|
||||
|
||||
it('handles passphrase-protected key error', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: 'Enter passphrase for key',
|
||||
exitCode: 255,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('passphrase');
|
||||
});
|
||||
|
||||
it('handles unexpected SSH response', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: 'unexpected output\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Unexpected response');
|
||||
});
|
||||
|
||||
it('handles exception during connection', async () => {
|
||||
mockExecSsh.mockRejectedValue(new Error('Spawn failed'));
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Connection test failed');
|
||||
});
|
||||
|
||||
it('uses correct SSH command for testing', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: 'SSH_OK\nhostname\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
await manager.testConnection(validConfig);
|
||||
|
||||
expect(mockExecSsh).toHaveBeenCalledWith('ssh', expect.any(Array));
|
||||
const args = mockExecSsh.mock.calls[0][1] as string[];
|
||||
|
||||
// Should end with the test command
|
||||
const lastArg = args[args.length - 1];
|
||||
expect(lastArg).toContain('echo "SSH_OK"');
|
||||
expect(lastArg).toContain('hostname');
|
||||
});
|
||||
|
||||
it('includes agent check in test command when specified', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: 'SSH_OK\nhostname\n/usr/bin/claude\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
await manager.testConnection(validConfig, 'claude');
|
||||
|
||||
const args = mockExecSsh.mock.calls[0][1] as string[];
|
||||
const lastArg = args[args.length - 1];
|
||||
expect(lastArg).toContain('which claude');
|
||||
});
|
||||
|
||||
it('handles no route to host error', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: 'ssh: connect to host example.com: No route to host',
|
||||
exitCode: 255,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No route to host');
|
||||
});
|
||||
|
||||
it('returns raw stderr for unknown errors', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: 'Some unusual error message',
|
||||
exitCode: 255,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Some unusual error message');
|
||||
});
|
||||
|
||||
it('returns Connection failed when stderr is empty', async () => {
|
||||
mockExecSsh.mockResolvedValue({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 255,
|
||||
});
|
||||
|
||||
const result = await manager.testConnection(validConfig);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Connection failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('singleton export', () => {
|
||||
it('exports a singleton instance', () => {
|
||||
expect(sshRemoteManager).toBeInstanceOf(SshRemoteManager);
|
||||
});
|
||||
|
||||
it('has all required methods', () => {
|
||||
expect(typeof sshRemoteManager.validateConfig).toBe('function');
|
||||
expect(typeof sshRemoteManager.testConnection).toBe('function');
|
||||
expect(typeof sshRemoteManager.buildSshArgs).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor with default deps', () => {
|
||||
it('creates instance with default dependencies when none provided', () => {
|
||||
// Create without any deps - should use defaults
|
||||
const defaultManager = new SshRemoteManager();
|
||||
expect(defaultManager).toBeInstanceOf(SshRemoteManager);
|
||||
|
||||
// Verify it has working methods
|
||||
expect(typeof defaultManager.validateConfig).toBe('function');
|
||||
expect(typeof defaultManager.buildSshArgs).toBe('function');
|
||||
});
|
||||
|
||||
it('merges partial deps with defaults', () => {
|
||||
// Only provide checkFileAccess, should still have execSsh from defaults
|
||||
const partialManager = new SshRemoteManager({
|
||||
checkFileAccess: () => true,
|
||||
});
|
||||
|
||||
// Should still work for validation
|
||||
const result = partialManager.validateConfig(validConfig);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
86
src/__tests__/main/utils/shell-escape.test.ts
Normal file
86
src/__tests__/main/utils/shell-escape.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
shellEscape,
|
||||
shellEscapeArgs,
|
||||
buildShellCommand,
|
||||
} from '../../../main/utils/shell-escape';
|
||||
|
||||
describe('shell-escape', () => {
|
||||
describe('shellEscape', () => {
|
||||
it('handles empty string', () => {
|
||||
expect(shellEscape('')).toBe("''");
|
||||
});
|
||||
|
||||
it('wraps simple strings in single quotes', () => {
|
||||
expect(shellEscape('hello')).toBe("'hello'");
|
||||
expect(shellEscape('hello world')).toBe("'hello world'");
|
||||
});
|
||||
|
||||
it('escapes single quotes within strings', () => {
|
||||
expect(shellEscape("it's")).toBe("'it'\\''s'");
|
||||
expect(shellEscape("don't")).toBe("'don'\\''t'");
|
||||
expect(shellEscape("'quoted'")).toBe("''\\''quoted'\\'''");
|
||||
});
|
||||
|
||||
it('prevents variable expansion', () => {
|
||||
expect(shellEscape('$HOME')).toBe("'$HOME'");
|
||||
expect(shellEscape('${PATH}')).toBe("'${PATH}'");
|
||||
});
|
||||
|
||||
it('prevents command substitution', () => {
|
||||
expect(shellEscape('$(whoami)')).toBe("'$(whoami)'");
|
||||
expect(shellEscape('`uname`')).toBe("'`uname`'");
|
||||
});
|
||||
|
||||
it('handles special shell characters', () => {
|
||||
expect(shellEscape('foo; rm -rf /')).toBe("'foo; rm -rf /'");
|
||||
expect(shellEscape('foo | bar')).toBe("'foo | bar'");
|
||||
expect(shellEscape('foo && bar')).toBe("'foo && bar'");
|
||||
expect(shellEscape('foo > /dev/null')).toBe("'foo > /dev/null'");
|
||||
expect(shellEscape('$(cat /etc/passwd)')).toBe("'$(cat /etc/passwd)'");
|
||||
});
|
||||
|
||||
it('handles newlines and tabs', () => {
|
||||
expect(shellEscape('line1\nline2')).toBe("'line1\nline2'");
|
||||
expect(shellEscape('col1\tcol2')).toBe("'col1\tcol2'");
|
||||
});
|
||||
|
||||
it('handles unicode characters', () => {
|
||||
expect(shellEscape('hello')).toBe("'hello'");
|
||||
expect(shellEscape('')).toBe("''");
|
||||
});
|
||||
});
|
||||
|
||||
describe('shellEscapeArgs', () => {
|
||||
it('escapes an array of arguments', () => {
|
||||
const result = shellEscapeArgs(['hello', 'world', "it's"]);
|
||||
expect(result).toEqual(["'hello'", "'world'", "'it'\\''s'"]);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(shellEscapeArgs([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildShellCommand', () => {
|
||||
it('builds a command with escaped arguments', () => {
|
||||
const result = buildShellCommand('echo', ['hello', 'world']);
|
||||
expect(result).toBe("echo 'hello' 'world'");
|
||||
});
|
||||
|
||||
it('handles empty arguments', () => {
|
||||
const result = buildShellCommand('ls', []);
|
||||
expect(result).toBe('ls');
|
||||
});
|
||||
|
||||
it('escapes dangerous arguments', () => {
|
||||
const result = buildShellCommand('echo', ['hello; rm -rf /']);
|
||||
expect(result).toBe("echo 'hello; rm -rf /'");
|
||||
});
|
||||
|
||||
it('handles complex command with multiple arguments', () => {
|
||||
const result = buildShellCommand('git', ['commit', '-m', "fix: it's working"]);
|
||||
expect(result).toBe("git 'commit' '-m' 'fix: it'\\''s working'");
|
||||
});
|
||||
});
|
||||
});
|
||||
374
src/__tests__/main/utils/ssh-command-builder.test.ts
Normal file
374
src/__tests__/main/utils/ssh-command-builder.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
buildSshCommand,
|
||||
buildRemoteCommand,
|
||||
} from '../../../main/utils/ssh-command-builder';
|
||||
import type { SshRemoteConfig } from '../../../shared/types';
|
||||
|
||||
describe('ssh-command-builder', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock HOME for consistent path expansion tests
|
||||
process.env = { ...originalEnv, HOME: '/Users/testuser' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
// Base config for testing
|
||||
const baseConfig: SshRemoteConfig = {
|
||||
id: 'test-remote-1',
|
||||
name: 'Test Remote',
|
||||
host: 'dev.example.com',
|
||||
port: 22,
|
||||
username: 'testuser',
|
||||
privateKeyPath: '~/.ssh/id_ed25519',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe('buildRemoteCommand', () => {
|
||||
// Note: The command itself is NOT escaped - it comes from agent config (trusted).
|
||||
// Only arguments, cwd, and env values are escaped as they may contain user input.
|
||||
|
||||
it('builds a simple command without cwd or env', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'claude',
|
||||
args: ['--print', '--verbose'],
|
||||
});
|
||||
// Command is not quoted (trusted), args are quoted
|
||||
expect(result).toBe("claude '--print' '--verbose'");
|
||||
});
|
||||
|
||||
it('builds a command with cwd', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'claude',
|
||||
args: ['--print'],
|
||||
cwd: '/home/user/project',
|
||||
});
|
||||
expect(result).toBe("cd '/home/user/project' && claude '--print'");
|
||||
});
|
||||
|
||||
it('builds a command with environment variables', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'claude',
|
||||
args: ['--print'],
|
||||
env: { ANTHROPIC_API_KEY: 'sk-test-key' },
|
||||
});
|
||||
expect(result).toBe("ANTHROPIC_API_KEY='sk-test-key' claude '--print'");
|
||||
});
|
||||
|
||||
it('builds a command with cwd and env', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'claude',
|
||||
args: ['--print', 'hello'],
|
||||
cwd: '/home/user/project',
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: 'sk-test-key',
|
||||
CUSTOM_VAR: 'value123',
|
||||
},
|
||||
});
|
||||
expect(result).toBe(
|
||||
"cd '/home/user/project' && ANTHROPIC_API_KEY='sk-test-key' CUSTOM_VAR='value123' claude '--print' 'hello'"
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes special characters in cwd', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'claude',
|
||||
args: [],
|
||||
cwd: "/home/user/project's name",
|
||||
});
|
||||
expect(result).toBe("cd '/home/user/project'\\''s name' && claude");
|
||||
});
|
||||
|
||||
it('escapes special characters in env values', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'claude',
|
||||
args: [],
|
||||
env: { API_KEY: "key'with'quotes" },
|
||||
});
|
||||
expect(result).toBe("API_KEY='key'\\''with'\\''quotes' claude");
|
||||
});
|
||||
|
||||
it('escapes special characters in arguments', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'echo',
|
||||
args: ['hello; rm -rf /', '$(whoami)'],
|
||||
});
|
||||
// Arguments are escaped, preventing injection
|
||||
expect(result).toBe("echo 'hello; rm -rf /' '$(whoami)'");
|
||||
});
|
||||
|
||||
it('handles empty arguments array', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'ls',
|
||||
args: [],
|
||||
});
|
||||
expect(result).toBe('ls');
|
||||
});
|
||||
|
||||
it('ignores invalid environment variable names', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'claude',
|
||||
args: [],
|
||||
env: {
|
||||
'VALID_VAR': 'value1',
|
||||
'invalid-var': 'value2',
|
||||
'123invalid': 'value3',
|
||||
'_ALSO_VALID': 'value4',
|
||||
},
|
||||
});
|
||||
// Only VALID_VAR and _ALSO_VALID should be included
|
||||
expect(result).toBe("VALID_VAR='value1' _ALSO_VALID='value4' claude");
|
||||
});
|
||||
|
||||
it('handles empty env object', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'claude',
|
||||
args: [],
|
||||
env: {},
|
||||
});
|
||||
expect(result).toBe('claude');
|
||||
});
|
||||
|
||||
it('handles undefined env', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'claude',
|
||||
args: [],
|
||||
env: undefined,
|
||||
});
|
||||
expect(result).toBe('claude');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSshCommand', () => {
|
||||
it('builds basic SSH command', () => {
|
||||
const result = buildSshCommand(baseConfig, {
|
||||
command: 'claude',
|
||||
args: ['--print'],
|
||||
});
|
||||
|
||||
expect(result.command).toBe('ssh');
|
||||
expect(result.args).toContain('-i');
|
||||
expect(result.args).toContain('/Users/testuser/.ssh/id_ed25519');
|
||||
expect(result.args).toContain('-p');
|
||||
expect(result.args).toContain('22');
|
||||
expect(result.args).toContain('testuser@dev.example.com');
|
||||
});
|
||||
|
||||
it('includes default SSH options', () => {
|
||||
const result = buildSshCommand(baseConfig, {
|
||||
command: 'claude',
|
||||
args: [],
|
||||
});
|
||||
|
||||
expect(result.args).toContain('-o');
|
||||
expect(result.args).toContain('BatchMode=yes');
|
||||
expect(result.args).toContain('StrictHostKeyChecking=accept-new');
|
||||
expect(result.args).toContain('ConnectTimeout=10');
|
||||
});
|
||||
|
||||
it('expands tilde in privateKeyPath', () => {
|
||||
const result = buildSshCommand(baseConfig, {
|
||||
command: 'claude',
|
||||
args: [],
|
||||
});
|
||||
|
||||
expect(result.args).toContain('/Users/testuser/.ssh/id_ed25519');
|
||||
expect(result.args).not.toContain('~/.ssh/id_ed25519');
|
||||
});
|
||||
|
||||
it('uses non-standard port', () => {
|
||||
const config = { ...baseConfig, port: 2222 };
|
||||
const result = buildSshCommand(config, {
|
||||
command: 'claude',
|
||||
args: [],
|
||||
});
|
||||
|
||||
const portIndex = result.args.indexOf('-p');
|
||||
expect(result.args[portIndex + 1]).toBe('2222');
|
||||
});
|
||||
|
||||
it('uses remoteWorkingDir from config when no cwd in options', () => {
|
||||
const config = { ...baseConfig, remoteWorkingDir: '/opt/projects' };
|
||||
const result = buildSshCommand(config, {
|
||||
command: 'claude',
|
||||
args: ['--print'],
|
||||
});
|
||||
|
||||
// The remote command should include cd to the remote working dir
|
||||
const remoteCommand = result.args[result.args.length - 1];
|
||||
expect(remoteCommand).toContain("cd '/opt/projects'");
|
||||
});
|
||||
|
||||
it('prefers option cwd over config remoteWorkingDir', () => {
|
||||
const config = { ...baseConfig, remoteWorkingDir: '/opt/projects' };
|
||||
const result = buildSshCommand(config, {
|
||||
command: 'claude',
|
||||
args: [],
|
||||
cwd: '/home/user/specific-project',
|
||||
});
|
||||
|
||||
const remoteCommand = result.args[result.args.length - 1];
|
||||
expect(remoteCommand).toContain("cd '/home/user/specific-project'");
|
||||
expect(remoteCommand).not.toContain('/opt/projects');
|
||||
});
|
||||
|
||||
it('merges remote config env with option env', () => {
|
||||
const config = {
|
||||
...baseConfig,
|
||||
remoteEnv: { CONFIG_VAR: 'from-config', SHARED_VAR: 'config-value' },
|
||||
};
|
||||
const result = buildSshCommand(config, {
|
||||
command: 'claude',
|
||||
args: [],
|
||||
env: { OPTION_VAR: 'from-option', SHARED_VAR: 'option-value' },
|
||||
});
|
||||
|
||||
const remoteCommand = result.args[result.args.length - 1];
|
||||
// Option env should override config env for SHARED_VAR
|
||||
expect(remoteCommand).toContain("CONFIG_VAR='from-config'");
|
||||
expect(remoteCommand).toContain("OPTION_VAR='from-option'");
|
||||
expect(remoteCommand).toContain("SHARED_VAR='option-value'");
|
||||
// Config value should not appear for SHARED_VAR
|
||||
expect(remoteCommand).not.toContain("SHARED_VAR='config-value'");
|
||||
});
|
||||
|
||||
it('handles config without remoteEnv or remoteWorkingDir', () => {
|
||||
const result = buildSshCommand(baseConfig, {
|
||||
command: 'claude',
|
||||
args: ['--print', 'hello'],
|
||||
});
|
||||
|
||||
const remoteCommand = result.args[result.args.length - 1];
|
||||
expect(remoteCommand).toBe("claude '--print' 'hello'");
|
||||
expect(remoteCommand).not.toContain('cd');
|
||||
});
|
||||
|
||||
it('includes the remote command as the last argument', () => {
|
||||
const result = buildSshCommand(baseConfig, {
|
||||
command: 'claude',
|
||||
args: ['--print', 'hello world'],
|
||||
});
|
||||
|
||||
const lastArg = result.args[result.args.length - 1];
|
||||
expect(lastArg).toContain('claude');
|
||||
expect(lastArg).toContain('--print');
|
||||
expect(lastArg).toContain('hello world');
|
||||
});
|
||||
|
||||
it('properly formats the SSH command for spawning', () => {
|
||||
const result = buildSshCommand(baseConfig, {
|
||||
command: 'claude',
|
||||
args: ['--print'],
|
||||
cwd: '/home/user/project',
|
||||
env: { API_KEY: 'test-key' },
|
||||
});
|
||||
|
||||
expect(result.command).toBe('ssh');
|
||||
// Verify the arguments form a valid SSH command
|
||||
expect(result.args[0]).toBe('-i');
|
||||
expect(result.args[1]).toBe('/Users/testuser/.ssh/id_ed25519');
|
||||
|
||||
// Check that -o options come before -p
|
||||
const oIndices = result.args.reduce<number[]>((acc, arg, i) => {
|
||||
if (arg === '-o') acc.push(i);
|
||||
return acc;
|
||||
}, []);
|
||||
const pIndex = result.args.indexOf('-p');
|
||||
expect(oIndices.every(i => i < pIndex)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles absolute privateKeyPath (no tilde)', () => {
|
||||
const config = { ...baseConfig, privateKeyPath: '/home/user/.ssh/key' };
|
||||
const result = buildSshCommand(config, {
|
||||
command: 'claude',
|
||||
args: [],
|
||||
});
|
||||
|
||||
expect(result.args).toContain('/home/user/.ssh/key');
|
||||
});
|
||||
|
||||
it('handles complex arguments with special characters', () => {
|
||||
const result = buildSshCommand(baseConfig, {
|
||||
command: 'git',
|
||||
args: ['commit', '-m', "fix: it's a bug with $VARIABLES"],
|
||||
});
|
||||
|
||||
const remoteCommand = result.args[result.args.length - 1];
|
||||
// The message should be properly escaped
|
||||
expect(remoteCommand).toContain("'fix: it'\\''s a bug with $VARIABLES'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('security considerations', () => {
|
||||
// Note: The command name itself is NOT escaped because it comes from
|
||||
// agent configuration (system-controlled, not user input). This is
|
||||
// intentional - escaping it would break PATH resolution.
|
||||
|
||||
it('prevents command injection via args', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'echo',
|
||||
args: ['safe', '$(rm -rf /)', '`whoami`'],
|
||||
});
|
||||
// All args are quoted, preventing execution
|
||||
expect(result).toBe("echo 'safe' '$(rm -rf /)' '`whoami`'");
|
||||
});
|
||||
|
||||
it('prevents command injection via cwd', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'ls',
|
||||
args: [],
|
||||
cwd: '/tmp; rm -rf /',
|
||||
});
|
||||
expect(result).toBe("cd '/tmp; rm -rf /' && ls");
|
||||
});
|
||||
|
||||
it('prevents command injection via env values', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
env: { TRAP: "$(rm -rf /)" },
|
||||
});
|
||||
expect(result).toBe("TRAP='$(rm -rf /)' echo");
|
||||
});
|
||||
|
||||
it('rejects env vars with invalid names', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'echo',
|
||||
args: [],
|
||||
env: {
|
||||
'VALID': 'ok',
|
||||
'in valid': 'rejected', // spaces
|
||||
'in;valid': 'rejected', // semicolon
|
||||
'in$valid': 'rejected', // dollar sign
|
||||
},
|
||||
});
|
||||
// Only VALID should appear
|
||||
expect(result).toBe("VALID='ok' echo");
|
||||
expect(result).not.toContain('in valid');
|
||||
expect(result).not.toContain('in;valid');
|
||||
expect(result).not.toContain('in$valid');
|
||||
});
|
||||
|
||||
it('prevents shell variable expansion in args', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'echo',
|
||||
args: ['$HOME', '${PATH}', '$SHELL'],
|
||||
});
|
||||
// Variables are in single quotes, preventing expansion
|
||||
expect(result).toBe("echo '$HOME' '${PATH}' '$SHELL'");
|
||||
});
|
||||
|
||||
it('handles newlines in arguments safely', () => {
|
||||
const result = buildRemoteCommand({
|
||||
command: 'echo',
|
||||
args: ['line1\nline2; rm -rf /'],
|
||||
});
|
||||
// Newline is inside single quotes, safe from injection
|
||||
expect(result).toBe("echo 'line1\nline2; rm -rf /'");
|
||||
});
|
||||
});
|
||||
});
|
||||
287
src/__tests__/main/utils/ssh-remote-resolver.test.ts
Normal file
287
src/__tests__/main/utils/ssh-remote-resolver.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Tests for SSH Remote Configuration Resolver.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
getSshRemoteConfig,
|
||||
createSshRemoteStoreAdapter,
|
||||
SshRemoteSettingsStore,
|
||||
} from '../../../main/utils/ssh-remote-resolver';
|
||||
import type { SshRemoteConfig } from '../../../shared/types';
|
||||
|
||||
describe('getSshRemoteConfig', () => {
|
||||
// Test fixtures
|
||||
const remote1: SshRemoteConfig = {
|
||||
id: 'remote-1',
|
||||
name: 'Dev Server',
|
||||
host: 'dev.example.com',
|
||||
port: 22,
|
||||
username: 'user',
|
||||
privateKeyPath: '~/.ssh/id_ed25519',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const remote2: SshRemoteConfig = {
|
||||
id: 'remote-2',
|
||||
name: 'Production Server',
|
||||
host: 'prod.example.com',
|
||||
port: 22,
|
||||
username: 'admin',
|
||||
privateKeyPath: '~/.ssh/id_rsa',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const disabledRemote: SshRemoteConfig = {
|
||||
id: 'remote-disabled',
|
||||
name: 'Disabled Server',
|
||||
host: 'disabled.example.com',
|
||||
port: 22,
|
||||
username: 'user',
|
||||
privateKeyPath: '~/.ssh/id_ed25519',
|
||||
enabled: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a mock store with specified configuration.
|
||||
*/
|
||||
function createMockStore(
|
||||
sshRemotes: SshRemoteConfig[] = [],
|
||||
defaultSshRemoteId: string | null = null
|
||||
): SshRemoteSettingsStore {
|
||||
return {
|
||||
getSshRemotes: vi.fn(() => sshRemotes),
|
||||
getDefaultSshRemoteId: vi.fn(() => defaultSshRemoteId),
|
||||
};
|
||||
}
|
||||
|
||||
describe('when no SSH remotes are configured', () => {
|
||||
it('returns null config with source "none"', () => {
|
||||
const store = createMockStore([], null);
|
||||
const result = getSshRemoteConfig(store, {});
|
||||
|
||||
expect(result.config).toBeNull();
|
||||
expect(result.source).toBe('none');
|
||||
});
|
||||
|
||||
it('returns null even with agent-specific config when no remotes exist', () => {
|
||||
const store = createMockStore([], null);
|
||||
const result = getSshRemoteConfig(store, {
|
||||
agentSshConfig: { enabled: true, remoteId: 'remote-1' },
|
||||
agentId: 'claude-code',
|
||||
});
|
||||
|
||||
expect(result.config).toBeNull();
|
||||
expect(result.source).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using global default SSH remote', () => {
|
||||
it('returns the global default config with source "global"', () => {
|
||||
const store = createMockStore([remote1, remote2], 'remote-1');
|
||||
const result = getSshRemoteConfig(store, {});
|
||||
|
||||
expect(result.config).toEqual(remote1);
|
||||
expect(result.source).toBe('global');
|
||||
});
|
||||
|
||||
it('returns null when global default points to disabled remote', () => {
|
||||
const store = createMockStore([disabledRemote], 'remote-disabled');
|
||||
const result = getSshRemoteConfig(store, {});
|
||||
|
||||
expect(result.config).toBeNull();
|
||||
expect(result.source).toBe('none');
|
||||
});
|
||||
|
||||
it('returns null when global default points to non-existent remote', () => {
|
||||
const store = createMockStore([remote1], 'non-existent');
|
||||
const result = getSshRemoteConfig(store, {});
|
||||
|
||||
expect(result.config).toBeNull();
|
||||
expect(result.source).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when using agent-specific SSH remote override', () => {
|
||||
it('returns agent-specific config with source "agent" when enabled', () => {
|
||||
const store = createMockStore([remote1, remote2], 'remote-1');
|
||||
const result = getSshRemoteConfig(store, {
|
||||
agentSshConfig: { enabled: true, remoteId: 'remote-2' },
|
||||
agentId: 'claude-code',
|
||||
});
|
||||
|
||||
expect(result.config).toEqual(remote2);
|
||||
expect(result.source).toBe('agent');
|
||||
});
|
||||
|
||||
it('returns null with source "disabled" when agent SSH is explicitly disabled', () => {
|
||||
const store = createMockStore([remote1, remote2], 'remote-1');
|
||||
const result = getSshRemoteConfig(store, {
|
||||
agentSshConfig: { enabled: false, remoteId: null },
|
||||
agentId: 'claude-code',
|
||||
});
|
||||
|
||||
expect(result.config).toBeNull();
|
||||
expect(result.source).toBe('disabled');
|
||||
});
|
||||
|
||||
it('overrides global default even when agent points to same remote', () => {
|
||||
const store = createMockStore([remote1], 'remote-1');
|
||||
const result = getSshRemoteConfig(store, {
|
||||
agentSshConfig: { enabled: true, remoteId: 'remote-1' },
|
||||
agentId: 'claude-code',
|
||||
});
|
||||
|
||||
expect(result.config).toEqual(remote1);
|
||||
expect(result.source).toBe('agent');
|
||||
});
|
||||
|
||||
it('falls back to global default when agent remote ID not found', () => {
|
||||
const store = createMockStore([remote1, remote2], 'remote-1');
|
||||
const result = getSshRemoteConfig(store, {
|
||||
agentSshConfig: { enabled: true, remoteId: 'non-existent' },
|
||||
agentId: 'claude-code',
|
||||
});
|
||||
|
||||
expect(result.config).toEqual(remote1);
|
||||
expect(result.source).toBe('global');
|
||||
});
|
||||
|
||||
it('falls back to global default when agent remote is disabled', () => {
|
||||
const store = createMockStore([remote1, disabledRemote], 'remote-1');
|
||||
const result = getSshRemoteConfig(store, {
|
||||
agentSshConfig: { enabled: true, remoteId: 'remote-disabled' },
|
||||
agentId: 'claude-code',
|
||||
});
|
||||
|
||||
expect(result.config).toEqual(remote1);
|
||||
expect(result.source).toBe('global');
|
||||
});
|
||||
|
||||
it('returns null when agent enabled but no remoteId and no global default', () => {
|
||||
const store = createMockStore([remote1], null);
|
||||
const result = getSshRemoteConfig(store, {
|
||||
agentSshConfig: { enabled: true, remoteId: null },
|
||||
agentId: 'claude-code',
|
||||
});
|
||||
|
||||
expect(result.config).toBeNull();
|
||||
expect(result.source).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('priority ordering', () => {
|
||||
it('agent-specific disabled takes precedence over global default', () => {
|
||||
const store = createMockStore([remote1], 'remote-1');
|
||||
const result = getSshRemoteConfig(store, {
|
||||
agentSshConfig: { enabled: false, remoteId: null },
|
||||
});
|
||||
|
||||
expect(result.config).toBeNull();
|
||||
expect(result.source).toBe('disabled');
|
||||
});
|
||||
|
||||
it('agent-specific remote takes precedence over global default', () => {
|
||||
const store = createMockStore([remote1, remote2], 'remote-1');
|
||||
const result = getSshRemoteConfig(store, {
|
||||
agentSshConfig: { enabled: true, remoteId: 'remote-2' },
|
||||
});
|
||||
|
||||
expect(result.config).toEqual(remote2);
|
||||
expect(result.source).toBe('agent');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSshRemoteStoreAdapter', () => {
|
||||
const remote1: SshRemoteConfig = {
|
||||
id: 'remote-1',
|
||||
name: 'Dev Server',
|
||||
host: 'dev.example.com',
|
||||
port: 22,
|
||||
username: 'user',
|
||||
privateKeyPath: '~/.ssh/id_ed25519',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it('creates adapter that delegates to store.get for sshRemotes', () => {
|
||||
const mockGet = vi.fn().mockImplementation((key: string, defaultValue: unknown) => {
|
||||
if (key === 'sshRemotes') return [remote1];
|
||||
if (key === 'defaultSshRemoteId') return 'remote-1';
|
||||
return defaultValue;
|
||||
});
|
||||
const mockStore = { get: mockGet };
|
||||
|
||||
const adapter = createSshRemoteStoreAdapter(mockStore);
|
||||
const remotes = adapter.getSshRemotes();
|
||||
|
||||
expect(remotes).toEqual([remote1]);
|
||||
expect(mockGet).toHaveBeenCalledWith('sshRemotes', []);
|
||||
});
|
||||
|
||||
it('creates adapter that delegates to store.get for defaultSshRemoteId', () => {
|
||||
const mockGet = vi.fn().mockImplementation((key: string, defaultValue: unknown) => {
|
||||
if (key === 'sshRemotes') return [];
|
||||
if (key === 'defaultSshRemoteId') return 'remote-1';
|
||||
return defaultValue;
|
||||
});
|
||||
const mockStore = { get: mockGet };
|
||||
|
||||
const adapter = createSshRemoteStoreAdapter(mockStore);
|
||||
const defaultId = adapter.getDefaultSshRemoteId();
|
||||
|
||||
expect(defaultId).toBe('remote-1');
|
||||
expect(mockGet).toHaveBeenCalledWith('defaultSshRemoteId', null);
|
||||
});
|
||||
|
||||
it('returns null for defaultSshRemoteId when not set', () => {
|
||||
const mockGet = vi.fn().mockImplementation((_key: string, defaultValue: unknown) => {
|
||||
return defaultValue;
|
||||
});
|
||||
const mockStore = { get: mockGet };
|
||||
|
||||
const adapter = createSshRemoteStoreAdapter(mockStore);
|
||||
const defaultId = adapter.getDefaultSshRemoteId();
|
||||
|
||||
expect(defaultId).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty array for sshRemotes when not set', () => {
|
||||
const mockGet = vi.fn().mockImplementation((_key: string, defaultValue: unknown) => {
|
||||
return defaultValue;
|
||||
});
|
||||
const mockStore = { get: mockGet };
|
||||
|
||||
const adapter = createSshRemoteStoreAdapter(mockStore);
|
||||
const remotes = adapter.getSshRemotes();
|
||||
|
||||
expect(remotes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with getSshRemoteConfig', () => {
|
||||
const remote1: SshRemoteConfig = {
|
||||
id: 'remote-1',
|
||||
name: 'Dev Server',
|
||||
host: 'dev.example.com',
|
||||
port: 22,
|
||||
username: 'user',
|
||||
privateKeyPath: '~/.ssh/id_ed25519',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it('works end-to-end with store adapter', () => {
|
||||
const mockGet = vi.fn().mockImplementation((key: string, defaultValue: unknown) => {
|
||||
if (key === 'sshRemotes') return [remote1];
|
||||
if (key === 'defaultSshRemoteId') return 'remote-1';
|
||||
return defaultValue;
|
||||
});
|
||||
const mockStore = { get: mockGet };
|
||||
|
||||
const adapter = createSshRemoteStoreAdapter(mockStore);
|
||||
const result = getSshRemoteConfig(adapter, {});
|
||||
|
||||
expect(result.config).toEqual(remote1);
|
||||
expect(result.source).toBe('global');
|
||||
});
|
||||
});
|
||||
@@ -10,39 +10,7 @@ import { NewInstanceModal } from '../../../renderer/components/NewInstanceModal'
|
||||
import type { Theme, Session } from '../../../renderer/types';
|
||||
import type { AgentConfig } from '../../../renderer/types';
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
Folder: ({ className }: { className?: string }) => (
|
||||
<span data-testid="folder-icon" className={className}>📁</span>
|
||||
),
|
||||
X: ({ className }: { className?: string }) => (
|
||||
<span data-testid="x-icon" className={className}>×</span>
|
||||
),
|
||||
RefreshCw: ({ className }: { className?: string }) => (
|
||||
<span data-testid="refresh-icon" className={className}>🔄</span>
|
||||
),
|
||||
ChevronRight: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="chevron-right-icon" className={className} style={style}>▶</span>
|
||||
),
|
||||
Check: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="check-icon" className={className} style={style}>✓</span>
|
||||
),
|
||||
AlertCircle: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="alert-circle-icon" className={className} style={style}>⚠</span>
|
||||
),
|
||||
Plus: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="plus-icon" className={className} style={style}>+</span>
|
||||
),
|
||||
Trash2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="trash-icon" className={className} style={style}>🗑</span>
|
||||
),
|
||||
HelpCircle: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="help-circle-icon" className={className} style={style}>?</span>
|
||||
),
|
||||
ChevronDown: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="chevron-down-icon" className={className} style={style}>▼</span>
|
||||
),
|
||||
}));
|
||||
// lucide-react icons are mocked globally in src/__tests__/setup.ts using a Proxy
|
||||
|
||||
// Mock layer stack context
|
||||
const mockRegisterLayer = vi.fn(() => 'layer-new-instance-123');
|
||||
|
||||
430
src/__tests__/renderer/hooks/useSshRemotes.test.ts
Normal file
430
src/__tests__/renderer/hooks/useSshRemotes.test.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useSshRemotes } from '../../../renderer/hooks/remote/useSshRemotes';
|
||||
import type { SshRemoteConfig } from '../../../shared/types';
|
||||
|
||||
const createMockConfig = (overrides: Partial<SshRemoteConfig> = {}): SshRemoteConfig => ({
|
||||
id: 'remote-1',
|
||||
name: 'Test Remote',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'testuser',
|
||||
privateKeyPath: '/home/testuser/.ssh/id_rsa',
|
||||
remoteWorkingDir: '/home/testuser/projects',
|
||||
enabled: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('useSshRemotes', () => {
|
||||
const originalMaestro = { ...window.maestro };
|
||||
|
||||
const mockSshRemote = {
|
||||
getConfigs: vi.fn(),
|
||||
getDefaultId: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
deleteConfig: vi.fn(),
|
||||
setDefaultId: vi.fn(),
|
||||
test: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default mock implementations
|
||||
mockSshRemote.getConfigs.mockResolvedValue({ success: true, configs: [] });
|
||||
mockSshRemote.getDefaultId.mockResolvedValue({ success: true, id: null });
|
||||
mockSshRemote.saveConfig.mockResolvedValue({ success: true, config: createMockConfig() });
|
||||
mockSshRemote.deleteConfig.mockResolvedValue({ success: true });
|
||||
mockSshRemote.setDefaultId.mockResolvedValue({ success: true });
|
||||
mockSshRemote.test.mockResolvedValue({
|
||||
success: true,
|
||||
result: { success: true, remoteInfo: { hostname: 'test-host' } },
|
||||
});
|
||||
|
||||
window.maestro = {
|
||||
...originalMaestro,
|
||||
sshRemote: mockSshRemote as typeof window.maestro.sshRemote,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.maestro = originalMaestro;
|
||||
});
|
||||
|
||||
describe('initial loading', () => {
|
||||
it('loads configs and default ID on mount', async () => {
|
||||
const config = createMockConfig();
|
||||
mockSshRemote.getConfigs.mockResolvedValue({ success: true, configs: [config] });
|
||||
mockSshRemote.getDefaultId.mockResolvedValue({ success: true, id: 'remote-1' });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
// Initially loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.configs).toEqual([config]);
|
||||
expect(result.current.defaultId).toBe('remote-1');
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('handles loading error gracefully', async () => {
|
||||
mockSshRemote.getConfigs.mockResolvedValue({ success: false, error: 'Failed to load' });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Failed to load');
|
||||
expect(result.current.configs).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles exception during loading', async () => {
|
||||
mockSshRemote.getConfigs.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveConfig', () => {
|
||||
it('creates new config and updates local state', async () => {
|
||||
const newConfig = createMockConfig({ id: 'remote-new', name: 'New Remote' });
|
||||
mockSshRemote.saveConfig.mockResolvedValue({ success: true, config: newConfig });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let saveResult: Awaited<ReturnType<typeof result.current.saveConfig>>;
|
||||
await act(async () => {
|
||||
saveResult = await result.current.saveConfig({
|
||||
name: 'New Remote',
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'testuser',
|
||||
privateKeyPath: '/path/to/key',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(saveResult!.success).toBe(true);
|
||||
expect(saveResult!.config).toEqual(newConfig);
|
||||
expect(result.current.configs).toContainEqual(newConfig);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('updates existing config in local state', async () => {
|
||||
const existingConfig = createMockConfig({ id: 'remote-1' });
|
||||
const updatedConfig = createMockConfig({ id: 'remote-1', name: 'Updated Remote' });
|
||||
|
||||
mockSshRemote.getConfigs.mockResolvedValue({ success: true, configs: [existingConfig] });
|
||||
mockSshRemote.saveConfig.mockResolvedValue({ success: true, config: updatedConfig });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig({ id: 'remote-1', name: 'Updated Remote' });
|
||||
});
|
||||
|
||||
expect(result.current.configs).toHaveLength(1);
|
||||
expect(result.current.configs[0].name).toBe('Updated Remote');
|
||||
});
|
||||
|
||||
it('handles save failure', async () => {
|
||||
mockSshRemote.saveConfig.mockResolvedValue({ success: false, error: 'Validation failed' });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let saveResult: Awaited<ReturnType<typeof result.current.saveConfig>>;
|
||||
await act(async () => {
|
||||
saveResult = await result.current.saveConfig({
|
||||
name: 'Test',
|
||||
host: '',
|
||||
port: 22,
|
||||
username: 'testuser',
|
||||
privateKeyPath: '/path',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
expect(saveResult!.success).toBe(false);
|
||||
expect(saveResult!.error).toBe('Validation failed');
|
||||
expect(result.current.error).toBe('Validation failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteConfig', () => {
|
||||
it('deletes config and updates local state', async () => {
|
||||
const config = createMockConfig({ id: 'remote-1' });
|
||||
mockSshRemote.getConfigs.mockResolvedValue({ success: true, configs: [config] });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.configs).toHaveLength(1);
|
||||
|
||||
let deleteResult: Awaited<ReturnType<typeof result.current.deleteConfig>>;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.deleteConfig('remote-1');
|
||||
});
|
||||
|
||||
expect(deleteResult!.success).toBe(true);
|
||||
expect(result.current.configs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('clears defaultId when deleted config was default', async () => {
|
||||
const config = createMockConfig({ id: 'remote-1' });
|
||||
mockSshRemote.getConfigs.mockResolvedValue({ success: true, configs: [config] });
|
||||
mockSshRemote.getDefaultId.mockResolvedValue({ success: true, id: 'remote-1' });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.defaultId).toBe('remote-1');
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteConfig('remote-1');
|
||||
});
|
||||
|
||||
expect(result.current.defaultId).toBeNull();
|
||||
});
|
||||
|
||||
it('handles delete failure', async () => {
|
||||
mockSshRemote.deleteConfig.mockResolvedValue({ success: false, error: 'Config in use' });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let deleteResult: Awaited<ReturnType<typeof result.current.deleteConfig>>;
|
||||
await act(async () => {
|
||||
deleteResult = await result.current.deleteConfig('remote-1');
|
||||
});
|
||||
|
||||
expect(deleteResult!.success).toBe(false);
|
||||
expect(deleteResult!.error).toBe('Config in use');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDefaultId', () => {
|
||||
it('sets default ID and updates local state', async () => {
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let setResult: Awaited<ReturnType<typeof result.current.setDefaultId>>;
|
||||
await act(async () => {
|
||||
setResult = await result.current.setDefaultId('remote-1');
|
||||
});
|
||||
|
||||
expect(setResult!.success).toBe(true);
|
||||
expect(result.current.defaultId).toBe('remote-1');
|
||||
});
|
||||
|
||||
it('clears default ID when set to null', async () => {
|
||||
mockSshRemote.getDefaultId.mockResolvedValue({ success: true, id: 'remote-1' });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.defaultId).toBe('remote-1');
|
||||
|
||||
await act(async () => {
|
||||
await result.current.setDefaultId(null);
|
||||
});
|
||||
|
||||
expect(result.current.defaultId).toBeNull();
|
||||
});
|
||||
|
||||
it('handles setDefaultId failure', async () => {
|
||||
mockSshRemote.setDefaultId.mockResolvedValue({ success: false, error: 'Config not found' });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let setResult: Awaited<ReturnType<typeof result.current.setDefaultId>>;
|
||||
await act(async () => {
|
||||
setResult = await result.current.setDefaultId('nonexistent');
|
||||
});
|
||||
|
||||
expect(setResult!.success).toBe(false);
|
||||
expect(setResult!.error).toBe('Config not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('testConnection', () => {
|
||||
it('tests connection by config ID', async () => {
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.testingConfigId).toBeNull();
|
||||
|
||||
let testResult: Awaited<ReturnType<typeof result.current.testConnection>>;
|
||||
await act(async () => {
|
||||
testResult = await result.current.testConnection('remote-1');
|
||||
});
|
||||
|
||||
expect(testResult!.success).toBe(true);
|
||||
expect(testResult!.result?.remoteInfo?.hostname).toBe('test-host');
|
||||
expect(mockSshRemote.test).toHaveBeenCalledWith('remote-1', undefined);
|
||||
});
|
||||
|
||||
it('tests connection with full config object', async () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let testResult: Awaited<ReturnType<typeof result.current.testConnection>>;
|
||||
await act(async () => {
|
||||
testResult = await result.current.testConnection(config, 'claude');
|
||||
});
|
||||
|
||||
expect(testResult!.success).toBe(true);
|
||||
expect(mockSshRemote.test).toHaveBeenCalledWith(config, 'claude');
|
||||
});
|
||||
|
||||
it('tracks testing state', async () => {
|
||||
let resolveTest: (value: unknown) => void;
|
||||
const testPromise = new Promise((resolve) => {
|
||||
resolveTest = resolve;
|
||||
});
|
||||
mockSshRemote.test.mockReturnValue(testPromise);
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let testPromiseResult: Promise<unknown>;
|
||||
act(() => {
|
||||
testPromiseResult = result.current.testConnection('remote-1');
|
||||
});
|
||||
|
||||
// Should be testing
|
||||
expect(result.current.testingConfigId).toBe('remote-1');
|
||||
|
||||
// Resolve the test
|
||||
await act(async () => {
|
||||
resolveTest!({
|
||||
success: true,
|
||||
result: { success: true, remoteInfo: { hostname: 'test-host' } },
|
||||
});
|
||||
await testPromiseResult;
|
||||
});
|
||||
|
||||
expect(result.current.testingConfigId).toBeNull();
|
||||
});
|
||||
|
||||
it('handles test failure', async () => {
|
||||
mockSshRemote.test.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'Connection refused',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let testResult: Awaited<ReturnType<typeof result.current.testConnection>>;
|
||||
await act(async () => {
|
||||
testResult = await result.current.testConnection('remote-1');
|
||||
});
|
||||
|
||||
expect(testResult!.success).toBe(false);
|
||||
expect(testResult!.error).toBe('Connection refused');
|
||||
});
|
||||
|
||||
it('handles test exception', async () => {
|
||||
mockSshRemote.test.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
let testResult: Awaited<ReturnType<typeof result.current.testConnection>>;
|
||||
await act(async () => {
|
||||
testResult = await result.current.testConnection('remote-1');
|
||||
});
|
||||
|
||||
expect(testResult!.success).toBe(false);
|
||||
expect(testResult!.error).toBe('Network error');
|
||||
expect(result.current.testingConfigId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('reloads all data from backend', async () => {
|
||||
const config1 = createMockConfig({ id: 'remote-1' });
|
||||
mockSshRemote.getConfigs.mockResolvedValue({ success: true, configs: [config1] });
|
||||
|
||||
const { result } = renderHook(() => useSshRemotes());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.configs).toHaveLength(1);
|
||||
|
||||
// Update mock for next call
|
||||
const config2 = createMockConfig({ id: 'remote-2', name: 'Another Remote' });
|
||||
mockSshRemote.getConfigs.mockResolvedValue({ success: true, configs: [config1, config2] });
|
||||
mockSshRemote.getDefaultId.mockResolvedValue({ success: true, id: 'remote-2' });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.configs).toHaveLength(2);
|
||||
expect(result.current.defaultId).toBe('remote-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -372,6 +372,13 @@ const mockMaestro = {
|
||||
deletedAutoRunTasks: 0,
|
||||
}),
|
||||
},
|
||||
sshRemote: {
|
||||
getConfigs: vi.fn().mockResolvedValue({ success: true, configs: [] }),
|
||||
getDefaultId: vi.fn().mockResolvedValue({ success: true, id: null }),
|
||||
setConfigs: vi.fn().mockResolvedValue({ success: true }),
|
||||
setDefaultId: vi.fn().mockResolvedValue({ success: true }),
|
||||
testConnection: vi.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'maestro', {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { tunnelManager } from './tunnel-manager';
|
||||
import { getThemeById } from './themes';
|
||||
import Store from 'electron-store';
|
||||
import { getHistoryManager } from './history-manager';
|
||||
import { registerGitHandlers, registerAutorunHandlers, registerPlaybooksHandlers, registerHistoryHandlers, registerAgentsHandlers, registerProcessHandlers, registerPersistenceHandlers, registerSystemHandlers, registerClaudeHandlers, registerAgentSessionsHandlers, registerGroupChatHandlers, registerDebugHandlers, registerSpeckitHandlers, registerOpenSpecHandlers, registerContextHandlers, registerMarketplaceHandlers, registerStatsHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, getActiveGroomingSessionCount } from './ipc/handlers';
|
||||
import { registerGitHandlers, registerAutorunHandlers, registerPlaybooksHandlers, registerHistoryHandlers, registerAgentsHandlers, registerProcessHandlers, registerPersistenceHandlers, registerSystemHandlers, registerClaudeHandlers, registerAgentSessionsHandlers, registerGroupChatHandlers, registerDebugHandlers, registerSpeckitHandlers, registerOpenSpecHandlers, registerContextHandlers, registerMarketplaceHandlers, registerStatsHandlers, registerDocumentGraphHandlers, registerSshRemoteHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, getActiveGroomingSessionCount } from './ipc/handlers';
|
||||
import { initializeStatsDB, closeStatsDB, getStatsDB } from './stats-db';
|
||||
import { groupChatEmitters } from './ipc/handlers/groupChat';
|
||||
import { routeModeratorResponse, routeAgentResponse, setGetSessionsCallback, setGetCustomEnvVarsCallback, setGetAgentConfigCallback, markParticipantResponded, spawnModeratorSynthesis, getGroupChatReadOnlyState, respawnParticipantWithRecovery } from './group-chat/group-chat-router';
|
||||
@@ -21,6 +21,7 @@ import { needsSessionRecovery, initiateSessionRecovery } from './group-chat/sess
|
||||
import { initializeSessionStorages } from './storage';
|
||||
import { initializeOutputParsers, getOutputParser } from './parsers';
|
||||
import { DEMO_MODE, DEMO_DATA_PATH } from './constants';
|
||||
import type { SshRemoteConfig } from '../shared/types';
|
||||
import { initAutoUpdater } from './auto-updater';
|
||||
|
||||
// ============================================================================
|
||||
@@ -148,6 +149,9 @@ interface MaestroSettings {
|
||||
// Web interface custom port
|
||||
webInterfaceUseCustomPort: boolean;
|
||||
webInterfaceCustomPort: number;
|
||||
// SSH remote execution
|
||||
sshRemotes: SshRemoteConfig[];
|
||||
defaultSshRemoteId: string | null;
|
||||
}
|
||||
|
||||
const store = new Store<MaestroSettings>({
|
||||
@@ -168,6 +172,8 @@ const store = new Store<MaestroSettings>({
|
||||
webAuthToken: null,
|
||||
webInterfaceUseCustomPort: false,
|
||||
webInterfaceCustomPort: 8080,
|
||||
sshRemotes: [],
|
||||
defaultSshRemoteId: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1011,6 +1017,7 @@ function setupIpcHandlers() {
|
||||
getAgentDetector: () => agentDetector,
|
||||
agentConfigsStore,
|
||||
settingsStore: store,
|
||||
getMainWindow: () => mainWindow,
|
||||
});
|
||||
|
||||
// Persistence operations - extracted to src/main/ipc/handlers/persistence.ts
|
||||
@@ -1103,6 +1110,17 @@ function setupIpcHandlers() {
|
||||
settingsStore: store,
|
||||
});
|
||||
|
||||
// Register Document Graph handlers for file watching
|
||||
registerDocumentGraphHandlers({
|
||||
getMainWindow: () => mainWindow,
|
||||
app,
|
||||
});
|
||||
|
||||
// Register SSH Remote handlers for managing SSH configurations
|
||||
registerSshRemoteHandlers({
|
||||
settingsStore: store,
|
||||
});
|
||||
|
||||
// Set up callback for group chat router to lookup sessions for auto-add @mentions
|
||||
setGetSessionsCallback(() => {
|
||||
const sessions = sessionsStore.get('sessions', []);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { registerContextHandlers, ContextHandlerDependencies, cleanupAllGrooming
|
||||
import { registerMarketplaceHandlers, MarketplaceHandlerDependencies } from './marketplace';
|
||||
import { registerStatsHandlers, StatsHandlerDependencies } from './stats';
|
||||
import { registerDocumentGraphHandlers, DocumentGraphHandlerDependencies } from './documentGraph';
|
||||
import { registerSshRemoteHandlers, SshRemoteHandlerDependencies } from './ssh-remote';
|
||||
import { AgentDetector } from '../../agent-detector';
|
||||
import { ProcessManager } from '../../process-manager';
|
||||
import { WebServer } from '../../web-server';
|
||||
@@ -55,6 +56,7 @@ export { registerMarketplaceHandlers };
|
||||
export type { MarketplaceHandlerDependencies };
|
||||
export { registerStatsHandlers };
|
||||
export { registerDocumentGraphHandlers };
|
||||
export { registerSshRemoteHandlers };
|
||||
export type { AgentsHandlerDependencies };
|
||||
export type { ProcessHandlerDependencies };
|
||||
export type { PersistenceHandlerDependencies };
|
||||
@@ -66,6 +68,7 @@ export type { DebugHandlerDependencies };
|
||||
export type { ContextHandlerDependencies };
|
||||
export type { StatsHandlerDependencies };
|
||||
export type { DocumentGraphHandlerDependencies };
|
||||
export type { SshRemoteHandlerDependencies };
|
||||
export type { MaestroSettings, SessionsData, GroupsData };
|
||||
|
||||
/**
|
||||
@@ -129,6 +132,7 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
|
||||
getAgentDetector: deps.getAgentDetector,
|
||||
agentConfigsStore: deps.agentConfigsStore,
|
||||
settingsStore: deps.settingsStore,
|
||||
getMainWindow: deps.getMainWindow,
|
||||
});
|
||||
registerPersistenceHandlers({
|
||||
settingsStore: deps.settingsStore,
|
||||
@@ -186,6 +190,10 @@ export function registerAllHandlers(deps: HandlerDependencies): void {
|
||||
getMainWindow: deps.getMainWindow,
|
||||
app: deps.app,
|
||||
});
|
||||
// Register SSH remote handlers
|
||||
registerSshRemoteHandlers({
|
||||
settingsStore: deps.settingsStore,
|
||||
});
|
||||
// Setup logger event forwarding to renderer
|
||||
setupLoggerEventForwarding(deps.getMainWindow);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface MaestroSettings {
|
||||
defaultShell: string;
|
||||
webAuthEnabled: boolean;
|
||||
webAuthToken: string | null;
|
||||
// SSH Remote configuration
|
||||
sshRemotes: any[];
|
||||
defaultSshRemoteId: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import { ProcessManager } from '../../process-manager';
|
||||
import { AgentDetector } from '../../agent-detector';
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
requireDependency,
|
||||
CreateHandlerOptions,
|
||||
} from '../../utils/ipcHandler';
|
||||
import { getSshRemoteConfig, createSshRemoteStoreAdapter } from '../../utils/ssh-remote-resolver';
|
||||
import { buildSshCommand } from '../../utils/ssh-command-builder';
|
||||
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../../shared/types';
|
||||
|
||||
const LOG_CONTEXT = '[ProcessManager]';
|
||||
|
||||
@@ -40,6 +43,9 @@ interface MaestroSettings {
|
||||
customShellPath?: string; // Custom path to shell binary (overrides auto-detected path)
|
||||
shellArgs?: string; // Additional CLI arguments for shell sessions
|
||||
shellEnvVars?: Record<string, string>; // Environment variables for shell sessions
|
||||
// SSH remote execution
|
||||
sshRemotes: SshRemoteConfig[];
|
||||
defaultSshRemoteId: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -51,6 +57,7 @@ export interface ProcessHandlerDependencies {
|
||||
getAgentDetector: () => AgentDetector | null;
|
||||
agentConfigsStore: Store<AgentConfigsData>;
|
||||
settingsStore: Store<MaestroSettings>;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +73,7 @@ export interface ProcessHandlerDependencies {
|
||||
* - runCommand: Execute a single command and capture output
|
||||
*/
|
||||
export function registerProcessHandlers(deps: ProcessHandlerDependencies): void {
|
||||
const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore } = deps;
|
||||
const { getProcessManager, getAgentDetector, agentConfigsStore, settingsStore, getMainWindow } = deps;
|
||||
|
||||
// Spawn a new process for a session
|
||||
// Supports agent-specific argument builders for batch mode, JSON output, resume, read-only mode, YOLO mode
|
||||
@@ -207,16 +214,72 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
// Falls back to the agent's configOptions default (e.g., 400000 for Codex, 128000 for OpenCode)
|
||||
const contextWindow = getContextWindowValue(agent, agentConfigValues, config.sessionCustomContextWindow);
|
||||
|
||||
// ========================================================================
|
||||
// SSH Remote Execution: Detect and wrap command for remote execution
|
||||
// Terminal sessions are always local (they need PTY for shell interaction)
|
||||
// ========================================================================
|
||||
let commandToSpawn = config.command;
|
||||
let argsToSpawn = finalArgs;
|
||||
let sshRemoteUsed: SshRemoteConfig | null = null;
|
||||
|
||||
// Only consider SSH remote for non-terminal AI agent sessions
|
||||
if (config.toolType !== 'terminal') {
|
||||
// Get agent-specific SSH config from agent configs store
|
||||
const agentSshConfig = agentConfigValues.sshRemote as AgentSshRemoteConfig | undefined;
|
||||
|
||||
// Resolve effective SSH remote configuration
|
||||
const sshStoreAdapter = createSshRemoteStoreAdapter(settingsStore);
|
||||
const sshResult = getSshRemoteConfig(sshStoreAdapter, {
|
||||
agentSshConfig,
|
||||
agentId: config.toolType,
|
||||
});
|
||||
|
||||
if (sshResult.config) {
|
||||
// SSH remote is configured - wrap the command for remote execution
|
||||
sshRemoteUsed = sshResult.config;
|
||||
|
||||
// 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
|
||||
const sshCommand = buildSshCommand(sshResult.config, {
|
||||
command: config.command,
|
||||
args: finalArgs,
|
||||
// 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,
|
||||
// Pass custom environment variables to the remote command
|
||||
env: effectiveCustomEnvVars,
|
||||
});
|
||||
|
||||
commandToSpawn = sshCommand.command;
|
||||
argsToSpawn = sshCommand.args;
|
||||
|
||||
logger.info(`SSH remote execution configured`, LOG_CONTEXT, {
|
||||
sessionId: config.sessionId,
|
||||
toolType: config.toolType,
|
||||
remoteName: sshResult.config.name,
|
||||
remoteHost: sshResult.config.host,
|
||||
source: sshResult.source,
|
||||
originalCommand: config.command,
|
||||
sshCommand: `${sshCommand.command} ${sshCommand.args.join(' ')}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = processManager.spawn({
|
||||
...config,
|
||||
args: finalArgs,
|
||||
requiresPty: agent?.requiresPty,
|
||||
command: commandToSpawn,
|
||||
args: argsToSpawn,
|
||||
// When using SSH, disable PTY (SSH provides its own terminal handling)
|
||||
// and env vars are passed via the remote command string
|
||||
requiresPty: sshRemoteUsed ? false : agent?.requiresPty,
|
||||
prompt: config.prompt,
|
||||
shell: shellToUse,
|
||||
shellArgs: shellArgsStr, // Shell-specific CLI args (for terminal sessions)
|
||||
shellEnvVars: shellEnvVars, // Shell-specific env vars (for terminal sessions)
|
||||
contextWindow, // Pass configured context window to process manager
|
||||
customEnvVars: effectiveCustomEnvVars, // Pass custom env vars (session-level or agent-level)
|
||||
// When using SSH, env vars are passed in the remote command string, not locally
|
||||
customEnvVars: sshRemoteUsed ? undefined : effectiveCustomEnvVars,
|
||||
imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode)
|
||||
noPromptSeparator: agent?.noPromptSeparator, // OpenCode doesn't support '--' before prompt
|
||||
// Stats tracking: use cwd as projectPath if not explicitly provided
|
||||
@@ -225,9 +288,31 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
|
||||
logger.info(`Process spawned successfully`, LOG_CONTEXT, {
|
||||
sessionId: config.sessionId,
|
||||
pid: result.pid
|
||||
pid: result.pid,
|
||||
...(sshRemoteUsed && { sshRemoteId: sshRemoteUsed.id, sshRemoteName: sshRemoteUsed.name })
|
||||
});
|
||||
return result;
|
||||
|
||||
// Emit SSH remote status event for renderer to update session state
|
||||
// This is emitted for all spawns (sshRemote will be null for local execution)
|
||||
const mainWindow = getMainWindow();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
const sshRemoteInfo = sshRemoteUsed ? {
|
||||
id: sshRemoteUsed.id,
|
||||
name: sshRemoteUsed.name,
|
||||
host: sshRemoteUsed.host,
|
||||
} : null;
|
||||
mainWindow.webContents.send('process:ssh-remote', config.sessionId, sshRemoteInfo);
|
||||
}
|
||||
|
||||
// Return spawn result with SSH remote info if used
|
||||
return {
|
||||
...result,
|
||||
sshRemote: sshRemoteUsed ? {
|
||||
id: sshRemoteUsed.id,
|
||||
name: sshRemoteUsed.name,
|
||||
host: sshRemoteUsed.host,
|
||||
} : undefined,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
263
src/main/ipc/handlers/ssh-remote.ts
Normal file
263
src/main/ipc/handlers/ssh-remote.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* SSH Remote IPC Handlers
|
||||
*
|
||||
* Provides IPC handlers for managing SSH remote configurations:
|
||||
* - Save (create/update) SSH remote configurations
|
||||
* - Delete SSH remote configurations
|
||||
* - Get all SSH remote configurations
|
||||
* - Get/set the global default SSH remote ID
|
||||
* - Test SSH remote connections
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import Store from 'electron-store';
|
||||
import * as crypto from 'crypto';
|
||||
import { SshRemoteConfig, SshRemoteTestResult } from '../../../shared/types';
|
||||
import { sshRemoteManager } from '../../ssh-remote-manager';
|
||||
import { createIpcHandler, CreateHandlerOptions } from '../../utils/ipcHandler';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { MaestroSettings } from './persistence';
|
||||
|
||||
const LOG_CONTEXT = '[SshRemote]';
|
||||
|
||||
/**
|
||||
* Helper to create handler options with consistent context
|
||||
*/
|
||||
const handlerOpts = (operation: string, logSuccess = true): CreateHandlerOptions => ({
|
||||
context: LOG_CONTEXT,
|
||||
operation,
|
||||
logSuccess,
|
||||
});
|
||||
|
||||
/**
|
||||
* Dependencies required for SSH remote handler registration
|
||||
*/
|
||||
export interface SshRemoteHandlerDependencies {
|
||||
/** The settings store (MaestroSettings) */
|
||||
settingsStore: Store<MaestroSettings>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SSH remotes from store
|
||||
*/
|
||||
function getSshRemotes(store: Store<MaestroSettings>): SshRemoteConfig[] {
|
||||
return store.get('sshRemotes', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save SSH remotes to store
|
||||
*/
|
||||
function setSshRemotes(store: Store<MaestroSettings>, remotes: SshRemoteConfig[]): void {
|
||||
store.set('sshRemotes', remotes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default SSH remote ID from store
|
||||
*/
|
||||
function getDefaultSshRemoteId(store: Store<MaestroSettings>): string | null {
|
||||
return store.get('defaultSshRemoteId', null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default SSH remote ID in store
|
||||
*/
|
||||
function setDefaultSshRemoteId(store: Store<MaestroSettings>, id: string | null): void {
|
||||
store.set('defaultSshRemoteId', id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all SSH remote IPC handlers.
|
||||
*
|
||||
* Handlers:
|
||||
* - ssh-remote:saveConfig - Create or update an SSH remote configuration
|
||||
* - ssh-remote:deleteConfig - Delete an SSH remote configuration by ID
|
||||
* - ssh-remote:getConfigs - Get all SSH remote configurations
|
||||
* - ssh-remote:getDefaultId - Get the global default SSH remote ID
|
||||
* - ssh-remote:setDefaultId - Set the global default SSH remote ID
|
||||
* - ssh-remote:test - Test an SSH remote connection
|
||||
*/
|
||||
export function registerSshRemoteHandlers(deps: SshRemoteHandlerDependencies): void {
|
||||
const { settingsStore } = deps;
|
||||
|
||||
/**
|
||||
* Save (create or update) an SSH remote configuration.
|
||||
*
|
||||
* If config.id is provided and exists, updates the existing config.
|
||||
* If config.id is not provided or doesn't exist, creates a new config with generated ID.
|
||||
*
|
||||
* Validates the configuration before saving.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'ssh-remote:saveConfig',
|
||||
createIpcHandler(
|
||||
handlerOpts('saveConfig'),
|
||||
async (config: Partial<SshRemoteConfig>): Promise<{ config: SshRemoteConfig }> => {
|
||||
const remotes = getSshRemotes(settingsStore);
|
||||
|
||||
// Determine if this is an update or create
|
||||
const existingIndex = config.id ? remotes.findIndex((r) => r.id === config.id) : -1;
|
||||
const isUpdate = existingIndex !== -1;
|
||||
|
||||
// Build the complete config
|
||||
const completeConfig: SshRemoteConfig = {
|
||||
id: config.id || crypto.randomUUID(),
|
||||
name: config.name || 'Unnamed Remote',
|
||||
host: config.host || '',
|
||||
port: config.port ?? 22,
|
||||
username: config.username || '',
|
||||
privateKeyPath: config.privateKeyPath || '',
|
||||
remoteWorkingDir: config.remoteWorkingDir,
|
||||
remoteEnv: config.remoteEnv,
|
||||
enabled: config.enabled ?? true,
|
||||
};
|
||||
|
||||
// Validate the configuration
|
||||
const validation = sshRemoteManager.validateConfig(completeConfig);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid configuration: ${validation.errors.join('; ')}`);
|
||||
}
|
||||
|
||||
if (isUpdate) {
|
||||
// Update existing config
|
||||
remotes[existingIndex] = completeConfig;
|
||||
logger.info(`Updated SSH remote "${completeConfig.name}" (${completeConfig.id})`, LOG_CONTEXT);
|
||||
} else {
|
||||
// Add new config
|
||||
remotes.push(completeConfig);
|
||||
logger.info(`Created SSH remote "${completeConfig.name}" (${completeConfig.id})`, LOG_CONTEXT);
|
||||
}
|
||||
|
||||
setSshRemotes(settingsStore, remotes);
|
||||
|
||||
return { config: completeConfig };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete an SSH remote configuration by ID.
|
||||
*
|
||||
* Also clears the default ID if it matches the deleted config.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'ssh-remote:deleteConfig',
|
||||
createIpcHandler(handlerOpts('deleteConfig'), async (id: string): Promise<Record<string, never>> => {
|
||||
const remotes = getSshRemotes(settingsStore);
|
||||
const index = remotes.findIndex((r) => r.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`SSH remote not found: ${id}`);
|
||||
}
|
||||
|
||||
const deletedName = remotes[index].name;
|
||||
remotes.splice(index, 1);
|
||||
setSshRemotes(settingsStore, remotes);
|
||||
|
||||
// Clear default if it was the deleted config
|
||||
const defaultId = getDefaultSshRemoteId(settingsStore);
|
||||
if (defaultId === id) {
|
||||
setDefaultSshRemoteId(settingsStore, null);
|
||||
logger.info(`Cleared default SSH remote (was ${id})`, LOG_CONTEXT);
|
||||
}
|
||||
|
||||
logger.info(`Deleted SSH remote "${deletedName}" (${id})`, LOG_CONTEXT);
|
||||
|
||||
return {};
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all SSH remote configurations.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'ssh-remote:getConfigs',
|
||||
createIpcHandler(handlerOpts('getConfigs', false), async (): Promise<{ configs: SshRemoteConfig[] }> => {
|
||||
const remotes = getSshRemotes(settingsStore);
|
||||
return { configs: remotes };
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the global default SSH remote ID.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'ssh-remote:getDefaultId',
|
||||
createIpcHandler(handlerOpts('getDefaultId', false), async (): Promise<{ id: string | null }> => {
|
||||
const defaultId = getDefaultSshRemoteId(settingsStore);
|
||||
return { id: defaultId };
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Set the global default SSH remote ID.
|
||||
*
|
||||
* Pass null to clear the default.
|
||||
* Validates that the ID exists in the stored configs.
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'ssh-remote:setDefaultId',
|
||||
createIpcHandler(handlerOpts('setDefaultId'), async (id: string | null): Promise<Record<string, never>> => {
|
||||
if (id !== null) {
|
||||
// Validate that the ID exists
|
||||
const remotes = getSshRemotes(settingsStore);
|
||||
const exists = remotes.some((r) => r.id === id);
|
||||
if (!exists) {
|
||||
throw new Error(`SSH remote not found: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
setDefaultSshRemoteId(settingsStore, id);
|
||||
logger.info(`Set default SSH remote to ${id ?? 'none'}`, LOG_CONTEXT);
|
||||
|
||||
return {};
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Test an SSH remote connection.
|
||||
*
|
||||
* Accepts either a config ID (to test stored config) or a full config object
|
||||
* (to test before saving).
|
||||
*/
|
||||
ipcMain.handle(
|
||||
'ssh-remote:test',
|
||||
createIpcHandler(
|
||||
handlerOpts('test'),
|
||||
async (
|
||||
configOrId: string | SshRemoteConfig,
|
||||
agentCommand?: string
|
||||
): Promise<{ result: SshRemoteTestResult }> => {
|
||||
let config: SshRemoteConfig;
|
||||
|
||||
if (typeof configOrId === 'string') {
|
||||
// Lookup by ID
|
||||
const remotes = getSshRemotes(settingsStore);
|
||||
const found = remotes.find((r) => r.id === configOrId);
|
||||
if (!found) {
|
||||
throw new Error(`SSH remote not found: ${configOrId}`);
|
||||
}
|
||||
config = found;
|
||||
} else {
|
||||
// Use provided config directly
|
||||
config = configOrId;
|
||||
}
|
||||
|
||||
logger.info(`Testing SSH connection to ${config.host}...`, LOG_CONTEXT);
|
||||
const result = await sshRemoteManager.testConnection(config, agentCommand);
|
||||
|
||||
if (result.success) {
|
||||
logger.info(
|
||||
`SSH connection test successful: ${result.remoteInfo?.hostname || 'unknown host'}`,
|
||||
LOG_CONTEXT
|
||||
);
|
||||
} else {
|
||||
logger.warn(`SSH connection test failed: ${result.error}`, LOG_CONTEXT);
|
||||
}
|
||||
|
||||
return { result };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
logger.debug(`${LOG_CONTEXT} SSH remote IPC handlers registered`);
|
||||
}
|
||||
@@ -477,6 +477,147 @@ export const CODEX_ERROR_PATTERNS: AgentErrorPatterns = {
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SSH Error Patterns
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Error patterns for SSH remote execution errors.
|
||||
* These are checked separately from agent-specific patterns because they can
|
||||
* occur when ANY agent runs via SSH remote execution.
|
||||
*/
|
||||
export const SSH_ERROR_PATTERNS: AgentErrorPatterns = {
|
||||
permission_denied: [
|
||||
{
|
||||
// SSH authentication failure - wrong key, key not authorized, etc.
|
||||
pattern: /ssh:.*permission denied/i,
|
||||
message: 'SSH authentication failed. Check your SSH key configuration.',
|
||||
recoverable: false,
|
||||
},
|
||||
{
|
||||
// SSH key authentication rejected
|
||||
pattern: /permission denied \(publickey/i,
|
||||
message: 'SSH key authentication failed. Ensure your key is authorized on the remote host.',
|
||||
recoverable: false,
|
||||
},
|
||||
{
|
||||
// Host key verification failed
|
||||
pattern: /host key verification failed/i,
|
||||
message: 'SSH host key verification failed. The remote host may have changed or this is a new connection.',
|
||||
recoverable: false,
|
||||
},
|
||||
{
|
||||
// No matching host key type (SSH algorithm mismatch)
|
||||
pattern: /no matching host key type found/i,
|
||||
message: 'SSH connection failed. No compatible host key algorithms between client and server.',
|
||||
recoverable: false,
|
||||
},
|
||||
{
|
||||
// Key passphrase required but not provided
|
||||
pattern: /enter passphrase for key/i,
|
||||
message: 'SSH key requires a passphrase. Use a key without a passphrase or add it to ssh-agent.',
|
||||
recoverable: false,
|
||||
},
|
||||
],
|
||||
|
||||
network_error: [
|
||||
{
|
||||
// SSH connection refused - sshd not running or firewall blocking
|
||||
pattern: /ssh:.*connection refused/i,
|
||||
message: 'SSH connection refused. Ensure the SSH server is running on the remote host.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
// SSH connection timed out
|
||||
pattern: /ssh:.*connection timed out/i,
|
||||
message: 'SSH connection timed out. Check network connectivity and firewall rules.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
// SSH operation timed out
|
||||
pattern: /ssh:.*operation timed out/i,
|
||||
message: 'SSH operation timed out. The remote host may be unreachable.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
// SSH hostname resolution failure
|
||||
pattern: /ssh:.*could not resolve hostname/i,
|
||||
message: 'SSH could not resolve hostname. Check the remote host address.',
|
||||
recoverable: false,
|
||||
},
|
||||
{
|
||||
// SSH no route to host
|
||||
pattern: /ssh:.*no route to host/i,
|
||||
message: 'SSH connection failed. No network route to the remote host.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
// SSH connection reset
|
||||
pattern: /ssh:.*connection reset/i,
|
||||
message: 'SSH connection was reset. The remote host may have terminated the connection.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
// SSH network unreachable
|
||||
pattern: /ssh:.*network is unreachable/i,
|
||||
message: 'SSH connection failed. The network is unreachable.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
// Generic SSH connection closed
|
||||
pattern: /ssh:.*connection closed/i,
|
||||
message: 'SSH connection was closed unexpectedly.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
// SSH port not open (connection refused without ssh: prefix)
|
||||
pattern: /connect to host.*port.*connection refused/i,
|
||||
message: 'SSH connection refused. The SSH port may be blocked or the server is not running.',
|
||||
recoverable: true,
|
||||
},
|
||||
],
|
||||
|
||||
agent_crashed: [
|
||||
{
|
||||
// Agent command not found on remote host
|
||||
pattern: /bash:.*command not found|zsh:.*command not found|sh:.*command not found/i,
|
||||
message: 'Agent command not found on remote host. Ensure the agent is installed on the remote machine.',
|
||||
recoverable: false,
|
||||
},
|
||||
{
|
||||
// Generic command not found (without shell prefix)
|
||||
pattern: /command not found:/i,
|
||||
message: 'Command not found on remote host. The agent may not be installed.',
|
||||
recoverable: false,
|
||||
},
|
||||
{
|
||||
// No such file or directory (agent binary missing)
|
||||
// Matches patterns like: "/usr/local/bin/claude: No such file or directory"
|
||||
pattern: /claude.*no such file or directory|opencode.*no such file or directory|codex.*no such file or directory/i,
|
||||
message: 'Agent binary not found on remote host. Install the agent on the remote machine.',
|
||||
recoverable: false,
|
||||
},
|
||||
{
|
||||
// SSH broken pipe - connection dropped during command execution
|
||||
pattern: /ssh:.*broken pipe/i,
|
||||
message: 'SSH connection dropped during command execution. The connection may have been interrupted.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
// SSH client died - may or may not have "ssh:" prefix
|
||||
pattern: /client_loop:\s*send disconnect/i,
|
||||
message: 'SSH connection was disconnected. The session may have timed out or been interrupted.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
// SSH packet corruption or protocol error
|
||||
pattern: /ssh:.*packet corrupt|ssh:.*protocol error/i,
|
||||
message: 'SSH protocol error. The connection may be unstable.',
|
||||
recoverable: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Pattern Registry
|
||||
// ============================================================================
|
||||
@@ -553,3 +694,25 @@ export function registerErrorPatterns(
|
||||
export function clearPatternRegistry(): void {
|
||||
patternRegistry.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a line against SSH-specific error patterns.
|
||||
* This should be called in addition to agent-specific pattern matching
|
||||
* when a session is running via SSH remote execution.
|
||||
*
|
||||
* @param line - The line to check for SSH errors
|
||||
* @returns Matched error info or null if no match
|
||||
*/
|
||||
export function matchSshErrorPattern(
|
||||
line: string
|
||||
): { type: AgentErrorType; message: string; recoverable: boolean } | null {
|
||||
return matchErrorPattern(SSH_ERROR_PATTERNS, line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SSH error patterns object.
|
||||
* Useful for testing or combining with agent patterns.
|
||||
*/
|
||||
export function getSshErrorPatterns(): AgentErrorPatterns {
|
||||
return SSH_ERROR_PATTERNS;
|
||||
}
|
||||
|
||||
@@ -147,6 +147,13 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
ipcRenderer.on('process:tool-execution', handler);
|
||||
return () => ipcRenderer.removeListener('process:tool-execution', handler);
|
||||
},
|
||||
// SSH remote execution status
|
||||
// Emitted when a process starts executing via SSH on a remote host
|
||||
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);
|
||||
},
|
||||
// Remote command execution from web interface
|
||||
// This allows web commands to go through the same code path as desktop commands
|
||||
// inputMode is optional - if provided, renderer should use it instead of session state
|
||||
@@ -580,6 +587,36 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
getStatus: () => ipcRenderer.invoke('tunnel:getStatus'),
|
||||
},
|
||||
|
||||
// SSH Remote API (execute agents on remote hosts via SSH)
|
||||
sshRemote: {
|
||||
saveConfig: (config: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
privateKeyPath?: string;
|
||||
remoteWorkingDir?: string;
|
||||
remoteEnv?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}) => ipcRenderer.invoke('ssh-remote:saveConfig', config),
|
||||
deleteConfig: (id: string) => ipcRenderer.invoke('ssh-remote:deleteConfig', id),
|
||||
getConfigs: () => ipcRenderer.invoke('ssh-remote:getConfigs'),
|
||||
getDefaultId: () => ipcRenderer.invoke('ssh-remote:getDefaultId'),
|
||||
setDefaultId: (id: string | null) => ipcRenderer.invoke('ssh-remote:setDefaultId', id),
|
||||
test: (configOrId: string | {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
privateKeyPath: string;
|
||||
remoteWorkingDir?: string;
|
||||
remoteEnv?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}, agentCommand?: string) => ipcRenderer.invoke('ssh-remote:test', configOrId, agentCommand),
|
||||
},
|
||||
|
||||
// Sync API (custom storage location for cross-device sync)
|
||||
sync: {
|
||||
getDefaultPath: () => ipcRenderer.invoke('sync:getDefaultPath') as Promise<string>,
|
||||
@@ -1595,7 +1632,11 @@ export interface MaestroAPI {
|
||||
setAll: (groups: any[]) => Promise<boolean>;
|
||||
};
|
||||
process: {
|
||||
spawn: (config: ProcessConfig) => Promise<{ pid: number; success: boolean }>;
|
||||
spawn: (config: ProcessConfig) => Promise<{
|
||||
pid: number;
|
||||
success: boolean;
|
||||
sshRemote?: { id: string; name: string; host: string };
|
||||
}>;
|
||||
write: (sessionId: string, data: string) => Promise<boolean>;
|
||||
interrupt: (sessionId: string) => Promise<boolean>;
|
||||
kill: (sessionId: string) => Promise<boolean>;
|
||||
@@ -1618,6 +1659,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 } | 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;
|
||||
@@ -1817,6 +1859,76 @@ export interface MaestroAPI {
|
||||
stop: () => Promise<{ success: boolean }>;
|
||||
getStatus: () => Promise<{ isRunning: boolean; url: string | null; error: string | null }>;
|
||||
};
|
||||
sshRemote: {
|
||||
saveConfig: (config: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
privateKeyPath?: string;
|
||||
remoteWorkingDir?: string;
|
||||
remoteEnv?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
config?: {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
privateKeyPath: string;
|
||||
remoteWorkingDir?: string;
|
||||
remoteEnv?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
deleteConfig: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getConfigs: () => Promise<{
|
||||
success: boolean;
|
||||
configs?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
privateKeyPath: string;
|
||||
remoteWorkingDir?: string;
|
||||
remoteEnv?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
error?: string;
|
||||
}>;
|
||||
getDefaultId: () => Promise<{ success: boolean; id?: string | null; error?: string }>;
|
||||
setDefaultId: (id: string | null) => Promise<{ success: boolean; error?: string }>;
|
||||
test: (
|
||||
configOrId: string | {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
privateKeyPath: string;
|
||||
remoteWorkingDir?: string;
|
||||
remoteEnv?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
},
|
||||
agentCommand?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
remoteInfo?: {
|
||||
hostname: string;
|
||||
agentVersion?: string;
|
||||
};
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
sync: {
|
||||
getDefaultPath: () => Promise<string>;
|
||||
getSettings: () => Promise<{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { stripControlSequences, stripAllAnsiCodes } from './utils/terminalFilter
|
||||
import { logger } from './utils/logger';
|
||||
import { getOutputParser, type ParsedEvent, type AgentOutputParser } from './parsers';
|
||||
import { aggregateModelUsage } from './parsers/usage-aggregator';
|
||||
import { matchSshErrorPattern } from './parsers/error-patterns';
|
||||
import type { AgentError } from '../shared/types';
|
||||
import { getAgentCapabilities } from './agent-capabilities';
|
||||
|
||||
@@ -777,6 +778,32 @@ export class ProcessManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for SSH-specific errors (when running via SSH remote)
|
||||
// These are checked separately because they can occur with any agent
|
||||
if (!managedProcess.errorEmitted) {
|
||||
const sshError = matchSshErrorPattern(line);
|
||||
if (sshError) {
|
||||
managedProcess.errorEmitted = true;
|
||||
const agentError: AgentError = {
|
||||
type: sshError.type,
|
||||
message: sshError.message,
|
||||
recoverable: sshError.recoverable,
|
||||
agentId: toolType,
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
raw: {
|
||||
errorLine: line,
|
||||
},
|
||||
};
|
||||
logger.debug('[ProcessManager] SSH error detected from output', 'ProcessManager', {
|
||||
sessionId,
|
||||
errorType: sshError.type,
|
||||
errorMessage: sshError.message,
|
||||
});
|
||||
this.emit('agent-error', sessionId, agentError);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
|
||||
@@ -995,6 +1022,32 @@ export class ProcessManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for SSH-specific errors in stderr (when running via SSH remote)
|
||||
// SSH errors typically appear on stderr (connection refused, permission denied, etc.)
|
||||
if (!managedProcess.errorEmitted) {
|
||||
const sshError = matchSshErrorPattern(stderrData);
|
||||
if (sshError) {
|
||||
managedProcess.errorEmitted = true;
|
||||
const agentError: AgentError = {
|
||||
type: sshError.type,
|
||||
message: sshError.message,
|
||||
recoverable: sshError.recoverable,
|
||||
agentId: toolType,
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
raw: {
|
||||
stderr: stderrData,
|
||||
},
|
||||
};
|
||||
logger.debug('[ProcessManager] SSH error detected from stderr', 'ProcessManager', {
|
||||
sessionId,
|
||||
errorType: sshError.type,
|
||||
errorMessage: sshError.message,
|
||||
});
|
||||
this.emit('agent-error', sessionId, agentError);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip ANSI codes and only emit if there's actual content
|
||||
const cleanedStderr = stripAllAnsiCodes(stderrData).trim();
|
||||
if (cleanedStderr) {
|
||||
@@ -1097,6 +1150,35 @@ export class ProcessManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for SSH-specific errors at exit (if not already emitted)
|
||||
// This catches SSH errors that may not have been detected during streaming
|
||||
if (!managedProcess.errorEmitted && (code !== 0 || managedProcess.stderrBuffer)) {
|
||||
const stderrToCheck = managedProcess.stderrBuffer || '';
|
||||
const sshError = matchSshErrorPattern(stderrToCheck);
|
||||
if (sshError) {
|
||||
managedProcess.errorEmitted = true;
|
||||
const agentError: AgentError = {
|
||||
type: sshError.type,
|
||||
message: sshError.message,
|
||||
recoverable: sshError.recoverable,
|
||||
agentId: toolType,
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
raw: {
|
||||
exitCode: code || 0,
|
||||
stderr: stderrToCheck,
|
||||
},
|
||||
};
|
||||
logger.debug('[ProcessManager] SSH error detected at exit', 'ProcessManager', {
|
||||
sessionId,
|
||||
exitCode: code,
|
||||
errorType: sshError.type,
|
||||
errorMessage: sshError.message,
|
||||
});
|
||||
this.emit('agent-error', sessionId, agentError);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp image files if any
|
||||
if (managedProcess.tempImageFiles && managedProcess.tempImageFiles.length > 0) {
|
||||
cleanupTempFiles(managedProcess.tempImageFiles);
|
||||
|
||||
312
src/main/ssh-remote-manager.ts
Normal file
312
src/main/ssh-remote-manager.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* SSH Remote Manager for Maestro.
|
||||
*
|
||||
* Manages SSH remote configurations and provides connection testing.
|
||||
* Used to execute AI agent commands on remote hosts via SSH.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { SshRemoteConfig, SshRemoteTestResult } from '../shared/types';
|
||||
import { execFileNoThrow, ExecResult } from './utils/execFile';
|
||||
|
||||
/**
|
||||
* Validation result for SSH remote configuration.
|
||||
*/
|
||||
export interface SshRemoteValidation {
|
||||
/** Whether the configuration is valid */
|
||||
valid: boolean;
|
||||
/** List of validation error messages */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies that can be injected for testing.
|
||||
*/
|
||||
export interface SshRemoteManagerDeps {
|
||||
/** Function to check file accessibility */
|
||||
checkFileAccess: (filePath: string) => boolean;
|
||||
/** Function to execute SSH commands */
|
||||
execSsh: (command: string, args: string[]) => Promise<ExecResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default dependencies using real implementations.
|
||||
*/
|
||||
const defaultDeps: SshRemoteManagerDeps = {
|
||||
checkFileAccess: (filePath: string): boolean => {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
execSsh: (command: string, args: string[]): Promise<ExecResult> => {
|
||||
return execFileNoThrow(command, args);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Manager for SSH remote configurations and connections.
|
||||
*
|
||||
* Provides:
|
||||
* - Configuration validation
|
||||
* - Connection testing
|
||||
* - SSH argument building
|
||||
*/
|
||||
export class SshRemoteManager {
|
||||
private readonly deps: SshRemoteManagerDeps;
|
||||
|
||||
/**
|
||||
* Default SSH options used for all connections.
|
||||
* These options ensure non-interactive key-based authentication.
|
||||
*/
|
||||
private readonly defaultSshOptions: Record<string, string> = {
|
||||
BatchMode: 'yes', // Disable password prompts (key-only)
|
||||
StrictHostKeyChecking: 'accept-new', // Auto-accept new host keys
|
||||
ConnectTimeout: '10', // Connection timeout in seconds
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new SshRemoteManager.
|
||||
*
|
||||
* @param deps Optional dependencies for testing. Uses real implementations if not provided.
|
||||
*/
|
||||
constructor(deps?: Partial<SshRemoteManagerDeps>) {
|
||||
this.deps = { ...defaultDeps, ...deps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an SSH remote configuration.
|
||||
*
|
||||
* Checks:
|
||||
* - Required fields are present
|
||||
* - Port is in valid range (1-65535)
|
||||
* - Private key file exists and is readable
|
||||
*
|
||||
* @param config The SSH remote configuration to validate
|
||||
* @returns Validation result with any error messages
|
||||
*/
|
||||
validateConfig(config: SshRemoteConfig): SshRemoteValidation {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required field checks
|
||||
if (!config.id || config.id.trim() === '') {
|
||||
errors.push('Configuration ID is required');
|
||||
}
|
||||
|
||||
if (!config.name || config.name.trim() === '') {
|
||||
errors.push('Name is required');
|
||||
}
|
||||
|
||||
if (!config.host || config.host.trim() === '') {
|
||||
errors.push('Host is required');
|
||||
}
|
||||
|
||||
if (!config.username || config.username.trim() === '') {
|
||||
errors.push('Username is required');
|
||||
}
|
||||
|
||||
if (!config.privateKeyPath || config.privateKeyPath.trim() === '') {
|
||||
errors.push('Private key path is required');
|
||||
}
|
||||
|
||||
// Port validation
|
||||
if (typeof config.port !== 'number' || config.port < 1 || config.port > 65535) {
|
||||
errors.push('Port must be between 1 and 65535');
|
||||
}
|
||||
|
||||
// Private key file existence check
|
||||
if (config.privateKeyPath && config.privateKeyPath.trim() !== '') {
|
||||
const keyPath = this.expandPath(config.privateKeyPath);
|
||||
if (!this.deps.checkFileAccess(keyPath)) {
|
||||
errors.push(`Private key not readable: ${config.privateKeyPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test SSH connection to a remote host.
|
||||
*
|
||||
* Executes a simple command on the remote to verify:
|
||||
* - SSH connection can be established
|
||||
* - Authentication succeeds
|
||||
* - Remote shell is accessible
|
||||
*
|
||||
* Optionally checks if the specified agent command is available.
|
||||
*
|
||||
* @param config The SSH remote configuration to test
|
||||
* @param agentCommand Optional agent command to check availability (e.g., 'claude')
|
||||
* @returns Test result with success status and remote info
|
||||
*/
|
||||
async testConnection(
|
||||
config: SshRemoteConfig,
|
||||
agentCommand?: string
|
||||
): Promise<SshRemoteTestResult> {
|
||||
// First validate the config
|
||||
const validation = this.validateConfig(config);
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
error: validation.errors.join('; '),
|
||||
};
|
||||
}
|
||||
|
||||
// Build SSH command for connection test
|
||||
const sshArgs = this.buildSshArgs(config);
|
||||
|
||||
// Test command: echo marker, get hostname, optionally check agent
|
||||
let testCommand = 'echo "SSH_OK" && hostname';
|
||||
if (agentCommand) {
|
||||
testCommand += ` && which ${agentCommand} 2>/dev/null || echo "AGENT_NOT_FOUND"`;
|
||||
}
|
||||
sshArgs.push(testCommand);
|
||||
|
||||
try {
|
||||
const result = await this.deps.execSsh('ssh', sshArgs);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
// Parse common SSH error patterns
|
||||
const errorMessage = this.parseSSHError(result.stderr) || 'Connection failed';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
|
||||
// Verify we got our marker
|
||||
if (lines[0] !== 'SSH_OK') {
|
||||
return { success: false, error: 'Unexpected response from remote host' };
|
||||
}
|
||||
|
||||
// Extract hostname and agent info
|
||||
const hostname = lines[1] || 'unknown';
|
||||
let agentVersion: string | undefined;
|
||||
|
||||
if (agentCommand && lines[2]) {
|
||||
if (lines[2] !== 'AGENT_NOT_FOUND') {
|
||||
agentVersion = 'installed'; // Path found = agent installed
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
remoteInfo: {
|
||||
hostname,
|
||||
agentVersion,
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Connection test failed: ${String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SSH command-line arguments for a remote connection.
|
||||
*
|
||||
* Constructs the argument array needed for spawning SSH with
|
||||
* proper authentication and connection options.
|
||||
*
|
||||
* @param config The SSH remote configuration
|
||||
* @returns Array of SSH command-line arguments
|
||||
*/
|
||||
buildSshArgs(config: SshRemoteConfig): string[] {
|
||||
const args: string[] = [];
|
||||
|
||||
// Private key
|
||||
args.push('-i', this.expandPath(config.privateKeyPath));
|
||||
|
||||
// Default SSH options
|
||||
for (const [key, value] of Object.entries(this.defaultSshOptions)) {
|
||||
args.push('-o', `${key}=${value}`);
|
||||
}
|
||||
|
||||
// Port
|
||||
args.push('-p', config.port.toString());
|
||||
|
||||
// User@host
|
||||
args.push(`${config.username}@${config.host}`);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand tilde (~) in paths to the user's home directory.
|
||||
*
|
||||
* @param filePath Path that may start with ~
|
||||
* @returns Expanded absolute path
|
||||
*/
|
||||
private expandPath(filePath: string): string {
|
||||
if (filePath.startsWith('~')) {
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
return path.join(homeDir, filePath.slice(1));
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SSH error messages to provide user-friendly descriptions.
|
||||
*
|
||||
* @param stderr The stderr output from SSH
|
||||
* @returns Human-readable error message, or undefined if not recognized
|
||||
*/
|
||||
private parseSSHError(stderr: string): string | undefined {
|
||||
const lowerStderr = stderr.toLowerCase();
|
||||
|
||||
if (lowerStderr.includes('permission denied')) {
|
||||
return 'Authentication failed. Check username and private key.';
|
||||
}
|
||||
|
||||
if (lowerStderr.includes('connection refused')) {
|
||||
return 'Connection refused. Check host and port.';
|
||||
}
|
||||
|
||||
if (lowerStderr.includes('connection timed out') || lowerStderr.includes('timed out')) {
|
||||
return 'Connection timed out. Check host and network.';
|
||||
}
|
||||
|
||||
if (lowerStderr.includes('no route to host')) {
|
||||
return 'No route to host. Check host address and network.';
|
||||
}
|
||||
|
||||
if (
|
||||
lowerStderr.includes('could not resolve hostname') ||
|
||||
lowerStderr.includes('name or service not known')
|
||||
) {
|
||||
return 'Could not resolve hostname. Check the host address.';
|
||||
}
|
||||
|
||||
if (lowerStderr.includes('remote host identification has changed')) {
|
||||
return 'SSH host key changed. Verify server identity and update known_hosts.';
|
||||
}
|
||||
|
||||
if (lowerStderr.includes('passphrase')) {
|
||||
return 'Private key has a passphrase. Key-based auth requires passphrase-less keys.';
|
||||
}
|
||||
|
||||
if (lowerStderr.includes('no such file')) {
|
||||
return 'Private key file not found.';
|
||||
}
|
||||
|
||||
// Return the raw stderr if we don't recognize the pattern
|
||||
if (stderr.trim()) {
|
||||
return stderr.trim();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of SshRemoteManager.
|
||||
* Use this for all SSH remote operations.
|
||||
*/
|
||||
export const sshRemoteManager = new SshRemoteManager();
|
||||
60
src/main/utils/shell-escape.ts
Normal file
60
src/main/utils/shell-escape.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shell escaping utilities for SSH remote execution.
|
||||
*
|
||||
* These utilities ensure safe command construction when building
|
||||
* shell commands for remote execution via SSH. Critical for preventing
|
||||
* shell injection attacks.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape a string for safe inclusion in a shell command.
|
||||
*
|
||||
* Uses single quotes and escapes any single quotes within the string.
|
||||
* This is the safest method for shell escaping as single-quoted strings
|
||||
* are treated literally in POSIX shells (no variable expansion, no
|
||||
* command substitution).
|
||||
*
|
||||
* @param str The string to escape
|
||||
* @returns The escaped string, wrapped in single quotes
|
||||
*
|
||||
* @example
|
||||
* shellEscape("hello world") // => "'hello world'"
|
||||
* shellEscape("it's fine") // => "'it'\\''s fine'"
|
||||
* shellEscape("$HOME") // => "'$HOME'" (no expansion)
|
||||
*/
|
||||
export function shellEscape(str: string): string {
|
||||
// Handle empty string
|
||||
if (str === '') {
|
||||
return "''";
|
||||
}
|
||||
|
||||
// Use single quotes and escape any single quotes within
|
||||
// The pattern 'text'\''more' breaks out of single quotes,
|
||||
// adds an escaped single quote, then re-enters single quotes
|
||||
return `'${str.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape multiple strings for shell inclusion.
|
||||
*
|
||||
* @param args Array of strings to escape
|
||||
* @returns Array of escaped strings
|
||||
*/
|
||||
export function shellEscapeArgs(args: string[]): string[] {
|
||||
return args.map(shellEscape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a shell command string from a command and arguments.
|
||||
*
|
||||
* @param command The command to run
|
||||
* @param args Arguments to the command
|
||||
* @returns A properly escaped shell command string
|
||||
*
|
||||
* @example
|
||||
* buildShellCommand("echo", ["hello", "world"])
|
||||
* // => "echo 'hello' 'world'"
|
||||
*/
|
||||
export function buildShellCommand(command: string, args: string[]): string {
|
||||
return [command, ...shellEscapeArgs(args)].join(' ');
|
||||
}
|
||||
202
src/main/utils/ssh-command-builder.ts
Normal file
202
src/main/utils/ssh-command-builder.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* SSH Command Builder utilities for remote agent execution.
|
||||
*
|
||||
* Provides functions to construct SSH command invocations that wrap
|
||||
* agent commands for remote execution. These utilities work with
|
||||
* SshRemoteManager and ProcessManager to enable executing AI agents
|
||||
* on remote hosts via SSH.
|
||||
*/
|
||||
|
||||
import { SshRemoteConfig } from '../../shared/types';
|
||||
import { shellEscape, buildShellCommand } from './shell-escape';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Result of building an SSH command.
|
||||
* Contains the command and arguments to pass to spawn().
|
||||
*/
|
||||
export interface SshCommandResult {
|
||||
/** The command to execute ('ssh') */
|
||||
command: string;
|
||||
/** Arguments for the SSH command */
|
||||
args: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for building the remote command.
|
||||
*/
|
||||
export interface RemoteCommandOptions {
|
||||
/** The command to execute on the remote host */
|
||||
command: string;
|
||||
/** Arguments for the command */
|
||||
args: string[];
|
||||
/** Working directory on the remote host (optional) */
|
||||
cwd?: string;
|
||||
/** Environment variables to set on the remote (optional) */
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default SSH options for all connections.
|
||||
* These options ensure non-interactive, key-based authentication.
|
||||
*/
|
||||
const DEFAULT_SSH_OPTIONS: Record<string, string> = {
|
||||
BatchMode: 'yes', // Disable password prompts (key-only)
|
||||
StrictHostKeyChecking: 'accept-new', // Auto-accept new host keys
|
||||
ConnectTimeout: '10', // Connection timeout in seconds
|
||||
};
|
||||
|
||||
/**
|
||||
* Expand tilde (~) in paths to the user's home directory.
|
||||
*
|
||||
* @param filePath Path that may start with ~
|
||||
* @returns Expanded absolute path
|
||||
*/
|
||||
function expandPath(filePath: string): string {
|
||||
if (filePath.startsWith('~')) {
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || '';
|
||||
return path.join(homeDir, filePath.slice(1));
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the remote shell command string from command, args, cwd, and env.
|
||||
*
|
||||
* This function constructs a properly escaped shell command that:
|
||||
* 1. Changes to the specified working directory (if provided)
|
||||
* 2. Sets environment variables (if provided)
|
||||
* 3. Executes the command with its arguments
|
||||
*
|
||||
* The result is a single shell command string that can be passed to SSH.
|
||||
* All user-provided values are properly escaped to prevent shell injection.
|
||||
*
|
||||
* @param options Command options including command, args, cwd, and env
|
||||
* @returns Properly escaped shell command string for remote execution
|
||||
*
|
||||
* @example
|
||||
* buildRemoteCommand({
|
||||
* command: 'claude',
|
||||
* args: ['--print', '--verbose'],
|
||||
* cwd: '/home/user/project',
|
||||
* env: { ANTHROPIC_API_KEY: 'sk-...' }
|
||||
* })
|
||||
* // => "cd '/home/user/project' && ANTHROPIC_API_KEY='sk-...' 'claude' '--print' '--verbose'"
|
||||
*/
|
||||
export function buildRemoteCommand(options: RemoteCommandOptions): string {
|
||||
const { command, args, cwd, env } = options;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Add cd command if working directory is specified
|
||||
if (cwd) {
|
||||
parts.push(`cd ${shellEscape(cwd)}`);
|
||||
}
|
||||
|
||||
// Build environment variable exports
|
||||
const envExports: string[] = [];
|
||||
if (env && Object.keys(env).length > 0) {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
// Environment variable names are validated (alphanumeric + underscore)
|
||||
// but we still escape the value to be safe
|
||||
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
||||
envExports.push(`${key}=${shellEscape(value)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the command with arguments
|
||||
const commandWithArgs = buildShellCommand(command, args);
|
||||
|
||||
// Combine env exports with command
|
||||
let fullCommand: string;
|
||||
if (envExports.length > 0) {
|
||||
// Prepend env vars inline: VAR1='val1' VAR2='val2' command args
|
||||
fullCommand = `${envExports.join(' ')} ${commandWithArgs}`;
|
||||
} else {
|
||||
fullCommand = commandWithArgs;
|
||||
}
|
||||
|
||||
parts.push(fullCommand);
|
||||
|
||||
// Join with && to ensure cd succeeds before running command
|
||||
return parts.join(' && ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SSH command and arguments for remote execution.
|
||||
*
|
||||
* This function constructs the complete SSH invocation to execute
|
||||
* a command on a remote host. It uses the SSH config for authentication
|
||||
* details and builds a properly escaped remote command string.
|
||||
*
|
||||
* @param config SSH remote configuration
|
||||
* @param remoteOptions Options for the remote command (command, args, cwd, env)
|
||||
* @returns Object with 'ssh' command and arguments array
|
||||
*
|
||||
* @example
|
||||
* buildSshCommand(
|
||||
* { host: 'dev.example.com', port: 22, username: 'user', privateKeyPath: '~/.ssh/id_ed25519', ... },
|
||||
* { command: 'claude', args: ['--print', 'hello'], cwd: '/home/user/project' }
|
||||
* )
|
||||
* // => {
|
||||
* // command: 'ssh',
|
||||
* // args: [
|
||||
* // '-i', '/Users/me/.ssh/id_ed25519',
|
||||
* // '-o', 'BatchMode=yes',
|
||||
* // '-o', 'StrictHostKeyChecking=accept-new',
|
||||
* // '-o', 'ConnectTimeout=10',
|
||||
* // '-p', '22',
|
||||
* // 'user@dev.example.com',
|
||||
* // "cd '/home/user/project' && 'claude' '--print' 'hello'"
|
||||
* // ]
|
||||
* // }
|
||||
*/
|
||||
export function buildSshCommand(
|
||||
config: SshRemoteConfig,
|
||||
remoteOptions: RemoteCommandOptions
|
||||
): SshCommandResult {
|
||||
const args: string[] = [];
|
||||
|
||||
// Private key authentication
|
||||
args.push('-i', expandPath(config.privateKeyPath));
|
||||
|
||||
// Default SSH options for non-interactive operation
|
||||
for (const [key, value] of Object.entries(DEFAULT_SSH_OPTIONS)) {
|
||||
args.push('-o', `${key}=${value}`);
|
||||
}
|
||||
|
||||
// Port specification
|
||||
args.push('-p', config.port.toString());
|
||||
|
||||
// User@host
|
||||
args.push(`${config.username}@${config.host}`);
|
||||
|
||||
// Merge remote config's environment with the command-specific environment
|
||||
// Command-specific env takes precedence over remote config env
|
||||
const mergedEnv: Record<string, string> = {
|
||||
...(config.remoteEnv || {}),
|
||||
...(remoteOptions.env || {}),
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// Build the remote command string
|
||||
const remoteCommand = buildRemoteCommand({
|
||||
command: remoteOptions.command,
|
||||
args: remoteOptions.args,
|
||||
cwd: effectiveCwd,
|
||||
env: Object.keys(mergedEnv).length > 0 ? mergedEnv : undefined,
|
||||
});
|
||||
|
||||
args.push(remoteCommand);
|
||||
|
||||
return {
|
||||
command: 'ssh',
|
||||
args,
|
||||
};
|
||||
}
|
||||
173
src/main/utils/ssh-remote-resolver.ts
Normal file
173
src/main/utils/ssh-remote-resolver.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* SSH Remote Configuration Resolver.
|
||||
*
|
||||
* Provides utilities for resolving which SSH remote configuration should
|
||||
* be used for agent execution. Handles the resolution priority:
|
||||
* 1. Agent-specific SSH remote override (per-agent configuration)
|
||||
* 2. Global default SSH remote (applies to all agents)
|
||||
* 3. Local execution (no SSH remote)
|
||||
*
|
||||
* This module is used by the process spawn handlers to determine whether
|
||||
* an agent command should be executed locally or via SSH on a remote host.
|
||||
*/
|
||||
|
||||
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../shared/types';
|
||||
|
||||
/**
|
||||
* Options for resolving SSH remote configuration.
|
||||
*/
|
||||
export interface SshRemoteResolveOptions {
|
||||
/**
|
||||
* Agent-specific SSH remote configuration (optional).
|
||||
* If provided and enabled, takes precedence over global default.
|
||||
*/
|
||||
agentSshConfig?: AgentSshRemoteConfig;
|
||||
|
||||
/**
|
||||
* The tool type / agent ID.
|
||||
* Used for logging and debugging.
|
||||
*/
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of SSH remote configuration resolution.
|
||||
*/
|
||||
export interface SshRemoteResolveResult {
|
||||
/**
|
||||
* The resolved SSH remote configuration, or null for local execution.
|
||||
*/
|
||||
config: SshRemoteConfig | null;
|
||||
|
||||
/**
|
||||
* How the configuration was resolved.
|
||||
* - 'agent': Agent-specific override was used
|
||||
* - 'global': Global default was used
|
||||
* - 'disabled': SSH remote is explicitly disabled for this agent
|
||||
* - 'none': No SSH remote configured (local execution)
|
||||
*/
|
||||
source: 'agent' | 'global' | 'disabled' | 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Store interface for accessing SSH remote settings.
|
||||
* This allows dependency injection for testing.
|
||||
*/
|
||||
export interface SshRemoteSettingsStore {
|
||||
/**
|
||||
* Get all SSH remote configurations.
|
||||
*/
|
||||
getSshRemotes(): SshRemoteConfig[];
|
||||
|
||||
/**
|
||||
* Get the global default SSH remote ID.
|
||||
*/
|
||||
getDefaultSshRemoteId(): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective SSH remote configuration for agent execution.
|
||||
*
|
||||
* Resolution priority:
|
||||
* 1. If agentSshConfig is provided and explicitly disabled -> local execution
|
||||
* 2. If agentSshConfig is provided with a remoteId -> use that specific remote
|
||||
* 3. If global defaultSshRemoteId is set -> use that remote
|
||||
* 4. Otherwise -> local execution
|
||||
*
|
||||
* @param store The settings store to read SSH remote configurations from
|
||||
* @param options Resolution options including agent-specific config
|
||||
* @returns Resolved SSH remote configuration with source information
|
||||
*
|
||||
* @example
|
||||
* // Using global default (no agent override)
|
||||
* const result = getSshRemoteConfig(store, {});
|
||||
* if (result.config) {
|
||||
* // Execute via SSH
|
||||
* }
|
||||
*
|
||||
* @example
|
||||
* // With agent-specific override
|
||||
* const result = getSshRemoteConfig(store, {
|
||||
* agentSshConfig: { enabled: true, remoteId: 'remote-1' },
|
||||
* agentId: 'claude-code'
|
||||
* });
|
||||
*/
|
||||
export function getSshRemoteConfig(
|
||||
store: SshRemoteSettingsStore,
|
||||
options: SshRemoteResolveOptions = {}
|
||||
): SshRemoteResolveResult {
|
||||
const { agentSshConfig, agentId: _agentId } = options;
|
||||
|
||||
// Get all available SSH remotes
|
||||
const sshRemotes = store.getSshRemotes();
|
||||
|
||||
// Priority 1: Check agent-specific configuration
|
||||
if (agentSshConfig) {
|
||||
// If explicitly disabled for this agent, return null (local execution)
|
||||
if (!agentSshConfig.enabled) {
|
||||
return {
|
||||
config: null,
|
||||
source: 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
// If agent has a specific remote ID configured, use it
|
||||
if (agentSshConfig.remoteId) {
|
||||
const config = sshRemotes.find(
|
||||
(r) => r.id === agentSshConfig.remoteId && r.enabled
|
||||
);
|
||||
|
||||
if (config) {
|
||||
return {
|
||||
config,
|
||||
source: 'agent',
|
||||
};
|
||||
}
|
||||
// If the specified remote doesn't exist or is disabled, fall through to global default
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Check global default
|
||||
const defaultId = store.getDefaultSshRemoteId();
|
||||
if (defaultId) {
|
||||
const config = sshRemotes.find((r) => r.id === defaultId && r.enabled);
|
||||
|
||||
if (config) {
|
||||
return {
|
||||
config,
|
||||
source: 'global',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: No SSH remote configured - local execution
|
||||
return {
|
||||
config: null,
|
||||
source: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SshRemoteSettingsStore adapter from an electron-store instance.
|
||||
*
|
||||
* This adapter wraps an electron-store to provide the SshRemoteSettingsStore
|
||||
* interface, allowing the resolver to be used with the actual settings store.
|
||||
*
|
||||
* @param store The electron-store instance with SSH remote settings
|
||||
* @returns A SshRemoteSettingsStore adapter
|
||||
*
|
||||
* @example
|
||||
* const storeAdapter = createStoreAdapter(settingsStore);
|
||||
* const result = getSshRemoteConfig(storeAdapter, { agentId: 'claude-code' });
|
||||
*/
|
||||
export function createSshRemoteStoreAdapter<
|
||||
T extends {
|
||||
get(key: 'sshRemotes', defaultValue: SshRemoteConfig[]): SshRemoteConfig[];
|
||||
get(key: 'defaultSshRemoteId', defaultValue: null): string | null;
|
||||
}
|
||||
>(store: T): SshRemoteSettingsStore {
|
||||
return {
|
||||
getSshRemotes: () => store.get('sshRemotes', []),
|
||||
getDefaultSshRemoteId: () => store.get('defaultSshRemoteId', null),
|
||||
};
|
||||
}
|
||||
@@ -2347,6 +2347,30 @@ function MaestroConsoleInner() {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle SSH remote status events - tracks when sessions are executing on remote hosts
|
||||
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-(.+)$/);
|
||||
if (aiTabMatch) {
|
||||
actualSessionId = aiTabMatch[1];
|
||||
} else if (sessionId.endsWith('-ai') || sessionId.endsWith('-terminal')) {
|
||||
actualSessionId = sessionId.replace(/-ai$|-terminal$/, '');
|
||||
} else {
|
||||
actualSessionId = sessionId;
|
||||
}
|
||||
|
||||
// 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)
|
||||
const currentRemoteId = s.sshRemote?.id;
|
||||
const newRemoteId = sshRemote?.id;
|
||||
if (currentRemoteId === newRemoteId) return s;
|
||||
return { ...s, sshRemote: sshRemote ?? undefined };
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle tool execution events from AI agents
|
||||
// Only appends to logs if the tab has showThinking enabled (tools shown alongside thinking)
|
||||
const unsubscribeToolExecution = window.maestro.process.onToolExecution?.((sessionId: string, toolEvent: { toolName: string; state?: unknown; timestamp: number }) => {
|
||||
@@ -2395,6 +2419,7 @@ function MaestroConsoleInner() {
|
||||
unsubscribeUsage();
|
||||
unsubscribeAgentError();
|
||||
unsubscribeThinkingChunk?.();
|
||||
unsubscribeSshRemote?.();
|
||||
unsubscribeToolExecution?.();
|
||||
// Cancel any pending thinking chunk RAF and clear buffer (Phase 6.4)
|
||||
if (thinkingChunkRafIdRef.current !== null) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { Wand2, ExternalLink, Columns, Copy, Loader2, GitBranch, ArrowUp, ArrowDown, FileEdit, List, AlertCircle, X, GitPullRequest, Settings2 } from 'lucide-react';
|
||||
import { Wand2, ExternalLink, Columns, Copy, Loader2, GitBranch, ArrowUp, ArrowDown, FileEdit, List, AlertCircle, X, GitPullRequest, Settings2, Server } from 'lucide-react';
|
||||
import { LogViewer } from './LogViewer';
|
||||
import { TerminalOutput } from './TerminalOutput';
|
||||
import { InputArea } from './InputArea';
|
||||
@@ -703,6 +703,24 @@ export const MainPanel = React.memo(forwardRef<MainPanelHandle, MainPanelProps>(
|
||||
compact={useCompactGitWidget}
|
||||
/>
|
||||
|
||||
{/* SSH Remote Indicator - shows when session is using SSH remote execution */}
|
||||
{activeSession.sshRemote && (
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full border"
|
||||
style={{
|
||||
borderColor: 'rgba(147, 51, 234, 0.3)', // purple-500/30
|
||||
color: 'rgb(147, 51, 234)', // purple-500
|
||||
backgroundColor: 'rgba(147, 51, 234, 0.1)', // purple-500/10
|
||||
}}
|
||||
title={`Running on SSH remote: ${activeSession.sshRemote.name} (${activeSession.sshRemote.host})`}
|
||||
>
|
||||
<Server className="w-3 h-3" />
|
||||
<span className="max-w-[100px] truncate">
|
||||
{activeSession.sshRemote.name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Center: AUTO Mode Indicator - only show for current session */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import { Folder, RefreshCw, ChevronRight, AlertTriangle } from 'lucide-react';
|
||||
import type { AgentConfig, Session, ToolType } from '../types';
|
||||
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../shared/types';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { validateNewSession, validateEditSession } from '../utils/sessionValidation';
|
||||
import { FormInput } from './ui/FormInput';
|
||||
@@ -78,6 +79,10 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
|
||||
const [availableModels, setAvailableModels] = useState<Record<string, string[]>>({});
|
||||
const [loadingModels, setLoadingModels] = useState<Record<string, boolean>>({});
|
||||
const [directoryWarningAcknowledged, setDirectoryWarningAcknowledged] = useState(false);
|
||||
// SSH Remote configuration
|
||||
const [sshRemotes, setSshRemotes] = useState<SshRemoteConfig[]>([]);
|
||||
const [globalDefaultSshRemoteId, setGlobalDefaultSshRemoteId] = useState<string | null>(null);
|
||||
const [agentSshRemoteConfigs, setAgentSshRemoteConfigs] = useState<Record<string, AgentSshRemoteConfig>>({});
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -116,7 +121,14 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
|
||||
const detectedAgents = await window.maestro.agents.detect();
|
||||
setAgents(detectedAgents);
|
||||
|
||||
// Load configurations for all agents
|
||||
// Per-agent config (path, args, env vars) starts empty - each agent gets its own config
|
||||
// No provider-level loading - config is set per-agent during creation
|
||||
setCustomAgentPaths({});
|
||||
setCustomAgentArgs({});
|
||||
setCustomAgentEnvVars({});
|
||||
setAgentSshRemoteConfigs({});
|
||||
|
||||
// Load configurations for all agents (model, contextWindow - these are provider-level)
|
||||
const configs: Record<string, Record<string, any>> = {};
|
||||
const paths: Record<string, string> = {};
|
||||
const args: Record<string, string> = {};
|
||||
@@ -143,6 +155,20 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
|
||||
setCustomAgentArgs(args);
|
||||
setCustomAgentEnvVars(envVars);
|
||||
|
||||
// Load SSH remote configurations
|
||||
try {
|
||||
const sshConfigsResult = await window.maestro.sshRemote.getConfigs();
|
||||
if (sshConfigsResult.success && sshConfigsResult.configs) {
|
||||
setSshRemotes(sshConfigsResult.configs);
|
||||
}
|
||||
const sshDefaultResult = await window.maestro.sshRemote.getDefaultId();
|
||||
if (sshDefaultResult.success) {
|
||||
setGlobalDefaultSshRemoteId(sshDefaultResult.id ?? null);
|
||||
}
|
||||
} catch (sshError) {
|
||||
console.error('Failed to load SSH remote configs:', sshError);
|
||||
}
|
||||
|
||||
// Select first available non-hidden agent
|
||||
// (hidden agents like 'terminal' should never be auto-selected)
|
||||
const firstAvailable = detectedAgents.find((a: AgentConfig) => a.available && !a.hidden);
|
||||
@@ -218,6 +244,15 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
|
||||
// Get model from agent config - this will become per-session
|
||||
const agentCustomModel = agentConfigs[selectedAgent]?.model?.trim() || undefined;
|
||||
|
||||
// Save SSH remote configuration if set
|
||||
const sshRemoteConfig = agentSshRemoteConfigs[selectedAgent];
|
||||
if (sshRemoteConfig) {
|
||||
// Merge SSH remote config into agent configs and persist
|
||||
const currentConfig = agentConfigs[selectedAgent] || {};
|
||||
const updatedConfig = { ...currentConfig, sshRemote: sshRemoteConfig };
|
||||
window.maestro.agents.setConfig(selectedAgent, updatedConfig);
|
||||
}
|
||||
|
||||
onCreate(
|
||||
selectedAgent,
|
||||
expandedWorkingDir,
|
||||
@@ -238,7 +273,12 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
|
||||
setCustomAgentPaths(prev => ({ ...prev, [selectedAgent]: '' }));
|
||||
setCustomAgentArgs(prev => ({ ...prev, [selectedAgent]: '' }));
|
||||
setCustomAgentEnvVars(prev => ({ ...prev, [selectedAgent]: {} }));
|
||||
}, [instanceName, selectedAgent, workingDir, nudgeMessage, customAgentPaths, customAgentArgs, customAgentEnvVars, agentConfigs, onCreate, onClose, expandTilde, existingSessions]);
|
||||
setAgentSshRemoteConfigs(prev => {
|
||||
const newConfigs = { ...prev };
|
||||
delete newConfigs[selectedAgent];
|
||||
return newConfigs;
|
||||
});
|
||||
}, [instanceName, selectedAgent, workingDir, nudgeMessage, customAgentPaths, customAgentArgs, customAgentEnvVars, agentConfigs, agentSshRemoteConfigs, onCreate, onClose, expandTilde, existingSessions]);
|
||||
|
||||
// Check if form is valid for submission
|
||||
const isFormValid = useMemo(() => {
|
||||
@@ -535,6 +575,15 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
|
||||
onRefreshAgent={() => handleRefreshAgent(agent.id)}
|
||||
refreshingAgent={refreshingAgent === agent.id}
|
||||
showBuiltInEnvVars
|
||||
sshRemotes={sshRemotes}
|
||||
sshRemoteConfig={agentSshRemoteConfigs[agent.id]}
|
||||
onSshRemoteConfigChange={(config) => {
|
||||
setAgentSshRemoteConfigs(prev => ({
|
||||
...prev,
|
||||
[agent.id]: config
|
||||
}));
|
||||
}}
|
||||
globalDefaultSshRemoteId={globalDefaultSshRemoteId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -705,6 +754,10 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi
|
||||
const [customEnvVars, setCustomEnvVars] = useState<Record<string, string>>({});
|
||||
const [_customModel, setCustomModel] = useState('');
|
||||
const [refreshingAgent, setRefreshingAgent] = useState(false);
|
||||
// SSH Remote configuration
|
||||
const [sshRemotes, setSshRemotes] = useState<SshRemoteConfig[]>([]);
|
||||
const [globalDefaultSshRemoteId, setGlobalDefaultSshRemoteId] = useState<string | null>(null);
|
||||
const [sshRemoteConfig, setSshRemoteConfig] = useState<AgentSshRemoteConfig | undefined>(undefined);
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -732,8 +785,27 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi
|
||||
const modelValue = session.customModel ?? globalConfig.model ?? '';
|
||||
const contextWindowValue = session.customContextWindow ?? globalConfig.contextWindow;
|
||||
setAgentConfig({ ...globalConfig, model: modelValue, contextWindow: contextWindowValue });
|
||||
// Load SSH remote config from agent config
|
||||
const sshConfig = globalConfig.sshRemote as AgentSshRemoteConfig | undefined;
|
||||
setSshRemoteConfig(sshConfig);
|
||||
});
|
||||
|
||||
// Load SSH remote configurations
|
||||
window.maestro.sshRemote.getConfigs()
|
||||
.then((result) => {
|
||||
if (result.success && result.configs) {
|
||||
setSshRemotes(result.configs);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('Failed to load SSH remotes:', err));
|
||||
window.maestro.sshRemote.getDefaultId()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
setGlobalDefaultSshRemoteId(result.id ?? null);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('Failed to load SSH default ID:', err));
|
||||
|
||||
// Load per-session config (stored on the session/agent instance)
|
||||
// No provider-level fallback - each agent has its own config
|
||||
setCustomPath(session.customPath ?? '');
|
||||
@@ -775,6 +847,18 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi
|
||||
? agentConfig.contextWindow
|
||||
: undefined;
|
||||
|
||||
// Save SSH remote configuration to agent config store
|
||||
if (sshRemoteConfig) {
|
||||
const currentConfig = { ...agentConfig };
|
||||
currentConfig.sshRemote = sshRemoteConfig;
|
||||
window.maestro.agents.setConfig(session.toolType, currentConfig);
|
||||
} else {
|
||||
// Clear SSH remote config if undefined
|
||||
const currentConfig = { ...agentConfig };
|
||||
delete currentConfig.sshRemote;
|
||||
window.maestro.agents.setConfig(session.toolType, currentConfig);
|
||||
}
|
||||
|
||||
// Save with per-session config fields including model and contextWindow
|
||||
onSave(
|
||||
session.id,
|
||||
@@ -787,7 +871,7 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi
|
||||
contextWindowValue
|
||||
);
|
||||
onClose();
|
||||
}, [session, instanceName, nudgeMessage, customPath, customArgs, customEnvVars, agentConfig, onSave, onClose, existingSessions]);
|
||||
}, [session, instanceName, nudgeMessage, customPath, customArgs, customEnvVars, agentConfig, sshRemoteConfig, onSave, onClose, existingSessions]);
|
||||
|
||||
// Refresh available models
|
||||
const refreshModels = useCallback(async () => {
|
||||
@@ -1004,6 +1088,10 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi
|
||||
onRefreshAgent={handleRefreshAgent}
|
||||
refreshingAgent={refreshingAgent}
|
||||
showBuiltInEnvVars
|
||||
sshRemotes={sshRemotes}
|
||||
sshRemoteConfig={sshRemoteConfig}
|
||||
onSshRemoteConfigChange={setSshRemoteConfig}
|
||||
globalDefaultSshRemoteId={globalDefaultSshRemoteId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
535
src/renderer/components/Settings/SshRemoteModal.tsx
Normal file
535
src/renderer/components/Settings/SshRemoteModal.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* SshRemoteModal - Modal for adding/editing SSH remote configurations
|
||||
*
|
||||
* This modal provides a form for configuring SSH remotes that can be used
|
||||
* to execute AI agents on remote hosts. Supports:
|
||||
* - Host/port configuration
|
||||
* - Username and private key path
|
||||
* - Optional remote working directory
|
||||
* - Environment variables for remote execution
|
||||
* - Connection testing before saving
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <SshRemoteModal
|
||||
* theme={theme}
|
||||
* isOpen={showModal}
|
||||
* onClose={() => setShowModal(false)}
|
||||
* onSave={handleSaveConfig}
|
||||
* initialConfig={editingConfig} // Optional for editing
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Server, Plus, Trash2, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import type { Theme } from '../../types';
|
||||
import type { SshRemoteConfig, SshRemoteTestResult } from '../../../shared/types';
|
||||
import { MODAL_PRIORITIES } from '../../constants/modalPriorities';
|
||||
import { Modal, ModalFooter } from '../ui/Modal';
|
||||
import { FormInput } from '../ui/FormInput';
|
||||
|
||||
/**
|
||||
* Environment variable entry with stable ID for editing
|
||||
*/
|
||||
interface EnvVarEntry {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SshRemoteModalProps {
|
||||
/** Theme object for styling */
|
||||
theme: Theme;
|
||||
/** Whether the modal is open */
|
||||
isOpen: boolean;
|
||||
/** Callback when modal is closed */
|
||||
onClose: () => void;
|
||||
/** Callback when configuration is saved. Returns the saved config or error */
|
||||
onSave: (config: Partial<SshRemoteConfig>) => Promise<{
|
||||
success: boolean;
|
||||
config?: SshRemoteConfig;
|
||||
error?: string;
|
||||
}>;
|
||||
/** Optional callback to test connection before saving */
|
||||
onTestConnection?: (config: SshRemoteConfig) => Promise<{
|
||||
success: boolean;
|
||||
result?: SshRemoteTestResult;
|
||||
error?: string;
|
||||
}>;
|
||||
/** Optional initial configuration for editing */
|
||||
initialConfig?: SshRemoteConfig;
|
||||
/** Modal title override */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert environment variable object to array with stable IDs
|
||||
*/
|
||||
function envVarsToArray(envVars?: Record<string, string>): EnvVarEntry[] {
|
||||
if (!envVars) return [];
|
||||
return Object.entries(envVars).map(([key, value], index) => ({
|
||||
id: index,
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert environment variable array back to object
|
||||
*/
|
||||
function envVarsToObject(entries: EnvVarEntry[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
entries.forEach((entry) => {
|
||||
if (entry.key.trim()) {
|
||||
result[entry.key] = entry.value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function SshRemoteModal({
|
||||
theme,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
onTestConnection,
|
||||
initialConfig,
|
||||
title,
|
||||
}: SshRemoteModalProps) {
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
const [host, setHost] = useState('');
|
||||
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);
|
||||
|
||||
// UI state
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
hostname?: string;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEnvVars, setShowEnvVars] = useState(false);
|
||||
|
||||
// Refs
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset form when modal opens/closes or initialConfig changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (initialConfig) {
|
||||
setName(initialConfig.name);
|
||||
setHost(initialConfig.host);
|
||||
setPort(String(initialConfig.port));
|
||||
setUsername(initialConfig.username);
|
||||
setPrivateKeyPath(initialConfig.privateKeyPath);
|
||||
setRemoteWorkingDir(initialConfig.remoteWorkingDir || '');
|
||||
const entries = envVarsToArray(initialConfig.remoteEnv);
|
||||
setEnvVars(entries);
|
||||
setNextEnvVarId(entries.length);
|
||||
setEnabled(initialConfig.enabled);
|
||||
setShowEnvVars(entries.length > 0);
|
||||
} else {
|
||||
// Reset to defaults for new config
|
||||
setName('');
|
||||
setHost('');
|
||||
setPort('22');
|
||||
setUsername('');
|
||||
setPrivateKeyPath('');
|
||||
setRemoteWorkingDir('');
|
||||
setEnvVars([]);
|
||||
setNextEnvVarId(0);
|
||||
setEnabled(true);
|
||||
setShowEnvVars(false);
|
||||
}
|
||||
setError(null);
|
||||
setTestResult(null);
|
||||
}
|
||||
}, [isOpen, initialConfig]);
|
||||
|
||||
// Validation
|
||||
const validateForm = useCallback((): string | null => {
|
||||
if (!name.trim()) return 'Name is required';
|
||||
if (!host.trim()) return 'Host is required';
|
||||
const portNum = parseInt(port, 10);
|
||||
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
|
||||
return 'Port must be between 1 and 65535';
|
||||
}
|
||||
if (!username.trim()) return 'Username is required';
|
||||
if (!privateKeyPath.trim()) return 'Private key path is required';
|
||||
return null;
|
||||
}, [name, host, port, username, privateKeyPath]);
|
||||
|
||||
const isValid = validateForm() === null;
|
||||
|
||||
// Build config object from form state
|
||||
const buildConfig = useCallback((): SshRemoteConfig => {
|
||||
return {
|
||||
id: initialConfig?.id || '',
|
||||
name: name.trim(),
|
||||
host: host.trim(),
|
||||
port: parseInt(port, 10),
|
||||
username: username.trim(),
|
||||
privateKeyPath: privateKeyPath.trim(),
|
||||
remoteWorkingDir: remoteWorkingDir.trim() || undefined,
|
||||
remoteEnv: Object.keys(envVarsToObject(envVars)).length > 0
|
||||
? envVarsToObject(envVars)
|
||||
: undefined,
|
||||
enabled,
|
||||
};
|
||||
}, [initialConfig, name, host, port, username, privateKeyPath, remoteWorkingDir, envVars, enabled]);
|
||||
|
||||
// Handle save
|
||||
const handleSave = async () => {
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const config = buildConfig();
|
||||
const result = await onSave(config);
|
||||
if (result.success) {
|
||||
onClose();
|
||||
} else {
|
||||
setError(result.error || 'Failed to save configuration');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle test connection
|
||||
const handleTestConnection = async () => {
|
||||
if (!onTestConnection) return;
|
||||
|
||||
const validationError = validateForm();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const config = buildConfig();
|
||||
const result = await onTestConnection(config);
|
||||
if (result.success && result.result) {
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: 'Connection successful!',
|
||||
hostname: result.result.remoteInfo?.hostname,
|
||||
});
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: result.error || 'Connection failed',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: err instanceof Error ? err.message : 'Connection test failed',
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Environment variable handlers
|
||||
const addEnvVar = () => {
|
||||
setEnvVars((prev) => [...prev, { id: nextEnvVarId, key: '', value: '' }]);
|
||||
setNextEnvVarId((prev) => prev + 1);
|
||||
setShowEnvVars(true);
|
||||
};
|
||||
|
||||
const updateEnvVar = (id: number, field: 'key' | 'value', value: string) => {
|
||||
setEnvVars((prev) =>
|
||||
prev.map((entry) => (entry.id === id ? { ...entry, [field]: value } : entry))
|
||||
);
|
||||
};
|
||||
|
||||
const removeEnvVar = (id: number) => {
|
||||
setEnvVars((prev) => prev.filter((entry) => entry.id !== id));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const modalTitle = title || (initialConfig ? 'Edit SSH Remote' : 'Add SSH Remote');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
theme={theme}
|
||||
title={modalTitle}
|
||||
priority={MODAL_PRIORITIES.SSH_REMOTE}
|
||||
onClose={onClose}
|
||||
width={500}
|
||||
headerIcon={<Server className="w-4 h-4" style={{ color: theme.colors.accent }} />}
|
||||
initialFocusRef={nameInputRef as React.RefObject<HTMLElement>}
|
||||
footer={
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{/* Test Connection Button */}
|
||||
{onTestConnection && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !isValid}
|
||||
className="px-3 py-2 rounded border text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
'Test Connection'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<ModalFooter
|
||||
theme={theme}
|
||||
onCancel={onClose}
|
||||
onConfirm={handleSave}
|
||||
confirmLabel={saving ? 'Saving...' : 'Save'}
|
||||
confirmDisabled={!isValid || saving}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div
|
||||
className="p-3 rounded flex items-start gap-2 text-sm"
|
||||
style={{
|
||||
backgroundColor: theme.colors.error + '20',
|
||||
color: theme.colors.error,
|
||||
}}
|
||||
>
|
||||
<XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div
|
||||
className="p-3 rounded flex items-start gap-2 text-sm"
|
||||
style={{
|
||||
backgroundColor: testResult.success
|
||||
? theme.colors.success + '20'
|
||||
: theme.colors.error + '20',
|
||||
color: testResult.success ? theme.colors.success : theme.colors.error,
|
||||
}}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<div>{testResult.message}</div>
|
||||
{testResult.hostname && (
|
||||
<div className="text-xs mt-1 opacity-80">
|
||||
Remote hostname: {testResult.hostname}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<FormInput
|
||||
ref={nameInputRef}
|
||||
theme={theme}
|
||||
label="Display Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="My Remote Server"
|
||||
helperText="A friendly name to identify this remote configuration"
|
||||
/>
|
||||
|
||||
{/* Host and Port */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1">
|
||||
<FormInput
|
||||
theme={theme}
|
||||
label="Host"
|
||||
value={host}
|
||||
onChange={setHost}
|
||||
placeholder="192.168.1.100 or server.example.com"
|
||||
monospace
|
||||
/>
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<FormInput
|
||||
theme={theme}
|
||||
label="Port"
|
||||
value={port}
|
||||
onChange={setPort}
|
||||
placeholder="22"
|
||||
monospace
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<FormInput
|
||||
theme={theme}
|
||||
label="Username"
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
placeholder="username"
|
||||
monospace
|
||||
/>
|
||||
|
||||
{/* Private Key Path */}
|
||||
<FormInput
|
||||
theme={theme}
|
||||
label="Private Key Path"
|
||||
value={privateKeyPath}
|
||||
onChange={setPrivateKeyPath}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
monospace
|
||||
helperText="Path to your SSH private key file (password-protected keys require 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">
|
||||
<label
|
||||
className="text-xs font-bold opacity-70 uppercase"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
Environment Variables (optional)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEnvVar}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs hover:bg-white/10 transition-colors"
|
||||
style={{ color: theme.colors.accent }}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add Variable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showEnvVars && envVars.length > 0 && (
|
||||
<div className="space-y-2 mb-2">
|
||||
{envVars.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEnvVar(entry.id, 'key', e.target.value)}
|
||||
placeholder="VARIABLE"
|
||||
className="flex-1 p-2 rounded border bg-transparent outline-none text-xs font-mono"
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs" style={{ color: theme.colors.textDim }}>
|
||||
=
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEnvVar(entry.id, 'value', e.target.value)}
|
||||
placeholder="value"
|
||||
className="flex-[2] p-2 rounded border bg-transparent outline-none text-xs font-mono"
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvVar(entry.id)}
|
||||
className="p-2 rounded hover:bg-white/10 transition-colors"
|
||||
title="Remove variable"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs" style={{ color: theme.colors.textDim }}>
|
||||
Environment variables passed to agents running on this remote host
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enabled Toggle */}
|
||||
<div
|
||||
className="flex items-center justify-between p-3 rounded border"
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.bgMain,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-sm" style={{ color: theme.colors.textMain }}>
|
||||
Enable this remote
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: theme.colors.textDim }}>
|
||||
Disabled remotes won't be available for selection
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className="w-12 h-6 rounded-full transition-colors relative"
|
||||
style={{
|
||||
backgroundColor: enabled ? theme.colors.accent : theme.colors.bgActivity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform"
|
||||
style={{
|
||||
transform: enabled ? 'translateX(26px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SshRemoteModal;
|
||||
382
src/renderer/components/Settings/SshRemotesSection.tsx
Normal file
382
src/renderer/components/Settings/SshRemotesSection.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* SshRemotesSection - Settings section for managing SSH remote configurations
|
||||
*
|
||||
* This component provides a UI for:
|
||||
* - Listing all configured SSH remotes
|
||||
* - Adding new SSH remotes
|
||||
* - Editing existing SSH remotes
|
||||
* - Deleting SSH remotes
|
||||
* - Setting the global default SSH remote
|
||||
* - Testing SSH connections
|
||||
*
|
||||
* Integrates with the useSshRemotes hook for state management and
|
||||
* the SshRemoteModal for add/edit operations.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* <SshRemotesSection theme={theme} />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Check,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from 'lucide-react';
|
||||
import type { Theme } from '../../types';
|
||||
import type { SshRemoteConfig } from '../../../shared/types';
|
||||
import { useSshRemotes } from '../../hooks';
|
||||
import { SshRemoteModal } from './SshRemoteModal';
|
||||
|
||||
export interface SshRemotesSectionProps {
|
||||
/** Theme object for styling */
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
export function SshRemotesSection({ theme }: SshRemotesSectionProps) {
|
||||
// SSH remotes state from hook
|
||||
const {
|
||||
configs,
|
||||
defaultId,
|
||||
loading,
|
||||
error,
|
||||
saveConfig,
|
||||
deleteConfig,
|
||||
setDefaultId,
|
||||
testConnection,
|
||||
testingConfigId,
|
||||
} = useSshRemotes();
|
||||
|
||||
// Local UI state
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<SshRemoteConfig | undefined>(undefined);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>(
|
||||
{}
|
||||
);
|
||||
|
||||
// Handle add new remote
|
||||
const handleAddNew = () => {
|
||||
setEditingConfig(undefined);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Handle edit existing remote
|
||||
const handleEdit = (config: SshRemoteConfig) => {
|
||||
setEditingConfig(config);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Handle delete remote
|
||||
const handleDelete = async (id: string) => {
|
||||
setDeletingId(id);
|
||||
const result = await deleteConfig(id);
|
||||
if (!result.success) {
|
||||
console.error('Failed to delete SSH remote:', result.error);
|
||||
}
|
||||
setDeletingId(null);
|
||||
// Clear test result for deleted config
|
||||
setTestResults((prev) => {
|
||||
const { [id]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle set as default
|
||||
const handleSetDefault = async (id: string) => {
|
||||
// Toggle default off if already default
|
||||
const newDefaultId = id === defaultId ? null : id;
|
||||
await setDefaultId(newDefaultId);
|
||||
};
|
||||
|
||||
// Handle test connection from list
|
||||
const handleTestFromList = async (config: SshRemoteConfig) => {
|
||||
const result = await testConnection(config);
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[config.id]: {
|
||||
success: result.success,
|
||||
message: result.success
|
||||
? `Connected to ${result.result?.remoteInfo?.hostname || config.host}`
|
||||
: result.error || 'Connection failed',
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle save from modal
|
||||
const handleSave = async (config: Partial<SshRemoteConfig>) => {
|
||||
const result = await saveConfig(config);
|
||||
if (result.success) {
|
||||
setShowModal(false);
|
||||
setEditingConfig(undefined);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Handle test from modal
|
||||
const handleTestFromModal = async (config: SshRemoteConfig) => {
|
||||
return await testConnection(config);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 p-4 rounded-xl border"
|
||||
style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border }}
|
||||
>
|
||||
<Loader2 className="w-5 h-5 animate-spin" style={{ color: theme.colors.accent }} />
|
||||
<span className="text-sm" style={{ color: theme.colors.textDim }}>
|
||||
Loading SSH remotes...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-start gap-3 p-4 rounded-xl border relative"
|
||||
style={{ backgroundColor: theme.colors.bgMain, borderColor: theme.colors.border }}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="p-2 rounded-lg flex-shrink-0"
|
||||
style={{ backgroundColor: theme.colors.accent + '20' }}
|
||||
>
|
||||
<Server className="w-5 h-5" style={{ color: theme.colors.accent }} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] uppercase font-bold opacity-50 mb-1">Remote Execution</p>
|
||||
<p className="font-semibold mb-1">SSH Remote Hosts</p>
|
||||
<p className="text-xs opacity-60 mb-3">
|
||||
Configure remote hosts where AI agents can be executed via SSH. This allows running
|
||||
agents on powerful remote machines or servers with specific tools installed.
|
||||
</p>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div
|
||||
className="p-2 rounded text-xs flex items-start gap-2 mb-3"
|
||||
style={{
|
||||
backgroundColor: theme.colors.error + '20',
|
||||
color: theme.colors.error,
|
||||
}}
|
||||
>
|
||||
<XCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote List */}
|
||||
{configs.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{configs.map((config) => {
|
||||
const isDefault = config.id === defaultId;
|
||||
const isTesting = testingConfigId === config.id;
|
||||
const isDeleting = deletingId === config.id;
|
||||
const testResult = testResults[config.id];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={config.id}
|
||||
className={`p-3 rounded border transition-all ${
|
||||
isDefault ? 'ring-2' : ''
|
||||
} ${!config.enabled ? 'opacity-50' : ''}`}
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
'--tw-ring-color': theme.colors.accent,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{/* Remote Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="font-medium truncate"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
{config.name}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-bold uppercase"
|
||||
style={{
|
||||
backgroundColor: theme.colors.accent + '30',
|
||||
color: theme.colors.accent,
|
||||
}}
|
||||
>
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
{!config.enabled && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-bold uppercase"
|
||||
style={{
|
||||
backgroundColor: theme.colors.warning + '30',
|
||||
color: theme.colors.warning,
|
||||
}}
|
||||
>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs font-mono truncate"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
{config.username}@{config.host}:{config.port}
|
||||
</div>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div
|
||||
className="mt-2 text-xs flex items-center gap-1"
|
||||
style={{
|
||||
color: testResult.success
|
||||
? theme.colors.success
|
||||
: theme.colors.error,
|
||||
}}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
) : (
|
||||
<XCircle className="w-3 h-3" />
|
||||
)}
|
||||
<span className="truncate">{testResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Test Connection */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestFromList(config)}
|
||||
disabled={isTesting || !config.enabled}
|
||||
className="p-1.5 rounded hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Test connection"
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : config.enabled ? (
|
||||
<Wifi className="w-4 h-4" />
|
||||
) : (
|
||||
<WifiOff className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Set as Default */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetDefault(config.id)}
|
||||
disabled={!config.enabled}
|
||||
className={`p-1.5 rounded transition-colors disabled:opacity-50 ${
|
||||
isDefault ? '' : 'hover:bg-white/10'
|
||||
}`}
|
||||
style={{
|
||||
color: isDefault ? theme.colors.accent : theme.colors.textDim,
|
||||
}}
|
||||
title={isDefault ? 'Remove as default' : 'Set as default'}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Edit */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEdit(config)}
|
||||
className="p-1.5 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(config.id)}
|
||||
disabled={isDeleting}
|
||||
className="p-1.5 rounded hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||
style={{ color: theme.colors.error }}
|
||||
title="Delete"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{configs.length === 0 && (
|
||||
<div
|
||||
className="p-4 rounded border border-dashed text-center mb-3"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
>
|
||||
<Server
|
||||
className="w-8 h-8 mx-auto mb-2 opacity-30"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
/>
|
||||
<p className="text-sm" style={{ color: theme.colors.textDim }}>
|
||||
No SSH remotes configured
|
||||
</p>
|
||||
<p className="text-xs opacity-60 mt-1" style={{ color: theme.colors.textDim }}>
|
||||
Add a remote host to run AI agents on external machines
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddNew}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: theme.colors.accent,
|
||||
color: theme.colors.bgMain,
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add SSH Remote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<SshRemoteModal
|
||||
theme={theme}
|
||||
isOpen={showModal}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingConfig(undefined);
|
||||
}}
|
||||
onSave={handleSave}
|
||||
onTestConnection={handleTestFromModal}
|
||||
initialConfig={editingConfig}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SshRemotesSection;
|
||||
12
src/renderer/components/Settings/index.ts
Normal file
12
src/renderer/components/Settings/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Settings Components
|
||||
*
|
||||
* Components for the Settings modal and its sub-sections.
|
||||
*/
|
||||
|
||||
// SSH Remote configuration
|
||||
export { SshRemoteModal } from './SshRemoteModal';
|
||||
export type { SshRemoteModalProps } from './SshRemoteModal';
|
||||
|
||||
export { SshRemotesSection } from './SshRemotesSection';
|
||||
export type { SshRemotesSectionProps } from './SshRemotesSection';
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, memo } from 'react';
|
||||
import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Cpu, Settings, Palette, Sparkles, History, Download, Bug, Cloud, FolderSync, RotateCcw, Folder, ChevronDown, Plus, Trash2, Brain, AlertTriangle, FlaskConical, Database } from 'lucide-react';
|
||||
import { X, Key, Moon, Sun, Keyboard, Check, Terminal, Bell, Cpu, Settings, Palette, Sparkles, History, Download, Bug, Cloud, FolderSync, RotateCcw, Folder, ChevronDown, Plus, Trash2, Brain, AlertTriangle, FlaskConical, Database, Server } from 'lucide-react';
|
||||
import { useSettings } from '../hooks';
|
||||
import type { Theme, ThemeColors, ThemeId, Shortcut, ShellInfo, CustomAICommand, LLMProvider } from '../types';
|
||||
import { CustomThemeBuilder } from './CustomThemeBuilder';
|
||||
@@ -13,6 +13,7 @@ import { ToggleButtonGroup } from './ToggleButtonGroup';
|
||||
import { SettingCheckbox } from './SettingCheckbox';
|
||||
import { FontConfigurationPanel } from './FontConfigurationPanel';
|
||||
import { NotificationsPanel } from './NotificationsPanel';
|
||||
import { SshRemotesSection } from './Settings/SshRemotesSection';
|
||||
|
||||
// Feature flags - set to true to enable dormant features
|
||||
const FEATURE_FLAGS = {
|
||||
@@ -219,7 +220,7 @@ interface SettingsModalProps {
|
||||
setCrashReportingEnabled: (value: boolean) => void;
|
||||
customAICommands: CustomAICommand[];
|
||||
setCustomAICommands: (commands: CustomAICommand[]) => void;
|
||||
initialTab?: 'general' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands';
|
||||
initialTab?: 'general' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh';
|
||||
hasNoAgents?: boolean;
|
||||
onThemeImportError?: (message: string) => void;
|
||||
onThemeImportSuccess?: (message: string) => void;
|
||||
@@ -246,7 +247,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
setDefaultStatsTimeRange,
|
||||
} = useSettings();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh'>('general');
|
||||
const [systemFonts, setSystemFonts] = useState<string[]>([]);
|
||||
const [customFonts, setCustomFonts] = useState<string[]>([]);
|
||||
const [fontLoading, setFontLoading] = useState(false);
|
||||
@@ -374,9 +375,9 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleTabNavigation = (e: KeyboardEvent) => {
|
||||
const tabs: Array<'general' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands'> = FEATURE_FLAGS.LLM_SETTINGS
|
||||
? ['general', 'llm', 'shortcuts', 'theme', 'notifications', 'aicommands']
|
||||
: ['general', 'shortcuts', 'theme', 'notifications', 'aicommands'];
|
||||
const tabs: Array<'general' | 'llm' | 'shortcuts' | 'theme' | 'notifications' | 'aicommands' | 'ssh'> = FEATURE_FLAGS.LLM_SETTINGS
|
||||
? ['general', 'llm', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh']
|
||||
: ['general', 'shortcuts', 'theme', 'notifications', 'aicommands', 'ssh'];
|
||||
const currentIndex = tabs.indexOf(activeTab);
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === '[') {
|
||||
@@ -763,6 +764,10 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
<Cpu className="w-4 h-4" />
|
||||
{activeTab === 'aicommands' && <span>AI Commands</span>}
|
||||
</button>
|
||||
<button onClick={() => setActiveTab('ssh')} className={`px-4 py-4 text-sm font-bold border-b-2 ${activeTab === 'ssh' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`} tabIndex={-1} title="SSH">
|
||||
<Server className="w-4 h-4" />
|
||||
{activeTab === 'ssh' && <span>SSH</span>}
|
||||
</button>
|
||||
<div className="flex-1 flex justify-end items-center pr-4">
|
||||
<button onClick={onClose} tabIndex={-1}><X className="w-5 h-5 opacity-50 hover:opacity-100" /></button>
|
||||
</div>
|
||||
@@ -1984,6 +1989,12 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
<OpenSpecCommandsPanel theme={theme} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'ssh' && (
|
||||
<div className="space-y-5">
|
||||
<SshRemotesSection theme={theme} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
*/
|
||||
|
||||
import { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import { RefreshCw, Plus, Trash2, HelpCircle, ChevronDown } from 'lucide-react';
|
||||
import { RefreshCw, Plus, Trash2, HelpCircle, ChevronDown, Monitor, Cloud } from 'lucide-react';
|
||||
import type { Theme, AgentConfig, AgentConfigOption } from '../../types';
|
||||
import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../../shared/types';
|
||||
|
||||
// Counter for generating stable IDs for env vars
|
||||
let envVarIdCounter = 0;
|
||||
@@ -255,6 +256,11 @@ export interface AgentConfigPanelProps {
|
||||
compact?: boolean;
|
||||
// Show built-in environment variables section
|
||||
showBuiltInEnvVars?: boolean;
|
||||
// SSH Remote configuration (optional - only shown when provided)
|
||||
sshRemotes?: SshRemoteConfig[];
|
||||
sshRemoteConfig?: AgentSshRemoteConfig;
|
||||
onSshRemoteConfigChange?: (config: AgentSshRemoteConfig) => void;
|
||||
globalDefaultSshRemoteId?: string | null;
|
||||
}
|
||||
|
||||
export function AgentConfigPanel({
|
||||
@@ -284,6 +290,10 @@ export function AgentConfigPanel({
|
||||
refreshingAgent = false,
|
||||
compact = false,
|
||||
showBuiltInEnvVars = false,
|
||||
sshRemotes,
|
||||
sshRemoteConfig,
|
||||
onSshRemoteConfigChange,
|
||||
globalDefaultSshRemoteId,
|
||||
}: AgentConfigPanelProps): JSX.Element {
|
||||
const padding = compact ? 'p-2' : 'p-3';
|
||||
const spacing = compact ? 'space-y-2' : 'space-y-3';
|
||||
@@ -557,6 +567,133 @@ export function AgentConfigPanel({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* SSH Remote Configuration - only shown when props are provided */}
|
||||
{sshRemotes !== undefined && onSshRemoteConfigChange && (
|
||||
<div
|
||||
className={`${padding} rounded border`}
|
||||
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
|
||||
>
|
||||
<label className="block text-xs font-medium mb-2" style={{ color: theme.colors.textDim }}>
|
||||
SSH Remote Execution
|
||||
</label>
|
||||
|
||||
{/* SSH Remote Selection */}
|
||||
<div className="space-y-3">
|
||||
{/* Dropdown to select remote */}
|
||||
<div className="relative">
|
||||
<select
|
||||
value={
|
||||
sshRemoteConfig?.enabled === false
|
||||
? 'disabled'
|
||||
: sshRemoteConfig?.remoteId || 'default'
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === 'disabled') {
|
||||
// Explicitly disable SSH for this agent (run locally even if global default is set)
|
||||
onSshRemoteConfigChange({
|
||||
enabled: false,
|
||||
remoteId: null,
|
||||
});
|
||||
} else if (value === 'default') {
|
||||
// Use global default (or local if no global default)
|
||||
onSshRemoteConfigChange({
|
||||
enabled: true,
|
||||
remoteId: null,
|
||||
});
|
||||
} else {
|
||||
// Use specific remote
|
||||
onSshRemoteConfigChange({
|
||||
enabled: true,
|
||||
remoteId: value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full p-2 rounded border bg-transparent outline-none text-xs appearance-none cursor-pointer pr-8"
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
backgroundColor: theme.colors.bgMain,
|
||||
}}
|
||||
>
|
||||
<option value="default">
|
||||
{globalDefaultSshRemoteId
|
||||
? `Use Global Default (${sshRemotes.find(r => r.id === globalDefaultSshRemoteId)?.name || 'Unknown'})`
|
||||
: 'Local Execution (No SSH Remote)'}
|
||||
</option>
|
||||
<option value="disabled">Force Local Execution</option>
|
||||
{sshRemotes.filter(r => r.enabled).map((remote) => (
|
||||
<option key={remote.id} value={remote.id}>
|
||||
{remote.name} ({remote.host})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 pointer-events-none"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status indicator showing effective remote */}
|
||||
{(() => {
|
||||
const effectiveRemoteId = sshRemoteConfig?.enabled === false
|
||||
? null
|
||||
: sshRemoteConfig?.remoteId || globalDefaultSshRemoteId || null;
|
||||
const effectiveRemote = effectiveRemoteId
|
||||
? sshRemotes.find(r => r.id === effectiveRemoteId && r.enabled)
|
||||
: null;
|
||||
const isForceLocal = sshRemoteConfig?.enabled === false;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded text-xs"
|
||||
style={{ backgroundColor: theme.colors.bgActivity }}
|
||||
>
|
||||
{isForceLocal ? (
|
||||
<>
|
||||
<Monitor className="w-3 h-3" style={{ color: theme.colors.textDim }} />
|
||||
<span style={{ color: theme.colors.textDim }}>
|
||||
Agent will run locally (SSH disabled)
|
||||
</span>
|
||||
</>
|
||||
) : effectiveRemote ? (
|
||||
<>
|
||||
<Cloud className="w-3 h-3" style={{ color: theme.colors.success }} />
|
||||
<span style={{ color: theme.colors.textMain }}>
|
||||
Agent will run on <span className="font-medium">{effectiveRemote.name}</span>
|
||||
<span style={{ color: theme.colors.textDim }}> ({effectiveRemote.host})</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Monitor className="w-3 h-3" style={{ color: theme.colors.textDim }} />
|
||||
<span style={{ color: theme.colors.textDim }}>
|
||||
Agent will run locally
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* No remotes configured hint */}
|
||||
{sshRemotes.filter(r => r.enabled).length === 0 && (
|
||||
<p className="text-xs" style={{ color: theme.colors.textDim }}>
|
||||
No SSH remotes configured.{' '}
|
||||
<span style={{ color: theme.colors.accent }}>
|
||||
Configure remotes in Settings → SSH Remotes.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs opacity-50 mt-2">
|
||||
Execute this agent on a remote host via SSH instead of locally
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent-specific configuration options (contextWindow, model, etc.) */}
|
||||
{agent.configOptions && agent.configOptions.length > 0 && agent.configOptions.map((option: AgentConfigOption) => (
|
||||
<div
|
||||
|
||||
@@ -182,6 +182,9 @@ export const MODAL_PRIORITIES = {
|
||||
/** System log viewer overlay */
|
||||
LOG_VIEWER: 500,
|
||||
|
||||
/** SSH Remote configuration modal (above settings) */
|
||||
SSH_REMOTE: 460,
|
||||
|
||||
/** Settings modal */
|
||||
SETTINGS: 450,
|
||||
|
||||
|
||||
71
src/renderer/global.d.ts
vendored
71
src/renderer/global.d.ts
vendored
@@ -190,6 +190,7 @@ 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 } | null) => void) => () => void;
|
||||
onRemoteCommand: (callback: (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => void) => () => void;
|
||||
onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => () => void;
|
||||
onRemoteInterrupt: (callback: (sessionId: string) => void) => () => void;
|
||||
@@ -570,6 +571,76 @@ interface MaestroAPI {
|
||||
stop: () => Promise<{ success: boolean }>;
|
||||
getStatus: () => Promise<{ isRunning: boolean; url: string | null; error: string | null }>;
|
||||
};
|
||||
sshRemote: {
|
||||
saveConfig: (config: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
privateKeyPath?: string;
|
||||
remoteWorkingDir?: string;
|
||||
remoteEnv?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
config?: {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
privateKeyPath: string;
|
||||
remoteWorkingDir?: string;
|
||||
remoteEnv?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
deleteConfig: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getConfigs: () => Promise<{
|
||||
success: boolean;
|
||||
configs?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
privateKeyPath: string;
|
||||
remoteWorkingDir?: string;
|
||||
remoteEnv?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
error?: string;
|
||||
}>;
|
||||
getDefaultId: () => Promise<{ success: boolean; id?: string | null; error?: string }>;
|
||||
setDefaultId: (id: string | null) => Promise<{ success: boolean; error?: string }>;
|
||||
test: (
|
||||
configOrId: string | {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
privateKeyPath: string;
|
||||
remoteWorkingDir?: string;
|
||||
remoteEnv?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
},
|
||||
agentCommand?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
remoteInfo?: {
|
||||
hostname: string;
|
||||
agentVersion?: string;
|
||||
};
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
devtools: {
|
||||
open: () => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
|
||||
@@ -31,3 +31,7 @@ export type {
|
||||
UseCliActivityMonitoringDeps,
|
||||
UseCliActivityMonitoringReturn,
|
||||
} from './useCliActivityMonitoring';
|
||||
|
||||
// SSH remote configuration management
|
||||
export { useSshRemotes } from './useSshRemotes';
|
||||
export type { UseSshRemotesReturn } from './useSshRemotes';
|
||||
|
||||
272
src/renderer/hooks/remote/useSshRemotes.ts
Normal file
272
src/renderer/hooks/remote/useSshRemotes.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { SshRemoteConfig, SshRemoteTestResult } from '../../../shared/types';
|
||||
|
||||
/**
|
||||
* Return type for the useSshRemotes hook
|
||||
*/
|
||||
export interface UseSshRemotesReturn {
|
||||
// State
|
||||
/** List of all SSH remote configurations */
|
||||
configs: SshRemoteConfig[];
|
||||
/** ID of the default SSH remote (null if none) */
|
||||
defaultId: string | null;
|
||||
/** Whether the hook is currently loading initial data */
|
||||
loading: boolean;
|
||||
/** Error message if any operation failed */
|
||||
error: string | null;
|
||||
|
||||
// CRUD Operations
|
||||
/** Save (create or update) an SSH remote configuration */
|
||||
saveConfig: (config: Partial<SshRemoteConfig> & { id?: string }) => Promise<{
|
||||
success: boolean;
|
||||
config?: SshRemoteConfig;
|
||||
error?: string;
|
||||
}>;
|
||||
/** Delete an SSH remote configuration by ID */
|
||||
deleteConfig: (id: string) => Promise<{ success: boolean; error?: string }>;
|
||||
/** Refresh the list of configurations from the backend */
|
||||
refresh: () => Promise<void>;
|
||||
|
||||
// Default Management
|
||||
/** Set the default SSH remote ID */
|
||||
setDefaultId: (id: string | null) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Connection Testing
|
||||
/** Test an SSH connection (by ID or with full config) */
|
||||
testConnection: (
|
||||
configOrId: string | SshRemoteConfig,
|
||||
agentCommand?: string
|
||||
) => Promise<{ success: boolean; result?: SshRemoteTestResult; error?: string }>;
|
||||
/** ID of the config currently being tested (null if not testing) */
|
||||
testingConfigId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages SSH remote configurations for executing agents on remote hosts.
|
||||
*
|
||||
* Features:
|
||||
* - Loads and caches SSH remote configurations from backend
|
||||
* - Manages the global default SSH remote ID
|
||||
* - Provides CRUD operations (save/delete/refresh)
|
||||
* - Supports connection testing with loading state
|
||||
* - Handles errors gracefully with user-friendly messages
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const {
|
||||
* configs,
|
||||
* defaultId,
|
||||
* loading,
|
||||
* saveConfig,
|
||||
* deleteConfig,
|
||||
* setDefaultId,
|
||||
* testConnection,
|
||||
* } = useSshRemotes();
|
||||
* ```
|
||||
*
|
||||
* @returns Object containing SSH remote state and operations
|
||||
*/
|
||||
export function useSshRemotes(): UseSshRemotesReturn {
|
||||
// State
|
||||
const [configs, setConfigs] = useState<SshRemoteConfig[]>([]);
|
||||
const [defaultId, setDefaultIdState] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [testingConfigId, setTestingConfigId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Load configurations from backend
|
||||
*/
|
||||
const loadConfigs = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.maestro.sshRemote.getConfigs();
|
||||
if (result.success && result.configs) {
|
||||
setConfigs(result.configs);
|
||||
} else {
|
||||
setError(result.error || 'Failed to load SSH remote configurations');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useSshRemotes] Failed to load configs:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load SSH remote configurations');
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Load default ID from backend
|
||||
*/
|
||||
const loadDefaultId = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.maestro.sshRemote.getDefaultId();
|
||||
if (result.success) {
|
||||
setDefaultIdState(result.id ?? null);
|
||||
} else {
|
||||
console.error('[useSshRemotes] Failed to load default ID:', result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useSshRemotes] Failed to load default ID:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh all data from backend
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await Promise.all([loadConfigs(), loadDefaultId()]);
|
||||
setLoading(false);
|
||||
}, [loadConfigs, loadDefaultId]);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
/**
|
||||
* Save (create or update) an SSH remote configuration
|
||||
*/
|
||||
const saveConfig = useCallback(
|
||||
async (
|
||||
config: Partial<SshRemoteConfig> & { id?: string }
|
||||
): Promise<{ success: boolean; config?: SshRemoteConfig; error?: string }> => {
|
||||
try {
|
||||
const result = await window.maestro.sshRemote.saveConfig(config);
|
||||
if (result.success && result.config) {
|
||||
// Update local state
|
||||
setConfigs((prev) => {
|
||||
const index = prev.findIndex((c) => c.id === result.config!.id);
|
||||
if (index >= 0) {
|
||||
// Update existing
|
||||
const updated = [...prev];
|
||||
updated[index] = result.config!;
|
||||
return updated;
|
||||
} else {
|
||||
// Add new
|
||||
return [...prev, result.config!];
|
||||
}
|
||||
});
|
||||
setError(null);
|
||||
return { success: true, config: result.config };
|
||||
} else {
|
||||
const errorMsg = result.error || 'Failed to save SSH remote configuration';
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useSshRemotes] Failed to save config:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to save SSH remote configuration';
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete an SSH remote configuration by ID
|
||||
*/
|
||||
const deleteConfig = useCallback(
|
||||
async (id: string): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const result = await window.maestro.sshRemote.deleteConfig(id);
|
||||
if (result.success) {
|
||||
// Update local state
|
||||
setConfigs((prev) => prev.filter((c) => c.id !== id));
|
||||
// If deleted config was the default, update default state
|
||||
if (defaultId === id) {
|
||||
setDefaultIdState(null);
|
||||
}
|
||||
setError(null);
|
||||
return { success: true };
|
||||
} else {
|
||||
const errorMsg = result.error || 'Failed to delete SSH remote configuration';
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useSshRemotes] Failed to delete config:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to delete SSH remote configuration';
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
},
|
||||
[defaultId]
|
||||
);
|
||||
|
||||
/**
|
||||
* Set the default SSH remote ID
|
||||
*/
|
||||
const setDefaultId = useCallback(
|
||||
async (id: string | null): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const result = await window.maestro.sshRemote.setDefaultId(id);
|
||||
if (result.success) {
|
||||
setDefaultIdState(id);
|
||||
setError(null);
|
||||
return { success: true };
|
||||
} else {
|
||||
const errorMsg = result.error || 'Failed to set default SSH remote';
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useSshRemotes] Failed to set default ID:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to set default SSH remote';
|
||||
setError(errorMsg);
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Test an SSH connection
|
||||
*/
|
||||
const testConnection = useCallback(
|
||||
async (
|
||||
configOrId: string | SshRemoteConfig,
|
||||
agentCommand?: string
|
||||
): Promise<{ success: boolean; result?: SshRemoteTestResult; error?: string }> => {
|
||||
// Determine the config ID for testing state
|
||||
const testId = typeof configOrId === 'string' ? configOrId : configOrId.id;
|
||||
setTestingConfigId(testId);
|
||||
|
||||
try {
|
||||
const result = await window.maestro.sshRemote.test(configOrId, agentCommand);
|
||||
setTestingConfigId(null);
|
||||
|
||||
if (result.success && result.result) {
|
||||
return { success: true, result: result.result };
|
||||
} else {
|
||||
return { success: false, error: result.error || 'Connection test failed' };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useSshRemotes] Failed to test connection:', err);
|
||||
setTestingConfigId(null);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Connection test failed';
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
configs,
|
||||
defaultId,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// CRUD Operations
|
||||
saveConfig,
|
||||
deleteConfig,
|
||||
refresh,
|
||||
|
||||
// Default Management
|
||||
setDefaultId,
|
||||
|
||||
// Connection Testing
|
||||
testConnection,
|
||||
testingConfigId,
|
||||
};
|
||||
}
|
||||
@@ -20,11 +20,25 @@ export interface ProcessSessionIdHandler {
|
||||
(sessionId: string, agentSessionId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from process spawn operation.
|
||||
* Includes SSH remote info when the agent is executed on a remote host.
|
||||
*/
|
||||
export interface ProcessSpawnResult {
|
||||
pid: number;
|
||||
success: boolean;
|
||||
sshRemote?: {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const processService = {
|
||||
/**
|
||||
* Spawn a new process
|
||||
*/
|
||||
spawn: (config: ProcessConfig): Promise<{ pid: number; success: boolean }> =>
|
||||
spawn: (config: ProcessConfig): Promise<ProcessSpawnResult> =>
|
||||
createIpcMethod({
|
||||
call: () => window.maestro.process.spawn(config),
|
||||
errorContext: 'Process spawn',
|
||||
|
||||
@@ -469,6 +469,14 @@ export interface Session {
|
||||
// When true, new messages are blocked until the error is resolved
|
||||
agentErrorPaused?: boolean;
|
||||
|
||||
// SSH Remote execution status
|
||||
// Tracks the SSH remote being used for this session's agent execution
|
||||
sshRemote?: {
|
||||
id: string; // SSH remote config ID
|
||||
name: string; // Display name for UI
|
||||
host: string; // Remote host for tooltip
|
||||
};
|
||||
|
||||
// Per-session agent configuration overrides
|
||||
// These override the global agent-level settings for this specific session
|
||||
customPath?: string; // Custom path to agent binary (overrides agent-level)
|
||||
|
||||
@@ -219,3 +219,86 @@ export {
|
||||
MarketplaceCacheError,
|
||||
MarketplaceImportError,
|
||||
} from './marketplace-types';
|
||||
|
||||
// ============================================================================
|
||||
// SSH Remote Execution Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configuration for an SSH remote host where agents can be executed.
|
||||
* Supports key-based authentication only (no password auth).
|
||||
*/
|
||||
export interface SshRemoteConfig {
|
||||
/** Unique identifier for this remote configuration */
|
||||
id: string;
|
||||
|
||||
/** Display name for UI */
|
||||
name: string;
|
||||
|
||||
/** SSH server hostname or IP */
|
||||
host: string;
|
||||
|
||||
/** SSH server port (default: 22) */
|
||||
port: number;
|
||||
|
||||
/** SSH username */
|
||||
username: string;
|
||||
|
||||
/** Path to private key file (required, no password auth) */
|
||||
privateKeyPath: string;
|
||||
|
||||
/** Default working directory on remote (optional) */
|
||||
remoteWorkingDir?: string;
|
||||
|
||||
/** Environment variables to set on remote */
|
||||
remoteEnv?: Record<string, string>;
|
||||
|
||||
/** Enable this remote configuration */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of an SSH remote connection from last test.
|
||||
*/
|
||||
export interface SshRemoteStatus {
|
||||
/** Last connection test result */
|
||||
lastTestSuccess: boolean | null;
|
||||
|
||||
/** Last connection test timestamp */
|
||||
lastTestAt: number | null;
|
||||
|
||||
/** Error message from last test */
|
||||
lastTestError: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of testing an SSH remote connection.
|
||||
*/
|
||||
export interface SshRemoteTestResult {
|
||||
/** Whether the connection test succeeded */
|
||||
success: boolean;
|
||||
|
||||
/** Error message if test failed */
|
||||
error?: string;
|
||||
|
||||
/** Remote host info (hostname, agent version, etc.) */
|
||||
remoteInfo?: {
|
||||
hostname: string;
|
||||
agentVersion?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent-level SSH remote configuration.
|
||||
* Allows overriding the global default SSH remote for specific agents.
|
||||
*/
|
||||
export interface AgentSshRemoteConfig {
|
||||
/** Use SSH remote for this agent */
|
||||
enabled: boolean;
|
||||
|
||||
/** Remote config ID to use (references SshRemoteConfig.id) */
|
||||
remoteId: string | null;
|
||||
|
||||
/** Override working directory for this agent */
|
||||
workingDirOverride?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user