From 343d19a125701ac58e25ab0726fc882b78be9e24 Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 14:07:06 -0700 Subject: [PATCH 1/2] feat: add remote daemon config surface --- frontend/src/components/Settings.tsx | 22 ++- frontend/src/types/config.ts | 91 ++++++++--- frontend/src/types/electron.d.ts | 22 ++- frontend/src/utils/api.ts | 46 +++++- main/src/ipc/config.ts | 7 +- main/src/ipc/index.ts | 2 + main/src/ipc/remoteDaemon.test.ts | 169 ++++++++++++++++++++ main/src/ipc/remoteDaemon.ts | 221 +++++++++++++++++++++++++++ main/src/preload.ts | 25 ++- main/src/services/configManager.ts | 11 +- main/src/types/config.ts | 13 +- shared/types/remoteDaemon.ts | 165 ++++++++++++++++++++ 12 files changed, 757 insertions(+), 37 deletions(-) create mode 100644 main/src/ipc/remoteDaemon.test.ts create mode 100644 main/src/ipc/remoteDaemon.ts create mode 100644 shared/types/remoteDaemon.ts diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index 0fcc8ae9..4e0b529a 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -3,7 +3,7 @@ import { NotificationSettings } from './NotificationSettings'; import { useNotifications } from '../hooks/useNotifications'; import { API } from '../utils/api'; import { optIn, capture, captureAndOptOut } from '../services/posthog'; -import type { AppConfig, TerminalShortcut } from '../types/config'; +import type { PreferredShell, TerminalShortcut } from '../types/config'; import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; import { DEFAULT_WORKTREE_FILE_SYNC_ENTRIES } from '../../../shared/types/worktreeFileSync'; import { useConfigStore } from '../stores/configStore'; @@ -46,8 +46,13 @@ interface SettingsProps { initialSection?: string; } +type AvailableShell = { + id: PreferredShell; + name: string; + path: string; +}; + export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { - const [_config, setConfig] = useState(null); const [verbose, setVerbose] = useState(false); const [claudeExecutablePath, setClaudeExecutablePath] = useState(''); const [autoCheckUpdates, setAutoCheckUpdates] = useState(true); @@ -75,8 +80,8 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { const [activeTab, setActiveTab] = useState<'general' | 'notifications' | 'shortcuts'>('general'); const [analyticsEnabled, setAnalyticsEnabled] = useState(true); const [previousAnalyticsEnabled, setPreviousAnalyticsEnabled] = useState(true); - const [preferredShell, setPreferredShell] = useState('auto'); - const [availableShells, setAvailableShells] = useState>([]); + const [preferredShell, setPreferredShell] = useState('auto'); + const [availableShells, setAvailableShells] = useState([]); const [terminalShortcuts, setTerminalShortcuts] = useState([]); const [worktreeFileSync, setWorktreeFileSync] = useState([]); const { updateSettings } = useNotifications(); @@ -138,9 +143,10 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { const fetchConfig = async (currentPlatform?: string) => { try { const response = await API.config.get(); - if (!response.success) throw new Error(response.error || 'Failed to fetch config'); + if (!response.success || !response.data) { + throw new Error(response.error || 'Failed to fetch config'); + } const data = response.data; - setConfig(data); setVerbose(data.verbose || false); setAutoCheckUpdates(data.autoCheckUpdates !== false); // Default to true setDevMode(data.devMode || false); @@ -174,7 +180,7 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { const platformToCheck = currentPlatform || platform; if (platformToCheck === 'win32') { const shellsResponse = await API.config.getAvailableShells(); - if (shellsResponse.success) { + if (shellsResponse.success && shellsResponse.data) { setAvailableShells(shellsResponse.data); } } @@ -185,7 +191,7 @@ export function Settings({ isOpen, onClose, initialSection }: SettingsProps) { // Load worktree file sync entries setWorktreeFileSync(data.worktreeFileSync ?? DEFAULT_WORKTREE_FILE_SYNC_ENTRIES); - } catch (err) { + } catch { setError('Failed to load configuration'); } }; diff --git a/frontend/src/types/config.ts b/frontend/src/types/config.ts index 9b83a61c..7eb9e3c4 100644 --- a/frontend/src/types/config.ts +++ b/frontend/src/types/config.ts @@ -1,4 +1,5 @@ import type { CloudVmConfig } from '../../../shared/types/cloud'; +import type { RemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; export interface TerminalShortcut { @@ -24,25 +25,52 @@ export interface AnalyticsIdentity { gitUserName?: string; } +export interface AnalyticsConfig { + enabled: boolean; + posthogApiKey?: string; + posthogHost?: string; + distinctId?: string; + identitySource?: AnalyticsIdentity['identitySource']; + githubUsername?: string; + githubEmail?: string; + gitEmail?: string; + gitEmailHash?: string; + gitUserName?: string; +} + export interface AppConfig { - gitRepoPath: string; verbose?: boolean; anthropicApiKey?: string; + openaiApiKey?: string; + // Legacy fields for backward compatibility + gitRepoPath?: string; systemPromptAppend?: string; runScript?: string[]; + // Custom claude executable path (for when it's not in PATH) claudeExecutablePath?: string; + // Permission mode for all sessions defaultPermissionMode?: 'approve' | 'ignore'; + // Default model for new sessions + defaultModel?: string; + // Auto-check for updates autoCheckUpdates?: boolean; + // Stravu MCP integration + stravuApiKey?: string; + stravuServerUrl?: string; + // Theme preference theme?: 'light' | 'light-rounded' | 'dark' | 'oled' | 'dusk' | 'dusk-oled' | 'forge' | 'ember' | 'aurora' | 'night-owl' | 'night-owl-oled' | 'terracotta'; + // UI scale factor (0.75 to 1.5, default 1.0) uiScale?: number; + // Notification settings notifications?: { playSound: boolean; enabled: boolean; }; + // Dev mode for debugging devMode?: boolean; - // Route PTY spawns through an isolated ptyHost UtilityProcess for crash - // isolation. Off by default. Requires app restart to take effect. - usePtyHost?: boolean; + // Additional paths to add to PATH environment variable + additionalPaths?: string[]; + // Session creation preferences sessionCreationPreferences?: { sessionCount?: number; toolType?: 'claude' | 'none'; @@ -59,29 +87,58 @@ export interface AppConfig { }; // Pane commit footer setting (enabled by default) enableCommitFooter?: boolean; + // Use interactive mode for Claude CLI (persistent process with stdin instead of spawn-per-message) + useInteractiveMode?: boolean; + // Route PTY spawns through an isolated ptyHost UtilityProcess for crash + // isolation. Off by default. Requires app restart; the supervisor is forked + // once at `app.whenReady`. + usePtyHost?: boolean; // PostHog analytics settings - analytics?: { - enabled: boolean; - posthogApiKey?: string; - posthogHost?: string; - distinctId?: string; - identitySource?: AnalyticsIdentity['identitySource']; - githubUsername?: string; - githubEmail?: string; - gitEmail?: string; - gitEmailHash?: string; - gitUserName?: string; - }; + analytics?: AnalyticsConfig; // User-defined custom commands for the Add Tool picker customCommands?: CustomCommand[]; // Terminal shortcuts — hotkey-triggered clipboard paste snippets terminalShortcuts?: TerminalShortcut[]; // Worktree file sync — files/dirs to copy from main repo into new worktrees worktreeFileSync?: WorktreeFileSyncEntry[]; - // Preferred shell for terminal sessions on Windows + // Preferred shell for Windows terminals preferredShell?: 'auto' | 'gitbash' | 'powershell' | 'pwsh' | 'cmd'; // Cloud VM settings cloud?: CloudVmConfig; + // Self-hosted remote daemon settings and saved client profiles + remoteDaemon?: RemoteDaemonConfig; + terminalFontFamily?: string; + terminalFontSize?: number; +} + +export type PreferredShell = NonNullable; + +export interface UpdateConfigRequest { + verbose?: boolean; + anthropicApiKey?: string; + openaiApiKey?: string; + claudeExecutablePath?: string; + systemPromptAppend?: string; + defaultPermissionMode?: 'approve' | 'ignore'; + defaultModel?: string; + autoCheckUpdates?: boolean; + stravuApiKey?: string; + stravuServerUrl?: string; + theme?: AppConfig['theme']; + uiScale?: number; + notifications?: AppConfig['notifications']; + devMode?: boolean; + additionalPaths?: string[]; + sessionCreationPreferences?: AppConfig['sessionCreationPreferences']; + enableCommitFooter?: boolean; + useInteractiveMode?: boolean; + usePtyHost?: boolean; + analytics?: AnalyticsConfig; + customCommands?: CustomCommand[]; + terminalShortcuts?: TerminalShortcut[]; + worktreeFileSync?: WorktreeFileSyncEntry[]; + preferredShell?: PreferredShell; + cloud?: CloudVmConfig; terminalFontFamily?: string; terminalFontSize?: number; } diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 11f81d0c..bf4b4a37 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -2,7 +2,15 @@ import type { Session, SessionOutput, GitStatus, VersionUpdateInfo } from './session'; import type { Project } from './project'; import type { Folder } from './folder'; +import type { AppConfig, UpdateConfigRequest } from './config'; import type { SessionCreationPreferences } from '../stores/sessionPreferencesStore'; +import type { + RemoteDaemonClientRecord, + RemoteDaemonClientSettings, + RemoteDaemonConfig, + RemoteDaemonHostConfig, + RemotePaneConnectionProfile, +} from '../../../shared/types/remoteDaemon'; import type { ToolPanel } from '../../../shared/types/panels'; import type { CreateSessionRequest } from './session'; import type { DetectedProjectConfig } from '../../../shared/types/projectConfig'; @@ -208,14 +216,24 @@ interface ElectronAPI { // Configuration config: { - get: () => Promise; - update: (updates: Record) => Promise; + get: () => Promise>; + update: (updates: UpdateConfigRequest) => Promise; getSessionPreferences: () => Promise; updateSessionPreferences: (preferences: SessionCreationPreferences) => Promise; getAvailableShells: () => Promise; getMonospaceFonts: () => Promise; }; + remoteDaemon: { + getConfig: () => Promise>; + updateHostConfig: (updates: Partial) => Promise>; + upsertClientRecord: (record: RemoteDaemonClientRecord) => Promise>; + deleteClientRecord: (clientId: string) => Promise>; + upsertConnectionProfile: (profile: RemotePaneConnectionProfile) => Promise>; + deleteConnectionProfile: (profileId: string) => Promise>; + updateClientState: (updates: Partial>) => Promise>; + }; + // Prompts prompts: { getAll: () => Promise; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index b531d1a2..0406dfc7 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -1,7 +1,14 @@ // Utility for making API calls using Electron IPC import type { CreateSessionRequest } from '../types/session'; import type { Project } from '../types/project'; +import type { UpdateConfigRequest } from '../types/config'; import type { SessionCreationPreferences } from '../stores/sessionPreferencesStore'; +import type { + RemoteDaemonClientRecord, + RemoteDaemonClientSettings, + RemoteDaemonHostConfig, + RemotePaneConnectionProfile, +} from '../../../shared/types/remoteDaemon'; // Type for IPC response // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic type parameter default for flexible API responses @@ -443,7 +450,7 @@ export class API { return window.electronAPI.config.get(); }, - async update(updates: Record) { + async update(updates: UpdateConfigRequest) { if (!isElectron()) throw new Error('Electron API not available'); return window.electronAPI.config.update(updates); }, @@ -464,6 +471,43 @@ export class API { }, }; + static remoteDaemon = { + async getConfig() { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.getConfig(); + }, + + async updateHostConfig(updates: Partial) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.updateHostConfig(updates); + }, + + async upsertClientRecord(record: RemoteDaemonClientRecord) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.upsertClientRecord(record); + }, + + async deleteClientRecord(clientId: string) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.deleteClientRecord(clientId); + }, + + async upsertConnectionProfile(profile: RemotePaneConnectionProfile) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.upsertConnectionProfile(profile); + }, + + async deleteConnectionProfile(profileId: string) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.deleteConnectionProfile(profileId); + }, + + async updateClientState(updates: Partial>) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.remoteDaemon.updateClientState(updates); + }, + }; + // Prompts static prompts = { async getAll() { diff --git a/main/src/ipc/config.ts b/main/src/ipc/config.ts index fdf21435..d842b682 100644 --- a/main/src/ipc/config.ts +++ b/main/src/ipc/config.ts @@ -1,10 +1,11 @@ import { IpcMain } from 'electron'; import { execFile } from 'child_process'; import type { AppServices } from './types'; +import type { AppConfig, UpdateConfigRequest } from '../types/config'; import { ShellDetector } from '../utils/shellDetector'; export function registerConfigHandlers(ipcMain: IpcMain, { configManager, claudeCodeManager, getMainWindow }: AppServices): void { - ipcMain.handle('config:get', async () => { + ipcMain.handle('config:get', async (): Promise<{ success: boolean; data?: AppConfig; error?: string }> => { try { // Always reload from disk to pick up external changes (e.g., from setup scripts) const config = await configManager.reloadFromDisk(); @@ -15,7 +16,7 @@ export function registerConfigHandlers(ipcMain: IpcMain, { configManager, claude } }); - ipcMain.handle('config:update', async (_event, updates: import('../types/config').UpdateConfigRequest) => { + ipcMain.handle('config:update', async (_event, updates: UpdateConfigRequest) => { try { // Check if Claude path is being updated const oldConfig = configManager.getConfig(); @@ -165,4 +166,4 @@ export function registerConfigHandlers(ipcMain: IpcMain, { configManager, claude return { success: false, data: [] }; } }); -} \ No newline at end of file +} diff --git a/main/src/ipc/index.ts b/main/src/ipc/index.ts index f66ae190..4d562cfb 100644 --- a/main/src/ipc/index.ts +++ b/main/src/ipc/index.ts @@ -19,6 +19,7 @@ import { registerEditorPanelHandlers } from './editorPanel'; import { registerNimbalystHandlers } from './nimbalyst'; import { registerSpotlightHandlers } from './spotlight'; import { registerCloudHandlers } from './cloud'; +import { registerRemoteDaemonHandlers } from './remoteDaemon'; import { registerClipboardHandlers } from './clipboard'; import { registerResourceMonitorHandlers } from './resourceMonitor'; import { registerOnboardingHandlers } from './onboarding'; @@ -48,6 +49,7 @@ export function registerIpcHandlers(services: AppServices): PaneCommandRegistry registerNimbalystHandlers(ipcMain, services); registerSpotlightHandlers(ipcMain, services); registerCloudHandlers(ipcMain, services); + registerRemoteDaemonHandlers(ipcMain, services); registerClipboardHandlers(ipcMain, services); registerResourceMonitorHandlers(ipcMain, services, commandRegistry); registerOnboardingHandlers(ipcMain, services); diff --git a/main/src/ipc/remoteDaemon.test.ts b/main/src/ipc/remoteDaemon.test.ts new file mode 100644 index 00000000..49a6d00a --- /dev/null +++ b/main/src/ipc/remoteDaemon.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { createDefaultRemoteDaemonConfig, type RemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; +import { registerRemoteDaemonHandlers } from './remoteDaemon'; + +interface IpcMainStub { + handlers: Map Promise>; + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => Promise): void; +} + +interface ConfigManagerStub { + getConfig(): { remoteDaemon?: RemoteDaemonConfig }; + updateConfig(updates: { remoteDaemon?: RemoteDaemonConfig }): Promise<{ remoteDaemon?: RemoteDaemonConfig }>; +} + +function createIpcMainStub(): IpcMainStub { + const handlers = new Map Promise>(); + + return { + handlers, + handle(channel, listener) { + handlers.set(channel, listener); + }, + }; +} + +function createConfigManagerStub(initialConfig?: RemoteDaemonConfig): ConfigManagerStub { + let remoteDaemon = initialConfig; + + return { + getConfig() { + return { remoteDaemon }; + }, + async updateConfig(updates) { + remoteDaemon = updates.remoteDaemon; + return { remoteDaemon }; + }, + }; +} + +describe('remote daemon IPC', () => { + it('returns normalized remote daemon defaults when config is missing', async () => { + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + await expect(ipcMain.handlers.get('remote-daemon:get-config')?.({})).resolves.toEqual({ + success: true, + data: createDefaultRemoteDaemonConfig(), + }); + }); + + it('persists connection profiles and client state through dedicated handlers', async () => { + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + const upsertProfile = ipcMain.handlers.get('remote-daemon:upsert-connection-profile'); + const updateClientState = ipcMain.handlers.get('remote-daemon:update-client-state'); + const getConfig = ipcMain.handlers.get('remote-daemon:get-config'); + + await expect(upsertProfile?.({}, { + id: 'profile-1', + label: 'Mac mini', + baseUrl: 'http://127.0.0.1:42137', + token: 'secret-token', + transport: 'http+sse', + })).resolves.toEqual({ + success: true, + data: [{ + id: 'profile-1', + label: 'Mac mini', + baseUrl: 'http://127.0.0.1:42137', + token: 'secret-token', + transport: 'http+sse', + }], + }); + + await expect(updateClientState?.({}, { + activeProfileId: 'profile-1', + mode: 'remote', + })).resolves.toEqual({ + success: true, + data: { + profiles: [{ + id: 'profile-1', + label: 'Mac mini', + baseUrl: 'http://127.0.0.1:42137', + token: 'secret-token', + transport: 'http+sse', + }], + activeProfileId: 'profile-1', + mode: 'remote', + }, + }); + + await expect(getConfig?.({})).resolves.toEqual({ + success: true, + data: { + host: { + config: createDefaultRemoteDaemonConfig().host.config, + clients: [], + }, + client: { + profiles: [{ + id: 'profile-1', + label: 'Mac mini', + baseUrl: 'http://127.0.0.1:42137', + token: 'secret-token', + transport: 'http+sse', + }], + activeProfileId: 'profile-1', + mode: 'remote', + }, + }, + }); + }); + + it('falls back to local mode when deleting the active connection profile', async () => { + const initialConfig = createDefaultRemoteDaemonConfig(); + initialConfig.client = { + profiles: [{ + id: 'profile-1', + label: 'Workstation', + baseUrl: 'http://127.0.0.1:42137', + token: 'secret-token', + transport: 'http+sse', + }], + activeProfileId: 'profile-1', + mode: 'remote', + }; + + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(initialConfig); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + const deleteProfile = ipcMain.handlers.get('remote-daemon:delete-connection-profile'); + + await expect(deleteProfile?.({}, 'profile-1')).resolves.toEqual({ + success: true, + data: { + profiles: [], + activeProfileId: null, + mode: 'local', + }, + }); + }); + + it('normalizes stale remote mode back to local when no active profile remains', async () => { + const initialConfig = createDefaultRemoteDaemonConfig(); + initialConfig.client = { + profiles: [], + activeProfileId: null, + mode: 'remote', + }; + + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(initialConfig); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + await expect(ipcMain.handlers.get('remote-daemon:get-config')?.({})).resolves.toEqual({ + success: true, + data: createDefaultRemoteDaemonConfig(), + }); + }); +}); diff --git a/main/src/ipc/remoteDaemon.ts b/main/src/ipc/remoteDaemon.ts new file mode 100644 index 00000000..d32712e7 --- /dev/null +++ b/main/src/ipc/remoteDaemon.ts @@ -0,0 +1,221 @@ +import { + isRemoteDaemonClientRecord, + isRemotePaneConnectionProfile, + normalizeRemoteDaemonConfig, + type RemoteDaemonClientMode, + type RemoteDaemonClientSettings, + type RemoteDaemonConfig, +} from '../../../shared/types/remoteDaemon'; +import type { AppServices } from './types'; + +interface IpcMainHandleLike { + handle(channel: string, listener: (_event: unknown, ...args: unknown[]) => Promise): void; +} + +export function registerRemoteDaemonHandlers( + ipcMain: IpcMainHandleLike, + { configManager }: Pick, +): void { + ipcMain.handle('remote-daemon:get-config', async () => { + try { + return { success: true, data: getRemoteDaemonConfig(configManager.getConfig().remoteDaemon) }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to get remote daemon config') }; + } + }); + + ipcMain.handle('remote-daemon:update-host-config', async (_event, updates: unknown) => { + try { + if (!isRecord(updates)) { + throw new Error('Remote daemon host config update must be an object'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const next = normalizeRemoteDaemonConfig({ + ...current, + host: { + ...current.host, + config: { + ...current.host.config, + ...updates, + }, + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.host.config }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to update remote daemon host config') }; + } + }); + + ipcMain.handle('remote-daemon:upsert-client-record', async (_event, record: unknown) => { + try { + if (!isRemoteDaemonClientRecord(record)) { + throw new Error('Remote daemon client record is invalid'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const clients = upsertById(current.host.clients, record); + const next = normalizeRemoteDaemonConfig({ + ...current, + host: { + ...current.host, + clients, + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.host.clients }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to save remote daemon client record') }; + } + }); + + ipcMain.handle('remote-daemon:delete-client-record', async (_event, clientId: unknown) => { + try { + if (typeof clientId !== 'string' || clientId.length === 0) { + throw new Error('Remote daemon client record id must be a non-empty string'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const next = normalizeRemoteDaemonConfig({ + ...current, + host: { + ...current.host, + clients: current.host.clients.filter((client) => client.id !== clientId), + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.host.clients }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to delete remote daemon client record') }; + } + }); + + ipcMain.handle('remote-daemon:upsert-connection-profile', async (_event, profile: unknown) => { + try { + if (!isRemotePaneConnectionProfile(profile)) { + throw new Error('Remote daemon connection profile is invalid'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const profiles = upsertById(current.client.profiles, profile); + const next = normalizeRemoteDaemonConfig({ + ...current, + client: { + ...current.client, + profiles, + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.client.profiles }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to save remote daemon connection profile') }; + } + }); + + ipcMain.handle('remote-daemon:delete-connection-profile', async (_event, profileId: unknown) => { + try { + if (typeof profileId !== 'string' || profileId.length === 0) { + throw new Error('Remote daemon connection profile id must be a non-empty string'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const activeProfileId = current.client.activeProfileId === profileId + ? null + : current.client.activeProfileId; + const mode = activeProfileId ? current.client.mode : 'local'; + const next = normalizeRemoteDaemonConfig({ + ...current, + client: { + ...current.client, + profiles: current.client.profiles.filter((profile) => profile.id !== profileId), + activeProfileId, + mode, + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.client }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to delete remote daemon connection profile') }; + } + }); + + ipcMain.handle('remote-daemon:update-client-state', async (_event, updates: unknown) => { + try { + if (!isRecord(updates)) { + throw new Error('Remote daemon client state update must be an object'); + } + + const current = getRemoteDaemonConfig(configManager.getConfig().remoteDaemon); + const nextState = buildNextClientState(current.client, updates); + const next = normalizeRemoteDaemonConfig({ + ...current, + client: { + ...current.client, + ...nextState, + }, + }); + + await configManager.updateConfig({ remoteDaemon: next }); + return { success: true, data: next.client }; + } catch (error) { + return { success: false, error: getErrorMessage(error, 'Failed to update remote daemon client state') }; + } + }); +} + +function getRemoteDaemonConfig(value: unknown): RemoteDaemonConfig { + return normalizeRemoteDaemonConfig(value); +} + +function buildNextClientState( + current: RemoteDaemonClientSettings, + updates: Record, +): Pick { + const nextMode: RemoteDaemonClientMode = + updates.mode === 'remote' || updates.mode === 'local' + ? updates.mode + : current.mode; + + let nextActiveProfileId = current.activeProfileId; + if (updates.activeProfileId === null) { + nextActiveProfileId = null; + } else if (typeof updates.activeProfileId === 'string') { + nextActiveProfileId = updates.activeProfileId; + } + + if (nextMode === 'remote' && !nextActiveProfileId) { + throw new Error('Remote mode requires an active connection profile'); + } + + if (nextActiveProfileId && !current.profiles.some((profile) => profile.id === nextActiveProfileId)) { + throw new Error(`Remote daemon connection profile "${nextActiveProfileId}" does not exist`); + } + + return { + mode: nextActiveProfileId ? nextMode : 'local', + activeProfileId: nextActiveProfileId, + }; +} + +function upsertById(items: T[], nextItem: T): T[] { + const existingIndex = items.findIndex((item) => item.id === nextItem.id); + if (existingIndex === -1) { + return [...items, nextItem]; + } + + return items.map((item, index) => (index === existingIndex ? nextItem : item)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} diff --git a/main/src/preload.ts b/main/src/preload.ts index fcde800b..41c091aa 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -2,6 +2,13 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { CreateSessionRequest, Session } from './types/session'; import type { AppConfig, UpdateConfigRequest } from './types/config'; import type { CreateProjectRequest, UpdateProjectRequest, Project } from '../../frontend/src/types/project'; +import type { + RemoteDaemonClientRecord, + RemoteDaemonClientSettings, + RemoteDaemonConfig, + RemoteDaemonHostConfig, + RemotePaneConnectionProfile, +} from '../../shared/types/remoteDaemon'; import type { ToolPanel } from '../../shared/types/panels'; interface LogEntry { @@ -556,7 +563,7 @@ contextBridge.exposeInMainWorld('electronAPI', { // Configuration config: { - get: (): Promise => invokeIpc('config:get'), + get: (): Promise> => invokeIpc('config:get'), update: (updates: UpdateConfigRequest): Promise => invokeIpc('config:update', updates), getSessionPreferences: (): Promise => invokeIpc('config:get-session-preferences'), updateSessionPreferences: (preferences: AppConfig['sessionCreationPreferences']): Promise => invokeIpc('config:update-session-preferences', preferences), @@ -564,6 +571,22 @@ contextBridge.exposeInMainWorld('electronAPI', { getMonospaceFonts: (): Promise => invokeIpc('config:get-monospace-fonts'), }, + remoteDaemon: { + getConfig: (): Promise> => invokeIpc('remote-daemon:get-config'), + updateHostConfig: (updates: Partial): Promise> => + invokeIpc('remote-daemon:update-host-config', updates), + upsertClientRecord: (record: RemoteDaemonClientRecord): Promise> => + invokeIpc('remote-daemon:upsert-client-record', record), + deleteClientRecord: (clientId: string): Promise> => + invokeIpc('remote-daemon:delete-client-record', clientId), + upsertConnectionProfile: (profile: RemotePaneConnectionProfile): Promise> => + invokeIpc('remote-daemon:upsert-connection-profile', profile), + deleteConnectionProfile: (profileId: string): Promise> => + invokeIpc('remote-daemon:delete-connection-profile', profileId), + updateClientState: (updates: Partial>): Promise> => + invokeIpc('remote-daemon:update-client-state', updates), + }, + // Prompts prompts: { getAll: (): Promise => invokeIpc('prompts:get-all'), diff --git a/main/src/services/configManager.ts b/main/src/services/configManager.ts index 705f9522..7b0a54b5 100644 --- a/main/src/services/configManager.ts +++ b/main/src/services/configManager.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'events'; import type { AnalyticsIdentity, AppConfig } from '../types/config'; +import { createDefaultRemoteDaemonConfig, normalizeRemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; import { DEFAULT_WORKTREE_FILE_SYNC_ENTRIES } from '../../../shared/types/worktreeFileSync'; import fs from 'fs/promises'; @@ -56,6 +57,7 @@ export class ConfigManager extends EventEmitter { posthogApiKey: 'phc_wir25CCsjr2NsZGEdlWNdvwcNG1XDjhxc9RyL5KDCf1', posthogHost: 'https://us.i.posthog.com' }, + remoteDaemon: createDefaultRemoteDaemonConfig(), terminalShortcuts: [ { id: 'default-root-cause', @@ -133,6 +135,7 @@ export class ConfigManager extends EventEmitter { ...this.config.analytics, ...loadedConfig.analytics }, + remoteDaemon: normalizeRemoteDaemonConfig(loadedConfig.remoteDaemon), // Use !== undefined to distinguish "user cleared all entries" (empty array → preserve) // from "field absent in config file" (→ use defaults) worktreeFileSync: loadedConfig.worktreeFileSync !== undefined @@ -256,7 +259,13 @@ export class ConfigManager extends EventEmitter { } async updateConfig(updates: Partial): Promise { - this.config = { ...this.config, ...updates }; + this.config = { + ...this.config, + ...updates, + remoteDaemon: 'remoteDaemon' in updates + ? normalizeRemoteDaemonConfig(updates.remoteDaemon) + : this.config.remoteDaemon, + }; await this.saveConfig(); // Clear PATH cache if additional paths were updated diff --git a/main/src/types/config.ts b/main/src/types/config.ts index 774506fb..02ea6214 100644 --- a/main/src/types/config.ts +++ b/main/src/types/config.ts @@ -1,3 +1,7 @@ +import type { CloudVmConfig } from '../../../shared/types/cloud'; +import type { RemoteDaemonConfig } from '../../../shared/types/remoteDaemon'; +import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; + export interface TerminalShortcut { id: string; label: string; @@ -34,9 +38,6 @@ export interface AnalyticsConfig { gitUserName?: string; } -import type { CloudVmConfig } from '../../../shared/types/cloud'; -import type { WorktreeFileSyncEntry } from '../../../shared/types/worktreeFileSync'; - export interface AppConfig { verbose?: boolean; anthropicApiKey?: string; @@ -103,6 +104,8 @@ export interface AppConfig { preferredShell?: 'auto' | 'gitbash' | 'powershell' | 'pwsh' | 'cmd'; // Cloud VM settings cloud?: CloudVmConfig; + // Self-hosted remote daemon settings and saved client profiles + remoteDaemon?: RemoteDaemonConfig; terminalFontFamily?: string; terminalFontSize?: number; } @@ -140,7 +143,7 @@ export interface UpdateConfigRequest { showAdvanced?: boolean; baseBranch?: string; }; - disableCommitFooter?: boolean; + enableCommitFooter?: boolean; // Use interactive mode for Claude CLI (persistent process with stdin instead of spawn-per-message) useInteractiveMode?: boolean; // Route PTY spawns through an isolated ptyHost UtilityProcess for crash isolation. @@ -158,6 +161,8 @@ export interface UpdateConfigRequest { preferredShell?: 'auto' | 'gitbash' | 'powershell' | 'pwsh' | 'cmd'; // Cloud VM settings cloud?: CloudVmConfig; + // Self-hosted remote daemon settings and saved client profiles + remoteDaemon?: RemoteDaemonConfig; terminalFontFamily?: string; terminalFontSize?: number; } diff --git a/shared/types/remoteDaemon.ts b/shared/types/remoteDaemon.ts new file mode 100644 index 00000000..2bb9494f --- /dev/null +++ b/shared/types/remoteDaemon.ts @@ -0,0 +1,165 @@ +export type RemoteDaemonTransport = 'http+sse'; +export type RemoteDaemonClientMode = 'local' | 'remote'; + +export interface RemoteDaemonHostConfig { + enabled: boolean; + listenHost: string; + listenPort: number; + pairingRequired: boolean; + allowInsecureHttpOnLoopback: boolean; +} + +export interface RemoteDaemonClientRecord { + id: string; + label: string; + createdAt: string; + tokenHash: string; + lastUsedAt?: string; +} + +export interface RemotePaneConnectionProfile { + id: string; + label: string; + baseUrl: string; + token: string; + transport: RemoteDaemonTransport; +} + +export interface RemoteDaemonHostSettings { + config: RemoteDaemonHostConfig; + clients: RemoteDaemonClientRecord[]; +} + +export interface RemoteDaemonClientSettings { + profiles: RemotePaneConnectionProfile[]; + activeProfileId: string | null; + mode: RemoteDaemonClientMode; +} + +export interface RemoteDaemonConfig { + host: RemoteDaemonHostSettings; + client: RemoteDaemonClientSettings; +} + +export interface RemoteInvokeRequest { + channel: string; + args: unknown[]; +} + +export interface RemoteDaemonEventEnvelope { + channel: string; + args: unknown[]; + timestamp: string; +} + +export const DEFAULT_REMOTE_DAEMON_HOST_CONFIG: RemoteDaemonHostConfig = { + enabled: false, + listenHost: '127.0.0.1', + listenPort: 42137, + pairingRequired: true, + allowInsecureHttpOnLoopback: true, +}; + +export function createDefaultRemoteDaemonConfig(): RemoteDaemonConfig { + return { + host: { + config: { ...DEFAULT_REMOTE_DAEMON_HOST_CONFIG }, + clients: [], + }, + client: { + profiles: [], + activeProfileId: null, + mode: 'local', + }, + }; +} + +export function isRemoteDaemonClientRecord(value: unknown): value is RemoteDaemonClientRecord { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.id === 'string' && + typeof value.label === 'string' && + typeof value.createdAt === 'string' && + typeof value.tokenHash === 'string' && + (value.lastUsedAt === undefined || typeof value.lastUsedAt === 'string') + ); +} + +export function isRemotePaneConnectionProfile(value: unknown): value is RemotePaneConnectionProfile { + if (!isRecord(value)) { + return false; + } + + return ( + typeof value.id === 'string' && + typeof value.label === 'string' && + typeof value.baseUrl === 'string' && + typeof value.token === 'string' && + value.transport === 'http+sse' + ); +} + +export function normalizeRemoteDaemonConfig(value: unknown): RemoteDaemonConfig { + const defaults = createDefaultRemoteDaemonConfig(); + if (!isRecord(value)) { + return defaults; + } + + const host = isRecord(value.host) ? value.host : {}; + const hostConfig = isRecord(host.config) ? host.config : {}; + const clients = Array.isArray(host.clients) + ? host.clients.filter(isRemoteDaemonClientRecord) + : []; + + const client = isRecord(value.client) ? value.client : {}; + const profiles = Array.isArray(client.profiles) + ? client.profiles.filter(isRemotePaneConnectionProfile) + : []; + + let activeProfileId = typeof client.activeProfileId === 'string' ? client.activeProfileId : null; + if (activeProfileId && !profiles.some((profile) => profile.id === activeProfileId)) { + activeProfileId = null; + } + + return { + host: { + config: { + enabled: readBoolean(hostConfig.enabled, defaults.host.config.enabled), + listenHost: readString(hostConfig.listenHost, defaults.host.config.listenHost), + listenPort: readPort(hostConfig.listenPort, defaults.host.config.listenPort), + pairingRequired: readBoolean(hostConfig.pairingRequired, defaults.host.config.pairingRequired), + allowInsecureHttpOnLoopback: readBoolean( + hostConfig.allowInsecureHttpOnLoopback, + defaults.host.config.allowInsecureHttpOnLoopback, + ), + }, + clients: [...clients], + }, + client: { + profiles: [...profiles], + activeProfileId, + mode: activeProfileId && client.mode === 'remote' ? 'remote' : 'local', + }, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readBoolean(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +function readString(value: unknown, fallback: string): string { + return typeof value === 'string' && value.trim().length > 0 ? value : fallback; +} + +function readPort(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 65535 + ? value + : fallback; +} From bf5522e22d416dbce10ffabd4619a3d34bf86e3a Mon Sep 17 00:00:00 2001 From: ParsaKhaz Date: Thu, 14 May 2026 14:09:54 -0700 Subject: [PATCH 2/2] fix: reject empty remote daemon profiles --- main/src/ipc/remoteDaemon.test.ts | 20 ++++++++++++++++++++ shared/types/remoteDaemon.ts | 22 +++++++++++++--------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/main/src/ipc/remoteDaemon.test.ts b/main/src/ipc/remoteDaemon.test.ts index 49a6d00a..e7c332c4 100644 --- a/main/src/ipc/remoteDaemon.test.ts +++ b/main/src/ipc/remoteDaemon.test.ts @@ -166,4 +166,24 @@ describe('remote daemon IPC', () => { data: createDefaultRemoteDaemonConfig(), }); }); + + it('rejects connection profiles with empty auth or endpoint fields', async () => { + const ipcMain = createIpcMainStub(); + const configManager = createConfigManagerStub(); + + registerRemoteDaemonHandlers(ipcMain, { configManager }); + + const upsertProfile = ipcMain.handlers.get('remote-daemon:upsert-connection-profile'); + + await expect(upsertProfile?.({}, { + id: 'profile-1', + label: 'Broken profile', + baseUrl: ' ', + token: '', + transport: 'http+sse', + })).resolves.toEqual({ + success: false, + error: 'Remote daemon connection profile is invalid', + }); + }); }); diff --git a/shared/types/remoteDaemon.ts b/shared/types/remoteDaemon.ts index 2bb9494f..89e0a3cc 100644 --- a/shared/types/remoteDaemon.ts +++ b/shared/types/remoteDaemon.ts @@ -80,11 +80,11 @@ export function isRemoteDaemonClientRecord(value: unknown): value is RemoteDaemo } return ( - typeof value.id === 'string' && - typeof value.label === 'string' && - typeof value.createdAt === 'string' && - typeof value.tokenHash === 'string' && - (value.lastUsedAt === undefined || typeof value.lastUsedAt === 'string') + isNonEmptyString(value.id) && + isNonEmptyString(value.label) && + isNonEmptyString(value.createdAt) && + isNonEmptyString(value.tokenHash) && + (value.lastUsedAt === undefined || isNonEmptyString(value.lastUsedAt)) ); } @@ -94,10 +94,10 @@ export function isRemotePaneConnectionProfile(value: unknown): value is RemotePa } return ( - typeof value.id === 'string' && - typeof value.label === 'string' && - typeof value.baseUrl === 'string' && - typeof value.token === 'string' && + isNonEmptyString(value.id) && + isNonEmptyString(value.label) && + isNonEmptyString(value.baseUrl) && + isNonEmptyString(value.token) && value.transport === 'http+sse' ); } @@ -163,3 +163,7 @@ function readPort(value: unknown, fallback: number): number { ? value : fallback; } + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +}