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');