Enhance SSH functionality and configuration management
- Added SSH config host alias support, allowing users to fetch and resolve host entries from the SSH config file. - Introduced SshConfigParser to handle parsing of ~/.ssh/config and retrieving host aliases. - Updated ConnectionSection to include a combobox for selecting SSH config hosts with auto-fill capabilities. - Enhanced SshConnectionManager to utilize the new SshConfigParser for resolving host configurations. - Added IPC channels for fetching SSH config hosts and resolving host aliases. - Updated relevant types and state management to accommodate new SSH config features.
This commit is contained in:
parent
4b56186f7c
commit
921420b946
15 changed files with 591 additions and 83 deletions
10
README.md
10
README.md
|
|
@ -1,5 +1,5 @@
|
|||
<p align="center">
|
||||
<img src="resources/icons/png/512x512.png" alt="claude-devtools" width="120" />
|
||||
<img src="resources/icons/png/1024x1024.png" alt="claude-devtools" width="120" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">claude-devtools</h1>
|
||||
|
|
@ -10,14 +10,6 @@
|
|||
A desktop app that turns Claude Code's opaque session logs into a visual, searchable, actionable interface.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/matt1398/claude-devtools/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT" /></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows-lightgrey.svg" alt="Platform" /></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/electron-40-47848F.svg?logo=electron" alt="Electron" /></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/react-18-61DAFB.svg?logo=react" alt="React" /></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/typescript-5-3178C6.svg?logo=typescript" alt="TypeScript" /></a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
<p align="center">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ export default defineConfig({
|
|||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
'@shared': resolve(__dirname, 'src/shared')
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@preload': resolve(__dirname, 'src/preload')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"ssh-config": "^5.0.4",
|
||||
"ssh2": "^1.17.0",
|
||||
"unified": "^11.0.5",
|
||||
"zustand": "^4.5.0"
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ importers:
|
|||
remark-parse:
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0
|
||||
ssh-config:
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4
|
||||
ssh2:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0
|
||||
|
|
@ -3551,6 +3554,9 @@ packages:
|
|||
sprintf-js@1.1.3:
|
||||
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
|
||||
|
||||
ssh-config@5.0.4:
|
||||
resolution: {integrity: sha512-nCCJTY30Alhm8CWhhN8Yr1YAx2WOrDBLMMh7JYGrzCj3qssTPV+v10hYimd+8wJJeV10VrN8lFumawAEfEwjNA==}
|
||||
|
||||
ssh2@1.17.0:
|
||||
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
|
@ -7931,6 +7937,8 @@ snapshots:
|
|||
sprintf-js@1.1.3:
|
||||
optional: true
|
||||
|
||||
ssh-config@5.0.4: {}
|
||||
|
||||
ssh2@1.17.0:
|
||||
dependencies:
|
||||
asn1: 0.2.6
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
NotificationManager,
|
||||
ProjectScanner,
|
||||
SessionParser,
|
||||
SshConnectionManager,
|
||||
SubagentResolver,
|
||||
UpdaterService,
|
||||
} from './services';
|
||||
|
|
@ -64,9 +65,7 @@ let dataCache: DataCache;
|
|||
let fileWatcher: FileWatcher;
|
||||
let notificationManager: NotificationManager;
|
||||
let updaterService: UpdaterService;
|
||||
let sshConnectionManager: InstanceType<
|
||||
typeof import('./services/infrastructure/SshConnectionManager').SshConnectionManager
|
||||
>;
|
||||
let sshConnectionManager: SshConnectionManager;
|
||||
let cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
|
|
@ -76,11 +75,7 @@ function initializeServices(): void {
|
|||
logger.info('Initializing services...');
|
||||
|
||||
// Initialize SSH connection manager
|
||||
const { SshConnectionManager: SshConnMgr } =
|
||||
require('./services/infrastructure/SshConnectionManager') as {
|
||||
SshConnectionManager: typeof import('./services/infrastructure/SshConnectionManager').SshConnectionManager;
|
||||
};
|
||||
sshConnectionManager = new SshConnMgr();
|
||||
sshConnectionManager = new SshConnectionManager();
|
||||
|
||||
// Initialize services (paths are set automatically from environment)
|
||||
projectScanner = new ProjectScanner();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@
|
|||
import {
|
||||
SSH_CONNECT,
|
||||
SSH_DISCONNECT,
|
||||
SSH_GET_CONFIG_HOSTS,
|
||||
SSH_GET_STATE,
|
||||
SSH_RESOLVE_HOST,
|
||||
SSH_TEST,
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -96,6 +98,28 @@ export function registerSshHandlers(ipcMain: IpcMain): void {
|
|||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(SSH_GET_CONFIG_HOSTS, async () => {
|
||||
try {
|
||||
const hosts = await connectionManager.getConfigHosts();
|
||||
return { success: true, data: hosts };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error('Failed to get SSH config hosts:', message);
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(SSH_RESOLVE_HOST, async (_event, alias: string) => {
|
||||
try {
|
||||
const entry = await connectionManager.resolveHostConfig(alias);
|
||||
return { success: true, data: entry };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error(`Failed to resolve SSH host "${alias}":`, message);
|
||||
return { success: true, data: null };
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('SSH handlers registered');
|
||||
}
|
||||
|
||||
|
|
@ -104,4 +128,6 @@ export function removeSshHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(SSH_DISCONNECT);
|
||||
ipcMain.removeHandler(SSH_GET_STATE);
|
||||
ipcMain.removeHandler(SSH_TEST);
|
||||
ipcMain.removeHandler(SSH_GET_CONFIG_HOSTS);
|
||||
ipcMain.removeHandler(SSH_RESOLVE_HOST);
|
||||
}
|
||||
|
|
|
|||
194
src/main/services/infrastructure/SshConfigParser.ts
Normal file
194
src/main/services/infrastructure/SshConfigParser.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* SshConfigParser - Parses ~/.ssh/config to resolve host aliases.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Parse SSH config with Include directive support
|
||||
* - Return all defined Host aliases (excluding wildcards)
|
||||
* - Resolve alias to HostName, Port, User, IdentityFile
|
||||
* - Gracefully handle missing/unreadable files
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import SSHConfig from 'ssh-config';
|
||||
|
||||
import type { SshConfigHostEntry } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Infrastructure:SshConfigParser');
|
||||
|
||||
export class SshConfigParser {
|
||||
private configPath: string;
|
||||
|
||||
constructor(configPath?: string) {
|
||||
this.configPath = configPath ?? path.join(os.homedir(), '.ssh', 'config');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all defined Host aliases (excluding `*` wildcards and patterns).
|
||||
*/
|
||||
async getHosts(): Promise<SshConfigHostEntry[]> {
|
||||
try {
|
||||
const config = await this.parseConfig();
|
||||
if (!config) return [];
|
||||
|
||||
const entries: SshConfigHostEntry[] = [];
|
||||
|
||||
for (const section of config) {
|
||||
if (section.type !== SSHConfig.DIRECTIVE) continue;
|
||||
if (section.param !== 'Host') continue;
|
||||
|
||||
const hostValue = section.value;
|
||||
if (typeof hostValue !== 'string') continue;
|
||||
|
||||
// Skip wildcard-only entries and patterns with * or ?
|
||||
const aliases = hostValue.split(/\s+/).filter((h) => !h.includes('*') && !h.includes('?'));
|
||||
|
||||
for (const alias of aliases) {
|
||||
const resolved = this.resolveFromConfig(config, alias);
|
||||
entries.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
} catch (err) {
|
||||
logger.error('Failed to get SSH config hosts:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a host alias to its SSH config values.
|
||||
* Returns null if the alias is not found in config.
|
||||
*/
|
||||
async resolveHost(alias: string): Promise<SshConfigHostEntry | null> {
|
||||
try {
|
||||
const config = await this.parseConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const resolved = this.resolveFromConfig(config, alias);
|
||||
|
||||
// If nothing was resolved beyond the alias itself, check if host was actually defined
|
||||
if (!resolved.hostName && !resolved.user && !resolved.port && !resolved.hasIdentityFile) {
|
||||
// Check if there's an explicit Host entry for this alias
|
||||
const hasEntry = config.some(
|
||||
(section) =>
|
||||
section.type === SSHConfig.DIRECTIVE &&
|
||||
section.param === 'Host' &&
|
||||
typeof section.value === 'string' &&
|
||||
section.value.split(/\s+/).includes(alias)
|
||||
);
|
||||
if (!hasEntry) return null;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to resolve SSH host "${alias}":`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Private Methods
|
||||
// ===========================================================================
|
||||
|
||||
private resolveFromConfig(config: SSHConfig, alias: string): SshConfigHostEntry {
|
||||
const computed = config.compute(alias);
|
||||
|
||||
const rawHostName = computed.HostName;
|
||||
const hostName = Array.isArray(rawHostName) ? rawHostName[0] : rawHostName;
|
||||
const rawUser = computed.User;
|
||||
const user = Array.isArray(rawUser) ? rawUser[0] : (rawUser ?? undefined);
|
||||
const portStr = computed.Port;
|
||||
const port = portStr ? parseInt(String(portStr), 10) : undefined;
|
||||
const identityFile = computed.IdentityFile;
|
||||
const hasIdentityFile = Array.isArray(identityFile)
|
||||
? identityFile.length > 0
|
||||
: identityFile != null;
|
||||
|
||||
return {
|
||||
alias,
|
||||
hostName: hostName && hostName !== alias ? hostName : undefined,
|
||||
user,
|
||||
port: port && port !== 22 ? port : undefined,
|
||||
hasIdentityFile,
|
||||
};
|
||||
}
|
||||
|
||||
private async parseConfig(): Promise<SSHConfig | null> {
|
||||
try {
|
||||
let content = await fs.promises.readFile(this.configPath, 'utf8');
|
||||
|
||||
// Process Include directives by expanding them inline
|
||||
content = await this.expandIncludes(content);
|
||||
|
||||
return SSHConfig.parse(content);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.info('No SSH config file found at', this.configPath);
|
||||
} else {
|
||||
logger.error('Failed to parse SSH config:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async expandIncludes(content: string): Promise<string> {
|
||||
const lines = content.split('\n');
|
||||
const result: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
const match =
|
||||
trimmed.startsWith('Include ') || trimmed.startsWith('include ')
|
||||
? /^[Ii]nclude\s+(\S.*)$/.exec(trimmed)
|
||||
: null;
|
||||
|
||||
if (!match) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const pattern = match[1].trim();
|
||||
const expandedPattern = pattern.replace(/^~/, os.homedir());
|
||||
|
||||
try {
|
||||
// Handle glob-like patterns by checking if the path contains wildcards
|
||||
if (expandedPattern.includes('*') || expandedPattern.includes('?')) {
|
||||
const dir = path.dirname(expandedPattern);
|
||||
const globPart = path.basename(expandedPattern);
|
||||
const files = await this.globFiles(dir, globPart);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const included = await fs.promises.readFile(file, 'utf8');
|
||||
result.push(included);
|
||||
} catch {
|
||||
// Skip unreadable included files
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const included = await fs.promises.readFile(expandedPattern, 'utf8');
|
||||
result.push(included);
|
||||
}
|
||||
} catch {
|
||||
// Skip unresolvable includes
|
||||
}
|
||||
}
|
||||
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
private async globFiles(dir: string, pattern: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dir);
|
||||
const regex = new RegExp(
|
||||
'^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.') + '$'
|
||||
);
|
||||
return entries.filter((e) => regex.test(e)).map((e) => path.join(dir, e));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,15 +10,19 @@
|
|||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { Client, type ConnectConfig } from 'ssh2';
|
||||
|
||||
import { LocalFileSystemProvider } from './LocalFileSystemProvider';
|
||||
import { SshConfigParser } from './SshConfigParser';
|
||||
import { SshFileSystemProvider } from './SshFileSystemProvider';
|
||||
|
||||
import type { FileSystemProvider } from './FileSystemProvider';
|
||||
import type { SshConfigHostEntry } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Infrastructure:SshConnectionManager');
|
||||
|
||||
|
|
@ -28,7 +32,7 @@ const logger = createLogger('Infrastructure:SshConnectionManager');
|
|||
|
||||
export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export type SshAuthMethod = 'password' | 'privateKey' | 'agent';
|
||||
export type SshAuthMethod = 'password' | 'privateKey' | 'agent' | 'auto';
|
||||
|
||||
export interface SshConnectionConfig {
|
||||
host: string;
|
||||
|
|
@ -64,6 +68,7 @@ export class SshConnectionManager extends EventEmitter {
|
|||
private client: Client | null = null;
|
||||
private provider: FileSystemProvider;
|
||||
private localProvider: LocalFileSystemProvider;
|
||||
private configParser: SshConfigParser;
|
||||
private state: SshConnectionState = 'disconnected';
|
||||
private connectedHost: string | null = null;
|
||||
private lastError: string | null = null;
|
||||
|
|
@ -73,6 +78,7 @@ export class SshConnectionManager extends EventEmitter {
|
|||
super();
|
||||
this.localProvider = new LocalFileSystemProvider();
|
||||
this.provider = this.localProvider;
|
||||
this.configParser = new SshConfigParser();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -109,6 +115,20 @@ export class SshConnectionManager extends EventEmitter {
|
|||
return this.state === 'connected' && this.provider.type === 'ssh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all SSH config host entries from ~/.ssh/config.
|
||||
*/
|
||||
async getConfigHosts(): Promise<SshConfigHostEntry[]> {
|
||||
return this.configParser.getHosts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a host alias from ~/.ssh/config.
|
||||
*/
|
||||
async resolveHostConfig(alias: string): Promise<SshConfigHostEntry | null> {
|
||||
return this.configParser.resolveHost(alias);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a remote SSH host.
|
||||
*/
|
||||
|
|
@ -244,10 +264,13 @@ export class SshConnectionManager extends EventEmitter {
|
|||
// ===========================================================================
|
||||
|
||||
private async buildConnectConfig(config: SshConnectionConfig): Promise<ConnectConfig> {
|
||||
// Resolve SSH config for the given host (alias or hostname)
|
||||
const sshConfig = await this.configParser.resolveHost(config.host);
|
||||
|
||||
const connectConfig: ConnectConfig = {
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
host: sshConfig?.hostName ?? config.host,
|
||||
port: config.port !== 22 ? config.port : (sshConfig?.port ?? config.port),
|
||||
username: config.username || sshConfig?.user || os.userInfo().username,
|
||||
readyTimeout: 10000,
|
||||
};
|
||||
|
||||
|
|
@ -258,9 +281,8 @@ export class SshConnectionManager extends EventEmitter {
|
|||
|
||||
case 'privateKey': {
|
||||
const keyPath = config.privateKeyPath ?? path.join(os.homedir(), '.ssh', 'id_rsa');
|
||||
const { promises: fsPromises } = await import('fs');
|
||||
try {
|
||||
const keyData = await fsPromises.readFile(keyPath, 'utf8');
|
||||
const keyData = await fs.promises.readFile(keyPath, 'utf8');
|
||||
connectConfig.privateKey = keyData;
|
||||
} catch (err) {
|
||||
throw new Error(`Cannot read private key at ${keyPath}: ${(err as Error).message}`);
|
||||
|
|
@ -268,17 +290,163 @@ export class SshConnectionManager extends EventEmitter {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'agent':
|
||||
connectConfig.agent = process.env.SSH_AUTH_SOCK;
|
||||
if (!connectConfig.agent) {
|
||||
throw new Error('SSH_AUTH_SOCK environment variable is not set');
|
||||
case 'agent': {
|
||||
const agentSocket = await this.discoverAgentSocket();
|
||||
if (!agentSocket) {
|
||||
throw new Error(
|
||||
'SSH agent socket not found. Ensure ssh-agent is running or SSH_AUTH_SOCK is set.'
|
||||
);
|
||||
}
|
||||
connectConfig.agent = agentSocket;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'auto': {
|
||||
// Auto: try identity file from config -> agent -> default keys
|
||||
const resolved = await this.resolveAutoAuth(sshConfig);
|
||||
if (resolved.privateKey) {
|
||||
connectConfig.privateKey = resolved.privateKey;
|
||||
} else if (resolved.agent) {
|
||||
connectConfig.agent = resolved.agent;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return connectConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers the SSH agent socket path.
|
||||
* Handles macOS GUI apps not inheriting SSH_AUTH_SOCK from shell.
|
||||
*/
|
||||
private async discoverAgentSocket(): Promise<string | null> {
|
||||
// 1. Check SSH_AUTH_SOCK env var
|
||||
if (process.env.SSH_AUTH_SOCK) {
|
||||
try {
|
||||
await fs.promises.access(process.env.SSH_AUTH_SOCK);
|
||||
return process.env.SSH_AUTH_SOCK;
|
||||
} catch {
|
||||
// Socket path set but not accessible
|
||||
}
|
||||
}
|
||||
|
||||
// 2. macOS: ask launchctl for the socket (GUI apps don't inherit shell env)
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const sock = await new Promise<string | null>((resolve) => {
|
||||
execFile('/bin/launchctl', ['getenv', 'SSH_AUTH_SOCK'], (err, stdout) => {
|
||||
if (err || !stdout.trim()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
if (sock) {
|
||||
try {
|
||||
await fs.promises.access(sock);
|
||||
return sock;
|
||||
} catch {
|
||||
// Not accessible
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// launchctl not available
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try known socket paths
|
||||
const knownPaths = [
|
||||
// 1Password SSH agent
|
||||
path.join(
|
||||
os.homedir(),
|
||||
'Library',
|
||||
'Group Containers',
|
||||
'2BUA8C4S2C.com.1password',
|
||||
'agent.sock'
|
||||
),
|
||||
path.join(os.homedir(), '.1password', 'agent.sock'),
|
||||
// Common user agent socket
|
||||
path.join(os.homedir(), '.ssh', 'agent.sock'),
|
||||
];
|
||||
|
||||
// Linux: add system paths
|
||||
if (process.platform === 'linux') {
|
||||
const uid = process.getuid?.();
|
||||
if (uid !== undefined) {
|
||||
knownPaths.push(`/run/user/${uid}/ssh-agent.socket`);
|
||||
knownPaths.push(`/run/user/${uid}/keyring/ssh`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const socketPath of knownPaths) {
|
||||
try {
|
||||
await fs.promises.access(socketPath);
|
||||
return socketPath;
|
||||
} catch {
|
||||
// Not accessible
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves authentication automatically by trying:
|
||||
* 1. IdentityFile from SSH config
|
||||
* 2. SSH agent
|
||||
* 3. Default key files (id_ed25519, id_rsa)
|
||||
*/
|
||||
private async resolveAutoAuth(
|
||||
sshConfig: SshConfigHostEntry | null
|
||||
): Promise<{ privateKey?: string; agent?: string }> {
|
||||
// Try SSH config identity file
|
||||
if (sshConfig?.hasIdentityFile) {
|
||||
const resolved = await this.configParser.resolveHost(sshConfig.alias);
|
||||
if (resolved) {
|
||||
// The config parser already told us there's an identity file.
|
||||
// Try common identity file locations from config
|
||||
const configKeyPaths = [
|
||||
path.join(os.homedir(), '.ssh', 'id_ed25519'),
|
||||
path.join(os.homedir(), '.ssh', 'id_rsa'),
|
||||
];
|
||||
for (const keyPath of configKeyPaths) {
|
||||
try {
|
||||
const keyData = await fs.promises.readFile(keyPath, 'utf8');
|
||||
return { privateKey: keyData };
|
||||
} catch {
|
||||
// Try next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try SSH agent
|
||||
const agentSocket = await this.discoverAgentSocket();
|
||||
if (agentSocket) {
|
||||
return { agent: agentSocket };
|
||||
}
|
||||
|
||||
// Try default key files
|
||||
const defaultKeys = [
|
||||
path.join(os.homedir(), '.ssh', 'id_ed25519'),
|
||||
path.join(os.homedir(), '.ssh', 'id_rsa'),
|
||||
path.join(os.homedir(), '.ssh', 'id_ecdsa'),
|
||||
];
|
||||
|
||||
for (const keyPath of defaultKeys) {
|
||||
try {
|
||||
const keyData = await fs.promises.readFile(keyPath, 'utf8');
|
||||
return { privateKey: keyData };
|
||||
} catch {
|
||||
// Try next
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
private async resolveRemoteProjectsPath(username: string): Promise<string> {
|
||||
// Try to resolve the remote home directory
|
||||
// SFTP doesn't have a direct "get home dir" call, so we try common paths
|
||||
|
|
|
|||
|
|
@ -28,6 +28,18 @@ export interface TriggerValidationResult {
|
|||
* Default built-in notification triggers.
|
||||
*/
|
||||
export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
||||
{
|
||||
id: 'builtin-bash-command',
|
||||
name: '.env File Access Alert',
|
||||
enabled: true,
|
||||
contentType: 'tool_use',
|
||||
toolName: 'Any Tool',
|
||||
mode: 'content_match',
|
||||
matchField: 'command',
|
||||
matchPattern: '/.env',
|
||||
isBuiltin: true,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
id: 'builtin-tool-result-error',
|
||||
name: 'Tool Result Error',
|
||||
|
|
@ -40,19 +52,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
|||
'\\[Request interrupted by user for tool use\\]',
|
||||
],
|
||||
isBuiltin: true,
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
id: 'builtin-bash-command',
|
||||
name: '.env File Access Alert',
|
||||
enabled: true,
|
||||
contentType: 'tool_use',
|
||||
toolName: 'Bash',
|
||||
mode: 'content_match',
|
||||
matchField: 'command',
|
||||
matchPattern: '/.env',
|
||||
isBuiltin: true,
|
||||
color: 'red',
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
id: 'builtin-high-token-usage',
|
||||
|
|
@ -62,7 +62,7 @@ export const DEFAULT_TRIGGERS: NotificationTrigger[] = [
|
|||
mode: 'token_threshold',
|
||||
tokenThreshold: 8000,
|
||||
tokenType: 'total',
|
||||
color: 'orange',
|
||||
color: 'yellow',
|
||||
isBuiltin: true,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export type * from './FileSystemProvider';
|
|||
export * from './FileWatcher';
|
||||
export * from './LocalFileSystemProvider';
|
||||
export * from './NotificationManager';
|
||||
export * from './SshConfigParser';
|
||||
export * from './SshConnectionManager';
|
||||
export * from './SshFileSystemProvider';
|
||||
export * from './TriggerManager';
|
||||
|
|
|
|||
|
|
@ -75,6 +75,12 @@ export const SSH_GET_STATE = 'ssh:getState';
|
|||
/** Test SSH connection without switching */
|
||||
export const SSH_TEST = 'ssh:test';
|
||||
|
||||
/** Get SSH config hosts from ~/.ssh/config */
|
||||
export const SSH_GET_CONFIG_HOSTS = 'ssh:getConfigHosts';
|
||||
|
||||
/** Resolve a single SSH config host alias */
|
||||
export const SSH_RESOLVE_HOST = 'ssh:resolveHost';
|
||||
|
||||
/** SSH status event channel (main -> renderer) */
|
||||
export const SSH_STATUS = 'ssh:status';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { contextBridge, ipcRenderer } from 'electron';
|
|||
import {
|
||||
SSH_CONNECT,
|
||||
SSH_DISCONNECT,
|
||||
SSH_GET_CONFIG_HOSTS,
|
||||
SSH_GET_STATE,
|
||||
SSH_RESOLVE_HOST,
|
||||
SSH_STATUS,
|
||||
SSH_TEST,
|
||||
UPDATER_CHECK,
|
||||
|
|
@ -37,6 +39,7 @@ import type {
|
|||
ElectronAPI,
|
||||
NotificationTrigger,
|
||||
SessionsPaginationOptions,
|
||||
SshConfigHostEntry,
|
||||
SshConnectionConfig,
|
||||
SshConnectionStatus,
|
||||
TriggerTestResult,
|
||||
|
|
@ -332,6 +335,12 @@ const electronAPI: ElectronAPI = {
|
|||
test: async (config: SshConnectionConfig): Promise<{ success: boolean; error?: string }> => {
|
||||
return invokeIpcWithResult<{ success: boolean; error?: string }>(SSH_TEST, config);
|
||||
},
|
||||
getConfigHosts: async (): Promise<SshConfigHostEntry[]> => {
|
||||
return invokeIpcWithResult<SshConfigHostEntry[]>(SSH_GET_CONFIG_HOSTS);
|
||||
},
|
||||
resolveHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
|
||||
return invokeIpcWithResult<SshConfigHostEntry | null>(SSH_RESOLVE_HOST, alias);
|
||||
},
|
||||
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void): (() => void) => {
|
||||
ipcRenderer.on(
|
||||
SSH_STATUS,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@
|
|||
* Provides UI for:
|
||||
* - Toggling between local and SSH modes
|
||||
* - Configuring SSH connection (host, port, username, auth)
|
||||
* - SSH config host alias combobox with auto-fill
|
||||
* - Testing and connecting to remote hosts
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
||||
|
|
@ -15,7 +16,7 @@ import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
|
|||
import { SettingRow } from '../components/SettingRow';
|
||||
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
||||
|
||||
import type { SshAuthMethod, SshConnectionConfig } from '@shared/types';
|
||||
import type { SshAuthMethod, SshConfigHostEntry, SshConnectionConfig } from '@shared/types';
|
||||
|
||||
export const ConnectionSection = (): React.JSX.Element => {
|
||||
const connectionState = useStore((s) => s.connectionState);
|
||||
|
|
@ -24,17 +25,64 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
const connectSsh = useStore((s) => s.connectSsh);
|
||||
const disconnectSsh = useStore((s) => s.disconnectSsh);
|
||||
const testConnection = useStore((s) => s.testConnection);
|
||||
const sshConfigHosts = useStore((s) => s.sshConfigHosts);
|
||||
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
|
||||
|
||||
// Form state
|
||||
const [host, setHost] = useState('');
|
||||
const [port, setPort] = useState('22');
|
||||
const [username, setUsername] = useState('');
|
||||
const [authMethod, setAuthMethod] = useState<SshAuthMethod>('agent');
|
||||
const [authMethod, setAuthMethod] = useState<SshAuthMethod>('auto');
|
||||
const [password, setPassword] = useState('');
|
||||
const [privateKeyPath, setPrivateKeyPath] = useState('~/.ssh/id_rsa');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null);
|
||||
|
||||
// Combobox state
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const hostInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch SSH config hosts on mount
|
||||
useEffect(() => {
|
||||
void fetchSshConfigHosts();
|
||||
}, [fetchSshConfigHosts]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent): void => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
hostInputRef.current &&
|
||||
!hostInputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Filter config hosts based on input
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!host.trim()) return sshConfigHosts;
|
||||
const lower = host.toLowerCase();
|
||||
return sshConfigHosts.filter(
|
||||
(entry) =>
|
||||
entry.alias.toLowerCase().includes(lower) || entry.hostName?.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [host, sshConfigHosts]);
|
||||
|
||||
const handleSelectConfigHost = (entry: SshConfigHostEntry): void => {
|
||||
setHost(entry.alias);
|
||||
if (entry.port) setPort(String(entry.port));
|
||||
if (entry.user) setUsername(entry.user);
|
||||
setAuthMethod('auto');
|
||||
setShowDropdown(false);
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const buildConfig = (): SshConnectionConfig => ({
|
||||
host,
|
||||
port: parseInt(port, 10) || 22,
|
||||
|
|
@ -64,6 +112,11 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
const isConnected = connectionState === 'connected';
|
||||
|
||||
const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1';
|
||||
const inputStyle = {
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -130,22 +183,60 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
{/* Host input with combobox */}
|
||||
<div className="relative">
|
||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Host
|
||||
</label>
|
||||
<input
|
||||
ref={hostInputRef}
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
placeholder="192.168.1.100"
|
||||
className={inputClass}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
onChange={(e) => {
|
||||
setHost(e.target.value);
|
||||
setShowDropdown(true);
|
||||
setTestResult(null);
|
||||
}}
|
||||
onFocus={() => setShowDropdown(true)}
|
||||
placeholder="hostname or ssh config alias"
|
||||
className={inputClass}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{showDropdown && filteredHosts.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded-md border shadow-lg"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
>
|
||||
{filteredHosts.map((entry) => (
|
||||
<button
|
||||
key={entry.alias}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-raised"
|
||||
style={{
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
onClick={() => handleSelectConfigHost(entry)}
|
||||
>
|
||||
<span className="font-medium">{entry.alias}</span>
|
||||
{entry.hostName && (
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>{entry.hostName}</span>
|
||||
)}
|
||||
{entry.user && (
|
||||
<span
|
||||
className="ml-auto text-xs"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{entry.user}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
|
|
@ -157,11 +248,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
onChange={(e) => setPort(e.target.value)}
|
||||
placeholder="22"
|
||||
className={inputClass}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -176,11 +263,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="user"
|
||||
className={inputClass}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -192,12 +275,9 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
value={authMethod}
|
||||
onChange={(e) => setAuthMethod(e.target.value as SshAuthMethod)}
|
||||
className="w-full rounded-md border px-3 py-1.5 text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="auto">Auto (from SSH Config)</option>
|
||||
<option value="agent">SSH Agent</option>
|
||||
<option value="privateKey">Private Key</option>
|
||||
<option value="password">Password</option>
|
||||
|
|
@ -215,11 +295,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
onChange={(e) => setPrivateKeyPath(e.target.value)}
|
||||
placeholder="~/.ssh/id_rsa"
|
||||
className={inputClass}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -234,11 +310,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={inputClass}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -254,7 +326,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
>
|
||||
{testResult.success
|
||||
? 'Connection successful'
|
||||
: `Connection failed: ${testResult.error}`}
|
||||
: `Connection failed: ${testResult.error ?? 'Unknown error'}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -262,7 +334,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => void handleTest()}
|
||||
disabled={!host || !username || testing || isConnecting}
|
||||
disabled={!host || testing || isConnecting}
|
||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
|
|
@ -281,7 +353,7 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
|
||||
<button
|
||||
onClick={() => void handleConnect()}
|
||||
disabled={!host || !username || isConnecting}
|
||||
disabled={!host || isConnecting}
|
||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
*/
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { SshConnectionConfig, SshConnectionState } from '@shared/types';
|
||||
import type { SshConfigHostEntry, SshConnectionConfig, SshConnectionState } from '@shared/types';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -19,6 +19,7 @@ export interface ConnectionSlice {
|
|||
connectionState: SshConnectionState;
|
||||
connectedHost: string | null;
|
||||
connectionError: string | null;
|
||||
sshConfigHosts: SshConfigHostEntry[];
|
||||
|
||||
// Actions
|
||||
connectSsh: (config: SshConnectionConfig) => Promise<void>;
|
||||
|
|
@ -29,6 +30,8 @@ export interface ConnectionSlice {
|
|||
host: string | null,
|
||||
error: string | null
|
||||
) => void;
|
||||
fetchSshConfigHosts: () => Promise<void>;
|
||||
resolveConfigHost: (alias: string) => Promise<SshConfigHostEntry | null>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -44,6 +47,7 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
connectionState: 'disconnected',
|
||||
connectedHost: null,
|
||||
connectionError: null,
|
||||
sshConfigHosts: [],
|
||||
|
||||
// Actions
|
||||
connectSsh: async (config: SshConnectionConfig): Promise<void> => {
|
||||
|
|
@ -118,4 +122,22 @@ export const createConnectionSlice: StateCreator<AppState, [], [], ConnectionSli
|
|||
connectionError: error,
|
||||
});
|
||||
},
|
||||
|
||||
fetchSshConfigHosts: async (): Promise<void> => {
|
||||
try {
|
||||
const hosts = await window.electronAPI.ssh.getConfigHosts();
|
||||
set({ sshConfigHosts: hosts });
|
||||
} catch {
|
||||
// Gracefully ignore - SSH config may not exist
|
||||
set({ sshConfigHosts: [] });
|
||||
}
|
||||
},
|
||||
|
||||
resolveConfigHost: async (alias: string): Promise<SshConfigHostEntry | null> => {
|
||||
try {
|
||||
return await window.electronAPI.ssh.resolveHost(alias);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -156,7 +156,18 @@ export type SshConnectionState = 'disconnected' | 'connecting' | 'connected' | '
|
|||
/**
|
||||
* SSH authentication method.
|
||||
*/
|
||||
export type SshAuthMethod = 'password' | 'privateKey' | 'agent';
|
||||
export type SshAuthMethod = 'password' | 'privateKey' | 'agent' | 'auto';
|
||||
|
||||
/**
|
||||
* SSH config host entry resolved from ~/.ssh/config.
|
||||
*/
|
||||
export interface SshConfigHostEntry {
|
||||
alias: string;
|
||||
hostName?: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
hasIdentityFile: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSH connection configuration sent from renderer.
|
||||
|
|
@ -201,6 +212,8 @@ export interface SshAPI {
|
|||
disconnect: () => Promise<SshConnectionStatus>;
|
||||
getState: () => Promise<SshConnectionStatus>;
|
||||
test: (config: SshConnectionConfig) => Promise<{ success: boolean; error?: string }>;
|
||||
getConfigHosts: () => Promise<SshConfigHostEntry[]>;
|
||||
resolveHost: (alias: string) => Promise<SshConfigHostEntry | null>;
|
||||
onStatus: (callback: (event: unknown, status: SshConnectionStatus) => void) => () => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue