mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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
This commit is contained in:
34
CLAUDE.md
34
CLAUDE.md
@@ -167,6 +167,40 @@ src/
|
|||||||
| Add colorblind palette | `src/renderer/constants/colorblindPalettes.ts` |
|
| Add colorblind palette | `src/renderer/constants/colorblindPalettes.ts` |
|
||||||
| Add performance metrics | `src/shared/performance-metrics.ts` |
|
| Add performance metrics | `src/shared/performance-metrics.ts` |
|
||||||
| Add power management | `src/main/power-manager.ts`, `src/main/ipc/handlers/system.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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import {
|
|||||||
getContextWindowValue,
|
getContextWindowValue,
|
||||||
} from '../utils/agent-args';
|
} from '../utils/agent-args';
|
||||||
import { groupChatParticipantPrompt } from '../../prompts';
|
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.
|
* In-memory store for active participant sessions.
|
||||||
@@ -63,6 +65,12 @@ export interface SessionOverrides {
|
|||||||
customEnvVars?: Record<string, string>;
|
customEnvVars?: Record<string, string>;
|
||||||
/** SSH remote name for display in participant card */
|
/** SSH remote name for display in participant card */
|
||||||
sshRemoteName?: string;
|
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 agentDetector - Optional agent detector for resolving agent paths
|
||||||
* @param agentConfigValues - Optional agent config values (from config store)
|
* @param agentConfigValues - Optional agent config values (from config store)
|
||||||
* @param customEnvVars - Optional custom environment variables for the agent (deprecated, use sessionOverrides)
|
* @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
|
* @returns The created participant
|
||||||
*/
|
*/
|
||||||
export async function addParticipant(
|
export async function addParticipant(
|
||||||
@@ -88,7 +97,8 @@ export async function addParticipant(
|
|||||||
agentDetector?: AgentDetector,
|
agentDetector?: AgentDetector,
|
||||||
agentConfigValues?: Record<string, any>,
|
agentConfigValues?: Record<string, any>,
|
||||||
customEnvVars?: Record<string, string>,
|
customEnvVars?: Record<string, string>,
|
||||||
sessionOverrides?: SessionOverrides
|
sessionOverrides?: SessionOverrides,
|
||||||
|
sshStore?: SshRemoteSettingsStore
|
||||||
): Promise<GroupChatParticipant> {
|
): Promise<GroupChatParticipant> {
|
||||||
console.log(`[GroupChat:Debug] ========== ADD PARTICIPANT ==========`);
|
console.log(`[GroupChat:Debug] ========== ADD PARTICIPANT ==========`);
|
||||||
console.log(`[GroupChat:Debug] Group Chat ID: ${groupChatId}`);
|
console.log(`[GroupChat:Debug] Group Chat ID: ${groupChatId}`);
|
||||||
@@ -163,18 +173,52 @@ export async function addParticipant(
|
|||||||
const sessionId = `group-chat-${groupChatId}-participant-${name}-${uuidv4()}`;
|
const sessionId = `group-chat-${groupChatId}-participant-${name}-${uuidv4()}`;
|
||||||
console.log(`[GroupChat:Debug] Generated session ID: ${sessionId}`);
|
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
|
// Spawn the participant agent
|
||||||
console.log(`[GroupChat:Debug] Spawning participant agent...`);
|
console.log(`[GroupChat:Debug] Spawning participant agent...`);
|
||||||
const result = processManager.spawn({
|
const result = processManager.spawn({
|
||||||
sessionId,
|
sessionId,
|
||||||
toolType: agentId,
|
toolType: agentId,
|
||||||
cwd,
|
cwd: spawnCwd,
|
||||||
command,
|
command: spawnCommand,
|
||||||
args: configResolution.args,
|
args: spawnArgs,
|
||||||
readOnlyMode: false, // Participants can make changes
|
readOnlyMode: false, // Participants can make changes
|
||||||
prompt,
|
prompt: spawnPrompt,
|
||||||
contextWindow: getContextWindowValue(agentConfig, agentConfigValues || {}),
|
contextWindow: getContextWindowValue(agentConfig, agentConfigValues || {}),
|
||||||
customEnvVars: configResolution.effectiveCustomEnvVars ?? effectiveEnvVars,
|
customEnvVars: spawnEnvVars,
|
||||||
promptArgs: agentConfig?.promptArgs,
|
promptArgs: agentConfig?.promptArgs,
|
||||||
noPromptSeparator: agentConfig?.noPromptSeparator,
|
noPromptSeparator: agentConfig?.noPromptSeparator,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
getContextWindowValue,
|
getContextWindowValue,
|
||||||
} from '../utils/agent-args';
|
} from '../utils/agent-args';
|
||||||
import { groupChatParticipantRequestPrompt } from '../../prompts';
|
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 emitters from IPC handlers (will be populated after handlers are registered)
|
||||||
import { groupChatEmitters } from '../ipc/handlers/groupChat';
|
import { groupChatEmitters } from '../ipc/handlers/groupChat';
|
||||||
@@ -51,6 +53,12 @@ export interface SessionInfo {
|
|||||||
customModel?: string;
|
customModel?: string;
|
||||||
/** SSH remote name for display in participant card */
|
/** SSH remote name for display in participant card */
|
||||||
sshRemoteName?: string;
|
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 getCustomEnvVarsCallback: GetCustomEnvVarsCallback | null = null;
|
||||||
let getAgentConfigCallback: GetAgentConfigCallback | 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.
|
* Tracks pending participant responses for each group chat.
|
||||||
* When all pending participants have responded, we spawn a moderator synthesis round.
|
* When all pending participants have responded, we spawn a moderator synthesis round.
|
||||||
@@ -150,6 +161,14 @@ export function setGetAgentConfigCallback(callback: GetAgentConfigCallback): voi
|
|||||||
getAgentConfigCallback = callback;
|
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.
|
* Extracts @mentions from text that match known participants.
|
||||||
* Supports hyphenated names matching participants with spaces.
|
* Supports hyphenated names matching participants with spaces.
|
||||||
@@ -447,18 +466,54 @@ ${message}`;
|
|||||||
// Add power block reason to prevent sleep during group chat activity
|
// Add power block reason to prevent sleep during group chat activity
|
||||||
powerManager.addBlockReason(`groupchat:${groupChatId}`);
|
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({
|
const spawnResult = processManager.spawn({
|
||||||
sessionId,
|
sessionId,
|
||||||
toolType: chat.moderatorAgentId,
|
toolType: chat.moderatorAgentId,
|
||||||
cwd: process.env.HOME || '/tmp',
|
cwd: spawnCwd,
|
||||||
command,
|
command: spawnCommand,
|
||||||
args: finalArgs,
|
args: spawnArgs,
|
||||||
readOnlyMode: true,
|
readOnlyMode: true,
|
||||||
prompt: fullPrompt,
|
prompt: spawnPrompt,
|
||||||
contextWindow: getContextWindowValue(agent, agentConfigValues),
|
contextWindow: getContextWindowValue(agent, agentConfigValues),
|
||||||
customEnvVars:
|
customEnvVars: spawnEnvVars,
|
||||||
configResolution.effectiveCustomEnvVars ??
|
|
||||||
getCustomEnvVarsCallback?.(chat.moderatorAgentId),
|
|
||||||
promptArgs: agent.promptArgs,
|
promptArgs: agent.promptArgs,
|
||||||
noPromptSeparator: agent.noPromptSeparator,
|
noPromptSeparator: agent.noPromptSeparator,
|
||||||
});
|
});
|
||||||
@@ -610,13 +665,16 @@ export async function routeModeratorResponse(
|
|||||||
agentDetector,
|
agentDetector,
|
||||||
agentConfigValues,
|
agentConfigValues,
|
||||||
customEnvVars,
|
customEnvVars,
|
||||||
// Pass session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteName from session)
|
// Pass session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteConfig from session)
|
||||||
{
|
{
|
||||||
customModel: matchingSession.customModel,
|
customModel: matchingSession.customModel,
|
||||||
customArgs: matchingSession.customArgs,
|
customArgs: matchingSession.customArgs,
|
||||||
customEnvVars: matchingSession.customEnvVars,
|
customEnvVars: matchingSession.customEnvVars,
|
||||||
sshRemoteName: matchingSession.sshRemoteName,
|
sshRemoteName: matchingSession.sshRemoteName,
|
||||||
}
|
sshRemoteConfig: matchingSession.sshRemoteConfig,
|
||||||
|
},
|
||||||
|
// Pass SSH store for remote execution support
|
||||||
|
sshStore ?? undefined
|
||||||
);
|
);
|
||||||
existingParticipantNames.add(participantName);
|
existingParticipantNames.add(participantName);
|
||||||
|
|
||||||
@@ -771,18 +829,54 @@ export async function routeModeratorResponse(
|
|||||||
`[GroupChat:Debug] CustomEnvVars: ${JSON.stringify(configResolution.effectiveCustomEnvVars || {})}`
|
`[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({
|
const spawnResult = processManager.spawn({
|
||||||
sessionId,
|
sessionId,
|
||||||
toolType: participant.agentId,
|
toolType: participant.agentId,
|
||||||
cwd,
|
cwd: finalSpawnCwd,
|
||||||
command: spawnCommand,
|
command: finalSpawnCommand,
|
||||||
args: spawnArgs,
|
args: finalSpawnArgs,
|
||||||
readOnlyMode: readOnly ?? false, // Propagate read-only mode from caller
|
readOnlyMode: readOnly ?? false, // Propagate read-only mode from caller
|
||||||
prompt: participantPrompt,
|
prompt: finalSpawnPrompt,
|
||||||
contextWindow: getContextWindowValue(agent, agentConfigValues),
|
contextWindow: getContextWindowValue(agent, agentConfigValues),
|
||||||
customEnvVars:
|
customEnvVars: finalSpawnEnvVars,
|
||||||
configResolution.effectiveCustomEnvVars ??
|
|
||||||
getCustomEnvVarsCallback?.(participant.agentId),
|
|
||||||
promptArgs: agent.promptArgs,
|
promptArgs: agent.promptArgs,
|
||||||
noPromptSeparator: agent.noPromptSeparator,
|
noPromptSeparator: agent.noPromptSeparator,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,11 +60,13 @@ import {
|
|||||||
setGetSessionsCallback,
|
setGetSessionsCallback,
|
||||||
setGetCustomEnvVarsCallback,
|
setGetCustomEnvVarsCallback,
|
||||||
setGetAgentConfigCallback,
|
setGetAgentConfigCallback,
|
||||||
|
setSshStore,
|
||||||
markParticipantResponded,
|
markParticipantResponded,
|
||||||
spawnModeratorSynthesis,
|
spawnModeratorSynthesis,
|
||||||
getGroupChatReadOnlyState,
|
getGroupChatReadOnlyState,
|
||||||
respawnParticipantWithRecovery,
|
respawnParticipantWithRecovery,
|
||||||
} from './group-chat/group-chat-router';
|
} from './group-chat/group-chat-router';
|
||||||
|
import { createSshRemoteStoreAdapter } from './utils/ssh-remote-resolver';
|
||||||
import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage';
|
import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage';
|
||||||
import { needsSessionRecovery, initiateSessionRecovery } from './group-chat/session-recovery';
|
import { needsSessionRecovery, initiateSessionRecovery } from './group-chat/session-recovery';
|
||||||
import { initializeSessionStorages } from './storage';
|
import { initializeSessionStorages } from './storage';
|
||||||
@@ -553,6 +555,8 @@ function setupIpcHandlers() {
|
|||||||
customEnvVars: s.customEnvVars,
|
customEnvVars: s.customEnvVars,
|
||||||
customModel: s.customModel,
|
customModel: s.customModel,
|
||||||
sshRemoteName,
|
sshRemoteName,
|
||||||
|
// Pass full SSH config for remote execution support
|
||||||
|
sshRemoteConfig: s.sessionSshRemoteConfig,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -561,6 +565,9 @@ function setupIpcHandlers() {
|
|||||||
setGetCustomEnvVarsCallback(getCustomEnvVarsForAgent);
|
setGetCustomEnvVarsCallback(getCustomEnvVarsForAgent);
|
||||||
setGetAgentConfigCallback(getAgentConfigForAgent);
|
setGetAgentConfigCallback(getAgentConfigForAgent);
|
||||||
|
|
||||||
|
// Set up SSH store for group chat SSH remote execution support
|
||||||
|
setSshStore(createSshRemoteStoreAdapter(store));
|
||||||
|
|
||||||
// Setup logger event forwarding to renderer
|
// Setup logger event forwarding to renderer
|
||||||
setupLoggerEventForwarding(() => mainWindow);
|
setupLoggerEventForwarding(() => mainWindow);
|
||||||
|
|
||||||
|
|||||||
194
src/main/utils/ssh-spawn-wrapper.ts
Normal file
194
src/main/utils/ssh-spawn-wrapper.ts
Normal file
@@ -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<string, string>;
|
||||||
|
/** 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<string, string>;
|
||||||
|
/** 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<SshSpawnWrapResult> {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -73,6 +73,12 @@ export interface ModeratorConfig {
|
|||||||
customArgs?: string;
|
customArgs?: string;
|
||||||
/** Custom environment variables */
|
/** Custom environment variables */
|
||||||
customEnvVars?: Record<string, string>;
|
customEnvVars?: Record<string, string>;
|
||||||
|
/** SSH remote config for remote execution */
|
||||||
|
sshRemoteConfig?: {
|
||||||
|
enabled: boolean;
|
||||||
|
remoteId: string | null;
|
||||||
|
workingDirOverride?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user