feat: external goosed server (#5978)

supports connecting to an external goosed in client/server mode
This commit is contained in:
Michael Neale
2025-12-15 14:04:39 +11:00
committed by GitHub
parent 108ffad4a4
commit 208417e751
10 changed files with 334 additions and 83 deletions
@@ -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');
}
@@ -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"
>
<SessionSharingSection />
<div className="space-y-8">
<SessionSharingSection />
<ExternalBackendSection />
</div>
</TabsContent>
<TabsContent
@@ -5,6 +5,7 @@ import { Settings, RefreshCw, ExternalLink } from 'lucide-react';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
import UpdateSection from './UpdateSection';
import TunnelSection from '../tunnel/TunnelSection';
import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates';
import { getApiUrl } from '../../../config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
@@ -0,0 +1,180 @@
import { useState, useEffect } from 'react';
import { Switch } from '../../ui/switch';
import { Input } from '../../ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
import { AlertCircle } from 'lucide-react';
interface ExternalGoosedConfig {
enabled: boolean;
url: string;
secret: string;
}
interface Settings {
externalGoosed?: Partial<ExternalGoosedConfig>;
}
const DEFAULT_CONFIG: ExternalGoosedConfig = {
enabled: false,
url: '',
secret: '',
};
function parseConfig(partial: Partial<ExternalGoosedConfig> | 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<ExternalGoosedConfig>(DEFAULT_CONFIG);
const [isSaving, setIsSaving] = useState(false);
const [urlError, setUrlError] = useState<string | null>(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<void> => {
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 = <K extends keyof ExternalGoosedConfig>(
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 (
<section id="external-backend" className="space-y-4 pr-4 mt-1">
<Card className="pb-2">
<CardHeader className="pb-0">
<CardTitle>Goose Server</CardTitle>
<CardDescription>
By default goose launches a server for you, use this to connect to an external goose
server
</CardDescription>
</CardHeader>
<CardContent className="pt-4 space-y-4 px-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-text-default text-xs">Use external server</h3>
<p className="text-xs text-text-muted max-w-md mt-[2px]">
Connect to a goose server running elsewhere (requires app restart)
</p>
</div>
<div className="flex items-center">
<Switch
checked={config.enabled}
onCheckedChange={(checked) => saveConfig(updateField('enabled', checked))}
disabled={isSaving}
variant="mono"
/>
</div>
</div>
{config.enabled && (
<>
<div className="space-y-2">
<label htmlFor="external-url" className="text-text-default text-xs">
Server URL
</label>
<Input
id="external-url"
type="url"
placeholder="http://127.0.0.1:3000"
value={config.url}
onChange={(e) => handleUrlChange(e.target.value)}
onBlur={handleUrlBlur}
disabled={isSaving}
className={urlError ? 'border-red-500' : ''}
/>
{urlError && (
<p className="text-xs text-red-500 flex items-center gap-1">
<AlertCircle size={12} />
{urlError}
</p>
)}
</div>
<div className="space-y-2">
<label htmlFor="external-secret" className="text-text-default text-xs">
Secret Key
</label>
<Input
id="external-secret"
type="password"
placeholder="Enter the server's secret key"
value={config.secret}
onChange={(e) => updateField('secret', e.target.value)}
onBlur={() => saveConfig(config)}
disabled={isSaving}
/>
<p className="text-xs text-text-muted">
The secret key configured on the goosed server (GOOSE_SERVER__SECRET_KEY)
</p>
</div>
<div className="bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<p className="text-xs text-amber-800 dark:text-amber-200">
<strong>Note:</strong> Changes require restarting Goose to take effect. New chat
windows will connect to the external server.
</p>
</div>
</>
)}
</CardContent>
</Card>
</section>
);
}
+2 -6
View File
@@ -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}`;
};
+34 -30
View File
@@ -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<number> => {
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<GooseProcessEnv> = {}
): Promise<[number, string, ChildProcess, string[]]> => {
export interface StartGoosedOptions {
app: App;
serverSecret: string;
dir: string;
env?: Partial<GooseProcessEnv>;
externalGoosed?: ExternalGoosedConfig;
}
export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedResult> => {
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 => {
+96 -39
View File
@@ -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<typeof loadSettings>): 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;',
},
});
+2
View File
@@ -75,6 +75,7 @@ type ElectronAPI = {
setDockIcon: (show: boolean) => Promise<boolean>;
getDockIconState: () => Promise<boolean>;
getSettings: () => Promise<unknown | null>;
saveSettings: (settings: unknown) => Promise<boolean>;
getSecretKey: () => Promise<string>;
getGoosedHostPort: () => Promise<string | null>;
setWakelock: (enable: boolean) => Promise<boolean>;
@@ -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),
+4 -4
View File
@@ -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(),
+7
View File
@@ -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');