From b7614b25be2ec8f82cee4c247ff2cb9962136aeb Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 30 Jan 2026 17:42:17 -0500 Subject: [PATCH] fix(group-chat): add SSH remote execution support for moderator and participant spawns Group Chat was spawning agents locally even when sessions were configured for SSH remote execution. This fix: - Creates reusable SSH spawn wrapper utility (ssh-spawn-wrapper.ts) - Updates SessionInfo, SessionOverrides, and ModeratorConfig interfaces to include sshRemoteConfig - Applies SSH wrapping to addParticipant(), moderator spawn, and participant spawn in routeModeratorResponse() - Wires up SSH store in index.ts for group chat router - Documents SSH/provider awareness pattern in CLAUDE.md This ensures Group Chat respects SSH remote configuration just like regular session spawns. Closes issue #172 --- CLAUDE.md | 34 ++++ src/main/group-chat/group-chat-agent.ts | 58 ++++++- src/main/group-chat/group-chat-router.ts | 126 +++++++++++++-- src/main/index.ts | 7 + src/main/utils/ssh-spawn-wrapper.ts | 194 +++++++++++++++++++++++ src/shared/group-chat-types.ts | 6 + 6 files changed, 402 insertions(+), 23 deletions(-) create mode 100644 src/main/utils/ssh-spawn-wrapper.ts diff --git a/CLAUDE.md b/CLAUDE.md index ae50a109..6672eb56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,6 +167,40 @@ src/ | Add colorblind palette | `src/renderer/constants/colorblindPalettes.ts` | | Add performance metrics | `src/shared/performance-metrics.ts` | | Add power management | `src/main/power-manager.ts`, `src/main/ipc/handlers/system.ts` | +| Spawn agent with SSH support | `src/main/utils/ssh-spawn-wrapper.ts` (required for SSH remote execution) | + +--- + +## Critical Implementation Guidelines + +### SSH Remote Execution Awareness + +**IMPORTANT:** When implementing any feature that spawns agent processes (e.g., context grooming, group chat, batch operations), you MUST support SSH remote execution. + +Sessions can be configured to run on remote hosts via SSH. Without proper SSH wrapping, agents will always execute locally, breaking the user's expected behavior. + +**Required pattern:** +1. Check if the session has `sshRemoteConfig` with `enabled: true` +2. Use `wrapSpawnWithSsh()` from `src/main/utils/ssh-spawn-wrapper.ts` to wrap the spawn config +3. Pass the SSH store (available via `createSshRemoteStoreAdapter(settingsStore)`) + +```typescript +import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper'; +import { createSshRemoteStoreAdapter } from '../utils/ssh-remote-resolver'; + +// Before spawning, wrap the config with SSH if needed +if (sshStore && session.sshRemoteConfig?.enabled) { + const sshWrapped = await wrapSpawnWithSsh(spawnConfig, session.sshRemoteConfig, sshStore); + // Use sshWrapped.command, sshWrapped.args, sshWrapped.cwd, etc. +} +``` + +**Also ensure:** +- The correct agent type is used (don't hardcode `claude-code`) +- Custom agent configuration (customPath, customArgs, customEnvVars) is passed through +- Agent's `binaryName` is used for remote execution (not local paths) + +See [[CLAUDE-PATTERNS.md]] for detailed SSH patterns. --- diff --git a/src/main/group-chat/group-chat-agent.ts b/src/main/group-chat/group-chat-agent.ts index 6ca8f37d..307ddf41 100644 --- a/src/main/group-chat/group-chat-agent.ts +++ b/src/main/group-chat/group-chat-agent.ts @@ -25,6 +25,8 @@ import { getContextWindowValue, } from '../utils/agent-args'; import { groupChatParticipantPrompt } from '../../prompts'; +import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper'; +import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; /** * In-memory store for active participant sessions. @@ -63,6 +65,12 @@ export interface SessionOverrides { customEnvVars?: Record; /** SSH remote name for display in participant card */ sshRemoteName?: string; + /** Full SSH remote config for remote execution */ + sshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; } /** @@ -76,7 +84,8 @@ export interface SessionOverrides { * @param agentDetector - Optional agent detector for resolving agent paths * @param agentConfigValues - Optional agent config values (from config store) * @param customEnvVars - Optional custom environment variables for the agent (deprecated, use sessionOverrides) - * @param sessionOverrides - Optional session-specific overrides (customModel, customArgs, customEnvVars) + * @param sessionOverrides - Optional session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteConfig) + * @param sshStore - Optional SSH settings store for remote execution support * @returns The created participant */ export async function addParticipant( @@ -88,7 +97,8 @@ export async function addParticipant( agentDetector?: AgentDetector, agentConfigValues?: Record, customEnvVars?: Record, - sessionOverrides?: SessionOverrides + sessionOverrides?: SessionOverrides, + sshStore?: SshRemoteSettingsStore ): Promise { console.log(`[GroupChat:Debug] ========== ADD PARTICIPANT ==========`); console.log(`[GroupChat:Debug] Group Chat ID: ${groupChatId}`); @@ -163,18 +173,52 @@ export async function addParticipant( const sessionId = `group-chat-${groupChatId}-participant-${name}-${uuidv4()}`; console.log(`[GroupChat:Debug] Generated session ID: ${sessionId}`); + // Wrap spawn config with SSH if configured + let spawnCommand = command; + let spawnArgs = configResolution.args; + let spawnCwd = cwd; + let spawnPrompt: string | undefined = prompt; + let spawnEnvVars = configResolution.effectiveCustomEnvVars ?? effectiveEnvVars; + + // Apply SSH wrapping if SSH is configured and store is available + if (sshStore && sessionOverrides?.sshRemoteConfig) { + console.log(`[GroupChat:Debug] Applying SSH wrapping for participant...`); + const sshWrapped = await wrapSpawnWithSsh( + { + command, + args: configResolution.args, + cwd, + prompt, + customEnvVars: configResolution.effectiveCustomEnvVars ?? effectiveEnvVars, + promptArgs: agentConfig?.promptArgs, + noPromptSeparator: agentConfig?.noPromptSeparator, + agentBinaryName: agentConfig?.binaryName, + }, + sessionOverrides.sshRemoteConfig, + sshStore + ); + spawnCommand = sshWrapped.command; + spawnArgs = sshWrapped.args; + spawnCwd = sshWrapped.cwd; + spawnPrompt = sshWrapped.prompt; + spawnEnvVars = sshWrapped.customEnvVars; + if (sshWrapped.sshRemoteUsed) { + console.log(`[GroupChat:Debug] SSH remote used: ${sshWrapped.sshRemoteUsed.name}`); + } + } + // Spawn the participant agent console.log(`[GroupChat:Debug] Spawning participant agent...`); const result = processManager.spawn({ sessionId, toolType: agentId, - cwd, - command, - args: configResolution.args, + cwd: spawnCwd, + command: spawnCommand, + args: spawnArgs, readOnlyMode: false, // Participants can make changes - prompt, + prompt: spawnPrompt, contextWindow: getContextWindowValue(agentConfig, agentConfigValues || {}), - customEnvVars: configResolution.effectiveCustomEnvVars ?? effectiveEnvVars, + customEnvVars: spawnEnvVars, promptArgs: agentConfig?.promptArgs, noPromptSeparator: agentConfig?.noPromptSeparator, }); diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index a56bd8c5..5163c5c8 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -34,6 +34,8 @@ import { getContextWindowValue, } from '../utils/agent-args'; import { groupChatParticipantRequestPrompt } from '../../prompts'; +import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper'; +import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; // Import emitters from IPC handlers (will be populated after handlers are registered) import { groupChatEmitters } from '../ipc/handlers/groupChat'; @@ -51,6 +53,12 @@ export interface SessionInfo { customModel?: string; /** SSH remote name for display in participant card */ sshRemoteName?: string; + /** Full SSH remote config for remote execution */ + sshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; } /** @@ -71,6 +79,9 @@ let getSessionsCallback: GetSessionsCallback | null = null; let getCustomEnvVarsCallback: GetCustomEnvVarsCallback | null = null; let getAgentConfigCallback: GetAgentConfigCallback | null = null; +// Module-level SSH store for remote execution support +let sshStore: SshRemoteSettingsStore | null = null; + /** * Tracks pending participant responses for each group chat. * When all pending participants have responded, we spawn a moderator synthesis round. @@ -150,6 +161,14 @@ export function setGetAgentConfigCallback(callback: GetAgentConfigCallback): voi getAgentConfigCallback = callback; } +/** + * Sets the SSH store for remote execution support. + * Called from index.ts during initialization. + */ +export function setSshStore(store: SshRemoteSettingsStore): void { + sshStore = store; +} + /** * Extracts @mentions from text that match known participants. * Supports hyphenated names matching participants with spaces. @@ -447,18 +466,54 @@ ${message}`; // Add power block reason to prevent sleep during group chat activity powerManager.addBlockReason(`groupchat:${groupChatId}`); + // Prepare spawn config with potential SSH wrapping + let spawnCommand = command; + let spawnArgs = finalArgs; + let spawnCwd = process.env.HOME || '/tmp'; + let spawnPrompt: string | undefined = fullPrompt; + let spawnEnvVars = + configResolution.effectiveCustomEnvVars ?? + getCustomEnvVarsCallback?.(chat.moderatorAgentId); + + // Apply SSH wrapping if configured + if (sshStore && chat.moderatorConfig?.sshRemoteConfig) { + console.log(`[GroupChat:Debug] Applying SSH wrapping for moderator...`); + const sshWrapped = await wrapSpawnWithSsh( + { + command, + args: finalArgs, + cwd: process.env.HOME || '/tmp', + prompt: fullPrompt, + customEnvVars: + configResolution.effectiveCustomEnvVars ?? + getCustomEnvVarsCallback?.(chat.moderatorAgentId), + promptArgs: agent.promptArgs, + noPromptSeparator: agent.noPromptSeparator, + agentBinaryName: agent.binaryName, + }, + chat.moderatorConfig.sshRemoteConfig, + sshStore + ); + spawnCommand = sshWrapped.command; + spawnArgs = sshWrapped.args; + spawnCwd = sshWrapped.cwd; + spawnPrompt = sshWrapped.prompt; + spawnEnvVars = sshWrapped.customEnvVars; + if (sshWrapped.sshRemoteUsed) { + console.log(`[GroupChat:Debug] SSH remote used: ${sshWrapped.sshRemoteUsed.name}`); + } + } + const spawnResult = processManager.spawn({ sessionId, toolType: chat.moderatorAgentId, - cwd: process.env.HOME || '/tmp', - command, - args: finalArgs, + cwd: spawnCwd, + command: spawnCommand, + args: spawnArgs, readOnlyMode: true, - prompt: fullPrompt, + prompt: spawnPrompt, contextWindow: getContextWindowValue(agent, agentConfigValues), - customEnvVars: - configResolution.effectiveCustomEnvVars ?? - getCustomEnvVarsCallback?.(chat.moderatorAgentId), + customEnvVars: spawnEnvVars, promptArgs: agent.promptArgs, noPromptSeparator: agent.noPromptSeparator, }); @@ -610,13 +665,16 @@ export async function routeModeratorResponse( agentDetector, agentConfigValues, customEnvVars, - // Pass session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteName from session) + // Pass session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteConfig from session) { customModel: matchingSession.customModel, customArgs: matchingSession.customArgs, customEnvVars: matchingSession.customEnvVars, sshRemoteName: matchingSession.sshRemoteName, - } + sshRemoteConfig: matchingSession.sshRemoteConfig, + }, + // Pass SSH store for remote execution support + sshStore ?? undefined ); existingParticipantNames.add(participantName); @@ -771,18 +829,54 @@ export async function routeModeratorResponse( `[GroupChat:Debug] CustomEnvVars: ${JSON.stringify(configResolution.effectiveCustomEnvVars || {})}` ); + // Prepare spawn config with potential SSH wrapping + let finalSpawnCommand = spawnCommand; + let finalSpawnArgs = spawnArgs; + let finalSpawnCwd = cwd; + let finalSpawnPrompt: string | undefined = participantPrompt; + let finalSpawnEnvVars = + configResolution.effectiveCustomEnvVars ?? + getCustomEnvVarsCallback?.(participant.agentId); + + // Apply SSH wrapping if configured for this session + if (sshStore && matchingSession?.sshRemoteConfig) { + console.log(`[GroupChat:Debug] Applying SSH wrapping for participant ${participantName}...`); + const sshWrapped = await wrapSpawnWithSsh( + { + command: spawnCommand, + args: spawnArgs, + cwd, + prompt: participantPrompt, + customEnvVars: + configResolution.effectiveCustomEnvVars ?? + getCustomEnvVarsCallback?.(participant.agentId), + promptArgs: agent.promptArgs, + noPromptSeparator: agent.noPromptSeparator, + agentBinaryName: agent.binaryName, + }, + matchingSession.sshRemoteConfig, + sshStore + ); + finalSpawnCommand = sshWrapped.command; + finalSpawnArgs = sshWrapped.args; + finalSpawnCwd = sshWrapped.cwd; + finalSpawnPrompt = sshWrapped.prompt; + finalSpawnEnvVars = sshWrapped.customEnvVars; + if (sshWrapped.sshRemoteUsed) { + console.log(`[GroupChat:Debug] SSH remote used: ${sshWrapped.sshRemoteUsed.name}`); + } + } + const spawnResult = processManager.spawn({ sessionId, toolType: participant.agentId, - cwd, - command: spawnCommand, - args: spawnArgs, + cwd: finalSpawnCwd, + command: finalSpawnCommand, + args: finalSpawnArgs, readOnlyMode: readOnly ?? false, // Propagate read-only mode from caller - prompt: participantPrompt, + prompt: finalSpawnPrompt, contextWindow: getContextWindowValue(agent, agentConfigValues), - customEnvVars: - configResolution.effectiveCustomEnvVars ?? - getCustomEnvVarsCallback?.(participant.agentId), + customEnvVars: finalSpawnEnvVars, promptArgs: agent.promptArgs, noPromptSeparator: agent.noPromptSeparator, }); diff --git a/src/main/index.ts b/src/main/index.ts index ea92e018..27875894 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -60,11 +60,13 @@ import { setGetSessionsCallback, setGetCustomEnvVarsCallback, setGetAgentConfigCallback, + setSshStore, markParticipantResponded, spawnModeratorSynthesis, getGroupChatReadOnlyState, respawnParticipantWithRecovery, } from './group-chat/group-chat-router'; +import { createSshRemoteStoreAdapter } from './utils/ssh-remote-resolver'; import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage'; import { needsSessionRecovery, initiateSessionRecovery } from './group-chat/session-recovery'; import { initializeSessionStorages } from './storage'; @@ -553,6 +555,8 @@ function setupIpcHandlers() { customEnvVars: s.customEnvVars, customModel: s.customModel, sshRemoteName, + // Pass full SSH config for remote execution support + sshRemoteConfig: s.sessionSshRemoteConfig, }; }); }); @@ -561,6 +565,9 @@ function setupIpcHandlers() { setGetCustomEnvVarsCallback(getCustomEnvVarsForAgent); setGetAgentConfigCallback(getAgentConfigForAgent); + // Set up SSH store for group chat SSH remote execution support + setSshStore(createSshRemoteStoreAdapter(store)); + // Setup logger event forwarding to renderer setupLoggerEventForwarding(() => mainWindow); diff --git a/src/main/utils/ssh-spawn-wrapper.ts b/src/main/utils/ssh-spawn-wrapper.ts new file mode 100644 index 00000000..ae7aad0d --- /dev/null +++ b/src/main/utils/ssh-spawn-wrapper.ts @@ -0,0 +1,194 @@ +/** + * SSH Spawn Wrapper Utility + * + * Provides a reusable function to wrap spawn configurations with SSH remote execution. + * This extracts the SSH wrapping logic from the process:spawn IPC handler so it can be + * used by other components like Group Chat that spawn processes directly. + * + * IMPORTANT: Any feature that spawns agent processes must use this utility to properly + * support SSH remote execution. Without it, agents will always run locally even when + * the session is configured for SSH remote execution. + */ + +import * as os from 'os'; +import type { SshRemoteConfig, AgentSshRemoteConfig } from '../../shared/types'; +import { getSshRemoteConfig, SshRemoteSettingsStore } from './ssh-remote-resolver'; +import { buildSshCommand } from './ssh-command-builder'; +import { logger } from './logger'; + +const LOG_CONTEXT = '[SshSpawnWrapper]'; + +/** + * Configuration for wrapping a spawn with SSH. + */ +export interface SshSpawnWrapConfig { + /** The command to execute */ + command: string; + /** Arguments for the command */ + args: string[]; + /** Working directory */ + cwd: string; + /** The prompt to send (if any) */ + prompt?: string; + /** Custom environment variables */ + customEnvVars?: Record; + /** Agent's promptArgs function (for building prompt flags) */ + promptArgs?: (prompt: string) => string[]; + /** Whether agent uses -- separator before prompt */ + noPromptSeparator?: boolean; + /** Agent's binary name (used for SSH remote command) */ + agentBinaryName?: string; +} + +/** + * Result of wrapping a spawn config with SSH. + */ +export interface SshSpawnWrapResult { + /** The command to spawn (may be 'ssh' if SSH is enabled) */ + command: string; + /** Arguments for the command */ + args: string[]; + /** Working directory (local home for SSH, original for local) */ + cwd: string; + /** Custom environment variables (undefined for SSH since they're in the command) */ + customEnvVars?: Record; + /** The prompt to pass to ProcessManager (undefined for small SSH prompts, original for large) */ + prompt?: string; + /** Whether SSH remote was used */ + sshRemoteUsed: SshRemoteConfig | null; +} + +/** + * Wrap a spawn configuration with SSH remote execution if configured. + * + * This function handles: + * 1. Resolving the SSH remote configuration from session config + * 2. Building the SSH command wrapper for remote execution + * 3. Handling prompt embedding (small prompts in command line, large via stdin) + * 4. Adjusting cwd and env vars for SSH execution + * + * @param config The original spawn configuration + * @param sshConfig Session-level SSH configuration (if any) + * @param sshStore Store adapter for resolving SSH remote settings + * @returns Wrapped spawn configuration ready for ProcessManager + * + * @example + * const wrapped = await wrapSpawnWithSsh( + * { command: 'claude', args: ['--print'], cwd: '/project', prompt: 'Hello' }, + * { enabled: true, remoteId: 'my-server' }, + * createSshRemoteStoreAdapter(settingsStore) + * ); + * processManager.spawn(wrapped); + */ +export async function wrapSpawnWithSsh( + config: SshSpawnWrapConfig, + sshConfig: AgentSshRemoteConfig | undefined, + sshStore: SshRemoteSettingsStore +): Promise { + // Check if SSH is enabled for this session + if (!sshConfig?.enabled) { + // Local execution - return config unchanged + return { + command: config.command, + args: config.args, + cwd: config.cwd, + customEnvVars: config.customEnvVars, + prompt: config.prompt, + sshRemoteUsed: null, + }; + } + + // Resolve the SSH remote configuration + const sshResult = getSshRemoteConfig(sshStore, { + sessionSshConfig: sshConfig, + }); + + if (!sshResult.config) { + // SSH config not found or disabled - fall back to local execution + logger.warn('SSH remote config not found, falling back to local execution', LOG_CONTEXT, { + remoteId: sshConfig.remoteId, + source: sshResult.source, + }); + return { + command: config.command, + args: config.args, + cwd: config.cwd, + customEnvVars: config.customEnvVars, + prompt: config.prompt, + sshRemoteUsed: null, + }; + } + + logger.info('Wrapping spawn with SSH remote execution', LOG_CONTEXT, { + remoteId: sshResult.config.id, + remoteName: sshResult.config.name, + host: sshResult.config.host, + }); + + // For SSH execution, we need to include the prompt in the args + // because ProcessManager.spawn() won't add it (we pass prompt: undefined for SSH) + // Use promptArgs if available (e.g., OpenCode -p), otherwise use positional arg + // + // For large prompts (>4000 chars), don't embed in command line to avoid + // command line length limits. Instead, use --input-format stream-json and + // let ProcessManager send via stdin. + const isLargePrompt = config.prompt && config.prompt.length > 4000; + let sshArgs = [...config.args]; + let passPromptToSpawn: string | undefined; + + if (config.prompt && !isLargePrompt) { + // Small prompt - embed in command line + if (config.promptArgs) { + sshArgs = [...config.args, ...config.promptArgs(config.prompt)]; + } else if (config.noPromptSeparator) { + sshArgs = [...config.args, config.prompt]; + } else { + sshArgs = [...config.args, '--', config.prompt]; + } + } else if (config.prompt && isLargePrompt) { + // Large prompt - use stdin mode + sshArgs = [...config.args, '--input-format', 'stream-json']; + passPromptToSpawn = config.prompt; // Pass to ProcessManager for stdin delivery + logger.info('Using stdin for large prompt in SSH remote execution', LOG_CONTEXT, { + promptLength: config.prompt.length, + reason: 'avoid-command-line-length-limit', + }); + } + + // Determine the command to run on the remote host: + // Use agentBinaryName if provided (e.g., 'codex', 'claude'), otherwise use the command + // This avoids using local paths like '/opt/homebrew/bin/codex' on the remote + const remoteCommand = config.agentBinaryName || config.command; + + // Check if we'll send input via stdin + const useStdin = sshArgs.includes('--input-format') && sshArgs.includes('stream-json'); + + // Build the SSH command + const sshCommand = await buildSshCommand(sshResult.config, { + command: remoteCommand, + args: sshArgs, + cwd: config.cwd, + env: config.customEnvVars, + useStdin, + }); + + logger.debug('SSH command built', LOG_CONTEXT, { + sshBinary: sshCommand.command, + sshArgsCount: sshCommand.args.length, + remoteCommand, + remoteArgs: sshArgs, + remoteCwd: config.cwd, + }); + + return { + command: sshCommand.command, + args: sshCommand.args, + // Use local home directory as cwd - remote cwd is in the SSH command + cwd: os.homedir(), + // Env vars are passed via the SSH command, not locally + customEnvVars: undefined, + // For large prompts, pass to ProcessManager for stdin delivery + prompt: passPromptToSpawn, + sshRemoteUsed: sshResult.config, + }; +} diff --git a/src/shared/group-chat-types.ts b/src/shared/group-chat-types.ts index f7025ee9..5fea9675 100644 --- a/src/shared/group-chat-types.ts +++ b/src/shared/group-chat-types.ts @@ -73,6 +73,12 @@ export interface ModeratorConfig { customArgs?: string; /** Custom environment variables */ customEnvVars?: Record; + /** SSH remote config for remote execution */ + sshRemoteConfig?: { + enabled: boolean; + remoteId: string | null; + workingDirOverride?: string; + }; } /**