diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index b977c17701da..e0322da7b6d8 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -100,10 +100,10 @@ export default function MCPUIResourceRenderer({ const fetchProxyUrl = async () => { try { - const baseUrl = await window.electron.getGoosedHostPort(); + const gooseApiHost = await window.electron.getGoosedHostPort(); const secretKey = await window.electron.getSecretKey(); - if (baseUrl && secretKey) { - setProxyUrl(`${baseUrl}/mcp-ui-proxy?secret=${encodeURIComponent(secretKey)}`); + if (gooseApiHost && secretKey) { + setProxyUrl(`${gooseApiHost}/mcp-ui-proxy?secret=${encodeURIComponent(secretKey)}`); } else { console.error('Failed to get goosed host/port or secret key'); } diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx index e414134e9c1d..7c19f88c7227 100644 --- a/ui/desktop/src/components/settings/SettingsView.tsx +++ b/ui/desktop/src/components/settings/SettingsView.tsx @@ -3,6 +3,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { View, ViewOptions } from '../../utils/navigationUtils'; import ModelsSection from './models/ModelsSection'; import SessionSharingSection from './sessions/SessionSharingSection'; +import ExternalBackendSection from './app/ExternalBackendSection'; import AppSettingsSection from './app/AppSettingsSection'; import ConfigSettings from './config/ConfigSettings'; import { ExtensionConfig } from '../../api'; @@ -127,7 +128,10 @@ export default function SettingsView({ value="sharing" className="mt-0 focus-visible:outline-none focus-visible:ring-0" > - +
+ + +
; +} + +const DEFAULT_CONFIG: ExternalGoosedConfig = { + enabled: false, + url: '', + secret: '', +}; + +function parseConfig(partial: Partial | undefined): ExternalGoosedConfig { + return { + enabled: partial?.enabled ?? DEFAULT_CONFIG.enabled, + url: partial?.url ?? DEFAULT_CONFIG.url, + secret: partial?.secret ?? DEFAULT_CONFIG.secret, + }; +} + +export default function ExternalBackendSection() { + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [isSaving, setIsSaving] = useState(false); + const [urlError, setUrlError] = useState(null); + + useEffect(() => { + const loadSettings = async () => { + const settings = (await window.electron.getSettings()) as Settings | null; + setConfig(parseConfig(settings?.externalGoosed)); + }; + loadSettings(); + }, []); + + const validateUrl = (value: string): boolean => { + if (!value) { + setUrlError(null); + return true; + } + try { + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol)) { + setUrlError('URL must use http or https protocol'); + return false; + } + setUrlError(null); + return true; + } catch { + setUrlError('Invalid URL format'); + return false; + } + }; + + const saveConfig = async (newConfig: ExternalGoosedConfig): Promise => { + setIsSaving(true); + try { + const currentSettings = ((await window.electron.getSettings()) as Settings) || {}; + await window.electron.saveSettings({ + ...currentSettings, + externalGoosed: newConfig, + }); + } catch (error) { + console.error('Failed to save external backend settings:', error); + } finally { + setIsSaving(false); + } + }; + + const updateField = ( + field: K, + value: ExternalGoosedConfig[K] + ) => { + const newConfig = { ...config, [field]: value }; + setConfig(newConfig); + return newConfig; + }; + + const handleUrlChange = (value: string) => { + updateField('url', value); + validateUrl(value); + }; + + const handleUrlBlur = async () => { + if (validateUrl(config.url)) { + await saveConfig(config); + } + }; + + return ( +
+ + + Goose Server + + By default goose launches a server for you, use this to connect to an external goose + server + + + +
+
+

Use external server

+

+ Connect to a goose server running elsewhere (requires app restart) +

+
+
+ saveConfig(updateField('enabled', checked))} + disabled={isSaving} + variant="mono" + /> +
+
+ + {config.enabled && ( + <> +
+ + handleUrlChange(e.target.value)} + onBlur={handleUrlBlur} + disabled={isSaving} + className={urlError ? 'border-red-500' : ''} + /> + {urlError && ( +

+ + {urlError} +

+ )} +
+ +
+ + updateField('secret', e.target.value)} + onBlur={() => saveConfig(config)} + disabled={isSaving} + /> +

+ The secret key configured on the goosed server (GOOSE_SERVER__SECRET_KEY) +

+
+ +
+

+ Note: Changes require restarting Goose to take effect. New chat + windows will connect to the external server. +

+
+ + )} +
+
+
+ ); +} diff --git a/ui/desktop/src/config.ts b/ui/desktop/src/config.ts index d9f412dd4ecd..f5f96eb85fde 100644 --- a/ui/desktop/src/config.ts +++ b/ui/desktop/src/config.ts @@ -1,9 +1,5 @@ -// Helper to construct API endpoints export const getApiUrl = (endpoint: string): string => { - const baseUrl = - String(window.appConfig.get('GOOSE_API_HOST') || '') + - ':' + - String(window.appConfig.get('GOOSE_PORT') || ''); + const gooseApiHost = String(window.appConfig.get('GOOSE_API_HOST') || ''); const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; - return `${baseUrl}${cleanEndpoint}`; + return `${gooseApiHost}${cleanEndpoint}`; }; diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index eda35226c578..9a5c14f6f554 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -10,6 +10,7 @@ import { Buffer } from 'node:buffer'; import { status } from './api'; import { Client } from './api/client'; +import { ExternalGoosedConfig } from './utils/settings'; export const findAvailablePort = (): Promise => { return new Promise((resolve, _reject) => { @@ -53,11 +54,15 @@ export const checkServerStatus = async (client: Client, errorLog: string[]): Pro return false; }; -const connectToExternalBackend = async ( - workingDir: string, - port: number = 3000 -): Promise<[number, string, ChildProcess, string[]]> => { - log.info(`Using external goosed backend on port ${port}`); +export interface GoosedResult { + baseUrl: string; + workingDir: string; + process: ChildProcess; + errorLog: string[]; +} + +const connectToExternalBackend = (workingDir: string, url: string): GoosedResult => { + log.info(`Using external goosed backend at ${url}`); const mockProcess = { pid: undefined, @@ -66,7 +71,7 @@ const connectToExternalBackend = async ( }, } as ChildProcess; - return [port, workingDir, mockProcess, []]; + return { baseUrl: url, workingDir, process: mockProcess, errorLog: [] }; }; interface GooseProcessEnv { @@ -81,18 +86,26 @@ interface GooseProcessEnv { GOOSE_SERVER__SECRET_KEY?: string; } -export const startGoosed = async ( - app: App, - serverSecret: string, - dir: string, - env: Partial = {} -): Promise<[number, string, ChildProcess, string[]]> => { +export interface StartGoosedOptions { + app: App; + serverSecret: string; + dir: string; + env?: Partial; + externalGoosed?: ExternalGoosedConfig; +} + +export const startGoosed = async (options: StartGoosedOptions): Promise => { + const { app, serverSecret, dir: inputDir, env = {}, externalGoosed } = options; const isWindows = process.platform === 'win32'; const homeDir = os.homedir(); - dir = path.resolve(path.normalize(dir)); + const dir = path.resolve(path.normalize(inputDir)); + + if (externalGoosed?.enabled && externalGoosed.url) { + return connectToExternalBackend(dir, externalGoosed.url); + } if (process.env.GOOSE_EXTERNAL_BACKEND) { - return connectToExternalBackend(dir, 3000); + return connectToExternalBackend(dir, 'http://127.0.0.1:3000'); } let goosedPath = getGoosedBinaryPath(app); @@ -105,25 +118,18 @@ export const startGoosed = async ( log.info(`Starting goosed from: ${resolvedGoosedPath} on port ${port} in dir ${dir}`); const additionalEnv: GooseProcessEnv = { - // Set HOME for UNIX-like systems HOME: homeDir, - // Set USERPROFILE for Windows USERPROFILE: homeDir, - // Set APPDATA for Windows APPDATA: process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), - // Set LOCAL_APPDATA for Windows LOCALAPPDATA: process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'), - // Set PATH to include the binary directory PATH: `${path.dirname(resolvedGoosedPath)}${path.delimiter}${process.env.PATH || ''}`, GOOSE_PORT: String(port), GOOSE_SERVER__SECRET_KEY: serverSecret, - // Add any additional environment variables passed in ...env, } as GooseProcessEnv; const processEnv: GooseProcessEnv = { ...process.env, ...additionalEnv } as GooseProcessEnv; - // Ensure proper executable path on Windows if (isWindows && !resolvedGoosedPath.toLowerCase().endsWith('.exe')) { goosedPath = resolvedGoosedPath + '.exe'; } else { @@ -135,15 +141,11 @@ export const startGoosed = async ( cwd: dir, env: processEnv, stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'], - // Hide terminal window on Windows windowsHide: true, - // Run detached on Windows only to avoid terminal windows detached: isWindows, - // Never use shell to avoid command injection - this is critical for security shell: false, }; - // Log spawn options for debugging (excluding sensitive env vars) const safeSpawnOptions = { ...spawnOptions, env: Object.keys(spawnOptions.env || {}).reduce( @@ -160,12 +162,10 @@ export const startGoosed = async ( }; log.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2)); - // Security: Use only hardcoded, safe arguments const safeArgs = ['agent']; const goosedProcess: ChildProcess = spawn(goosedPath, safeArgs, spawnOptions); - // Only unref on Windows to allow it to run independently of the parent if (isWindows && goosedProcess.unref) { goosedProcess.unref(); } @@ -191,7 +191,7 @@ export const startGoosed = async ( goosedProcess.on('error', (err: Error) => { log.error(`Failed to start goosed on port ${port} and dir ${dir}`, err); - throw err; // Propagate the error + throw err; }); const try_kill_goose = () => { @@ -207,14 +207,18 @@ export const startGoosed = async ( } }; - // Ensure goosed is terminated when the app quits app.on('will-quit', () => { log.info('App quitting, terminating goosed server'); try_kill_goose(); }); log.info(`Goosed server successfully started on port ${port}`); - return [port, dir, goosedProcess, stderrLines]; + return { + baseUrl: `http://127.0.0.1:${port}`, + workingDir: dir, + process: goosedProcess, + errorLog: stderrLines, + }; }; const getGoosedBinaryPath = (app: Electron.App): string => { diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 7348b21318f7..b944615dd2e6 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -466,16 +466,23 @@ const getBundledConfig = (): BundledConfig => { const { defaultProvider, defaultModel, predefinedModels, baseUrlShare, version } = getBundledConfig(); -const SERVER_SECRET = process.env.GOOSE_EXTERNAL_BACKEND - ? 'test' - : crypto.randomBytes(32).toString('hex'); +const GENERATED_SECRET = crypto.randomBytes(32).toString('hex'); + +const getServerSecret = (settings: ReturnType): string => { + if (settings.externalGoosed?.enabled && settings.externalGoosed.secret) { + return settings.externalGoosed.secret; + } + if (process.env.GOOSE_EXTERNAL_BACKEND) { + return 'test'; + } + return GENERATED_SECRET; +}; let appConfig = { GOOSE_DEFAULT_PROVIDER: defaultProvider, GOOSE_DEFAULT_MODEL: defaultModel, GOOSE_PREDEFINED_MODELS: predefinedModels, GOOSE_API_HOST: 'http://127.0.0.1', - GOOSE_PORT: 0, GOOSE_WORKING_DIR: '', // If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode) GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true', @@ -503,15 +510,18 @@ const createChat = async ( ) => { updateEnvironmentVariables(envToggles); - const envVars = { - GOOSE_PATH_ROOT: process.env.GOOSE_PATH_ROOT, - }; - const [port, workingDir, goosedProcess, errorLog] = await startGoosed( + const settings = loadSettings(); + const serverSecret = getServerSecret(settings); + + const goosedResult = await startGoosed({ app, - SERVER_SECRET, - dir || os.homedir(), - envVars - ); + serverSecret, + dir: dir || os.homedir(), + env: { GOOSE_PATH_ROOT: process.env.GOOSE_PATH_ROOT }, + externalGoosed: settings.externalGoosed, + }); + + const { baseUrl, workingDir, process: goosedProcess, errorLog } = goosedResult; const mainWindowState = windowStateKeeper({ defaultWidth: 940, @@ -534,14 +544,13 @@ const createChat = async ( webPreferences: { spellcheck: true, preload: path.join(__dirname, 'preload.js'), - // Enable features needed for Web Speech API webSecurity: true, nodeIntegration: false, contextIsolation: true, additionalArguments: [ JSON.stringify({ ...appConfig, - GOOSE_PORT: port, + GOOSE_API_HOST: baseUrl, GOOSE_WORKING_DIR: workingDir, REQUEST_DIR: dir, GOOSE_BASE_URL_SHARE: baseUrlShare, @@ -552,7 +561,7 @@ const createChat = async ( scheduledJobId: scheduledJobId, }), ], - partition: 'persist:goose', // Add this line to ensure persistence + partition: 'persist:goose', }, }); @@ -567,10 +576,10 @@ const createChat = async ( const goosedClient = createClient( createConfig({ - baseUrl: `http://127.0.0.1:${port}`, + baseUrl, headers: { 'Content-Type': 'application/json', - 'X-Secret-Key': SERVER_SECRET, + 'X-Secret-Key': serverSecret, }, }) ); @@ -578,13 +587,41 @@ const createChat = async ( const serverReady = await checkServerStatus(goosedClient, errorLog); if (!serverReady) { - dialog.showMessageBoxSync({ - type: 'error', - title: 'Goose Failed to Start', - message: 'The backend server failed to start.', - detail: errorLog.join('\n'), - buttons: ['OK'], - }); + const isUsingExternalBackend = settings.externalGoosed?.enabled; + + if (isUsingExternalBackend) { + const response = dialog.showMessageBoxSync({ + type: 'error', + title: 'External Backend Unreachable', + message: `Could not connect to external backend at ${settings.externalGoosed?.url}`, + detail: 'The external goosed server may not be running.', + buttons: ['Disable External Backend & Retry', 'Quit'], + defaultId: 0, + cancelId: 1, + }); + + if (response === 0) { + const updatedSettings = { + ...settings, + externalGoosed: { + enabled: false, + url: settings.externalGoosed?.url || '', + secret: settings.externalGoosed?.secret || '', + }, + }; + saveSettings(updatedSettings); + mainWindow.destroy(); + return createChat(app, initialMessage, dir); + } + } else { + dialog.showMessageBoxSync({ + type: 'error', + title: 'Goose Failed to Start', + message: 'The backend server failed to start.', + detail: errorLog.join('\n'), + buttons: ['OK'], + }); + } app.quit(); } @@ -1166,8 +1203,19 @@ ipcMain.handle('get-settings', () => { } }); +ipcMain.handle('save-settings', (_event, settings) => { + try { + saveSettings(settings); + return true; + } catch (error) { + console.error('Error saving settings:', error); + return false; + } +}); + ipcMain.handle('get-secret-key', () => { - return SERVER_SECRET; + const settings = loadSettings(); + return getServerSecret(settings); }); ipcMain.handle('get-goosed-host-port', async (event) => { @@ -1763,6 +1811,28 @@ async function appMain() { } }); + const buildConnectSrc = (): string => { + const sources = [ + "'self'", + 'http://127.0.0.1:*', + 'https://api.github.com', + 'https://github.com', + 'https://objects.githubusercontent.com', + ]; + + const settings = loadSettings(); + if (settings.externalGoosed?.enabled && settings.externalGoosed.url) { + try { + const externalUrl = new URL(settings.externalGoosed.url); + sources.push(externalUrl.origin); + } catch { + console.warn('Invalid external goosed URL in settings, skipping CSP entry'); + } + } + + return sources.join(' '); + }; + // Add CSP headers to all sessions session.defaultSession.webRequest.onHeadersReceived((details, callback) => { callback({ @@ -1770,31 +1840,18 @@ async function appMain() { ...details.responseHeaders, 'Content-Security-Policy': "default-src 'self';" + - // Allow inline styles since we use them in our React components "style-src 'self' 'unsafe-inline';" + - // Scripts from our app and inline scripts (for theme initialization) "script-src 'self' 'unsafe-inline';" + - // Images from our app and data: URLs (for base64 images) "img-src 'self' data: https:;" + - // Connect to our local API and specific external services - "connect-src 'self' http://127.0.0.1:* https://api.github.com https://github.com https://objects.githubusercontent.com" + - // Don't allow any plugins + `connect-src ${buildConnectSrc()};` + "object-src 'none';" + - // Allow all frames (iframes) "frame-src 'self' https: http:;" + - // Font sources - allow self, data URLs, and external fonts "font-src 'self' data: https:;" + - // Media sources - allow microphone "media-src 'self' mediastream:;" + - // Form actions "form-action 'none';" + - // Base URI restriction "base-uri 'self';" + - // Manifest files "manifest-src 'self';" + - // Worker sources "worker-src 'self';" + - // Upgrade insecure requests 'upgrade-insecure-requests;', }, }); diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 69696ae86619..f60924bc6b79 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -75,6 +75,7 @@ type ElectronAPI = { setDockIcon: (show: boolean) => Promise; getDockIconState: () => Promise; getSettings: () => Promise; + saveSettings: (settings: unknown) => Promise; getSecretKey: () => Promise; getGoosedHostPort: () => Promise; setWakelock: (enable: boolean) => Promise; @@ -177,6 +178,7 @@ const electronAPI: ElectronAPI = { setDockIcon: (show: boolean) => ipcRenderer.invoke('set-dock-icon', show), getDockIconState: () => ipcRenderer.invoke('get-dock-icon-state'), getSettings: () => ipcRenderer.invoke('get-settings'), + saveSettings: (settings: unknown) => ipcRenderer.invoke('save-settings', settings), getSecretKey: () => ipcRenderer.invoke('get-secret-key'), getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'), setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable), diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx index 25928db1e502..d9191c1070e5 100644 --- a/ui/desktop/src/renderer.tsx +++ b/ui/desktop/src/renderer.tsx @@ -13,14 +13,14 @@ const App = lazy(() => import('./App')); if (!isLauncher) { console.log('window created, getting goosed connection info'); - const baseUrl = await window.electron.getGoosedHostPort(); - if (baseUrl === null) { + const gooseApiHost = await window.electron.getGoosedHostPort(); + if (gooseApiHost === null) { window.alert('failed to start goose backend process'); return; } - console.log('connecting at', baseUrl); + console.log('connecting at', gooseApiHost); client.setConfig({ - baseUrl, + baseUrl: gooseApiHost, headers: { 'Content-Type': 'application/json', 'X-Secret-Key': await window.electron.getSecretKey(), diff --git a/ui/desktop/src/utils/settings.ts b/ui/desktop/src/utils/settings.ts index d85267ff6814..edc3115b1aa1 100644 --- a/ui/desktop/src/utils/settings.ts +++ b/ui/desktop/src/utils/settings.ts @@ -7,11 +7,18 @@ export interface EnvToggles { GOOSE_SERVER__COMPUTER_CONTROLLER: boolean; } +export interface ExternalGoosedConfig { + enabled: boolean; + url: string; + secret: string; +} + export interface Settings { envToggles: EnvToggles; showMenuBarIcon: boolean; showDockIcon: boolean; enableWakelock: boolean; + externalGoosed?: ExternalGoosedConfig; } const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');