feat: external goosed server (#5978)
supports connecting to an external goosed in client/server mode
This commit is contained in:
@@ -100,10 +100,10 @@ export default function MCPUIResourceRenderer({
|
|||||||
|
|
||||||
const fetchProxyUrl = async () => {
|
const fetchProxyUrl = async () => {
|
||||||
try {
|
try {
|
||||||
const baseUrl = await window.electron.getGoosedHostPort();
|
const gooseApiHost = await window.electron.getGoosedHostPort();
|
||||||
const secretKey = await window.electron.getSecretKey();
|
const secretKey = await window.electron.getSecretKey();
|
||||||
if (baseUrl && secretKey) {
|
if (gooseApiHost && secretKey) {
|
||||||
setProxyUrl(`${baseUrl}/mcp-ui-proxy?secret=${encodeURIComponent(secretKey)}`);
|
setProxyUrl(`${gooseApiHost}/mcp-ui-proxy?secret=${encodeURIComponent(secretKey)}`);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to get goosed host/port or secret key');
|
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 { View, ViewOptions } from '../../utils/navigationUtils';
|
||||||
import ModelsSection from './models/ModelsSection';
|
import ModelsSection from './models/ModelsSection';
|
||||||
import SessionSharingSection from './sessions/SessionSharingSection';
|
import SessionSharingSection from './sessions/SessionSharingSection';
|
||||||
|
import ExternalBackendSection from './app/ExternalBackendSection';
|
||||||
import AppSettingsSection from './app/AppSettingsSection';
|
import AppSettingsSection from './app/AppSettingsSection';
|
||||||
import ConfigSettings from './config/ConfigSettings';
|
import ConfigSettings from './config/ConfigSettings';
|
||||||
import { ExtensionConfig } from '../../api';
|
import { ExtensionConfig } from '../../api';
|
||||||
@@ -127,7 +128,10 @@ export default function SettingsView({
|
|||||||
value="sharing"
|
value="sharing"
|
||||||
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
|
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
|
||||||
>
|
>
|
||||||
<SessionSharingSection />
|
<div className="space-y-8">
|
||||||
|
<SessionSharingSection />
|
||||||
|
<ExternalBackendSection />
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Settings, RefreshCw, ExternalLink } from 'lucide-react';
|
|||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||||
import UpdateSection from './UpdateSection';
|
import UpdateSection from './UpdateSection';
|
||||||
import TunnelSection from '../tunnel/TunnelSection';
|
import TunnelSection from '../tunnel/TunnelSection';
|
||||||
|
|
||||||
import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates';
|
import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates';
|
||||||
import { getApiUrl } from '../../../config';
|
import { getApiUrl } from '../../../config';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
// Helper to construct API endpoints
|
|
||||||
export const getApiUrl = (endpoint: string): string => {
|
export const getApiUrl = (endpoint: string): string => {
|
||||||
const baseUrl =
|
const gooseApiHost = String(window.appConfig.get('GOOSE_API_HOST') || '');
|
||||||
String(window.appConfig.get('GOOSE_API_HOST') || '') +
|
|
||||||
':' +
|
|
||||||
String(window.appConfig.get('GOOSE_PORT') || '');
|
|
||||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||||
return `${baseUrl}${cleanEndpoint}`;
|
return `${gooseApiHost}${cleanEndpoint}`;
|
||||||
};
|
};
|
||||||
|
|||||||
+34
-30
@@ -10,6 +10,7 @@ import { Buffer } from 'node:buffer';
|
|||||||
|
|
||||||
import { status } from './api';
|
import { status } from './api';
|
||||||
import { Client } from './api/client';
|
import { Client } from './api/client';
|
||||||
|
import { ExternalGoosedConfig } from './utils/settings';
|
||||||
|
|
||||||
export const findAvailablePort = (): Promise<number> => {
|
export const findAvailablePort = (): Promise<number> => {
|
||||||
return new Promise((resolve, _reject) => {
|
return new Promise((resolve, _reject) => {
|
||||||
@@ -53,11 +54,15 @@ export const checkServerStatus = async (client: Client, errorLog: string[]): Pro
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectToExternalBackend = async (
|
export interface GoosedResult {
|
||||||
workingDir: string,
|
baseUrl: string;
|
||||||
port: number = 3000
|
workingDir: string;
|
||||||
): Promise<[number, string, ChildProcess, string[]]> => {
|
process: ChildProcess;
|
||||||
log.info(`Using external goosed backend on port ${port}`);
|
errorLog: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectToExternalBackend = (workingDir: string, url: string): GoosedResult => {
|
||||||
|
log.info(`Using external goosed backend at ${url}`);
|
||||||
|
|
||||||
const mockProcess = {
|
const mockProcess = {
|
||||||
pid: undefined,
|
pid: undefined,
|
||||||
@@ -66,7 +71,7 @@ const connectToExternalBackend = async (
|
|||||||
},
|
},
|
||||||
} as ChildProcess;
|
} as ChildProcess;
|
||||||
|
|
||||||
return [port, workingDir, mockProcess, []];
|
return { baseUrl: url, workingDir, process: mockProcess, errorLog: [] };
|
||||||
};
|
};
|
||||||
|
|
||||||
interface GooseProcessEnv {
|
interface GooseProcessEnv {
|
||||||
@@ -81,18 +86,26 @@ interface GooseProcessEnv {
|
|||||||
GOOSE_SERVER__SECRET_KEY?: string;
|
GOOSE_SERVER__SECRET_KEY?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const startGoosed = async (
|
export interface StartGoosedOptions {
|
||||||
app: App,
|
app: App;
|
||||||
serverSecret: string,
|
serverSecret: string;
|
||||||
dir: string,
|
dir: string;
|
||||||
env: Partial<GooseProcessEnv> = {}
|
env?: Partial<GooseProcessEnv>;
|
||||||
): Promise<[number, string, ChildProcess, string[]]> => {
|
externalGoosed?: ExternalGoosedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedResult> => {
|
||||||
|
const { app, serverSecret, dir: inputDir, env = {}, externalGoosed } = options;
|
||||||
const isWindows = process.platform === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const homeDir = os.homedir();
|
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) {
|
if (process.env.GOOSE_EXTERNAL_BACKEND) {
|
||||||
return connectToExternalBackend(dir, 3000);
|
return connectToExternalBackend(dir, 'http://127.0.0.1:3000');
|
||||||
}
|
}
|
||||||
|
|
||||||
let goosedPath = getGoosedBinaryPath(app);
|
let goosedPath = getGoosedBinaryPath(app);
|
||||||
@@ -105,25 +118,18 @@ export const startGoosed = async (
|
|||||||
log.info(`Starting goosed from: ${resolvedGoosedPath} on port ${port} in dir ${dir}`);
|
log.info(`Starting goosed from: ${resolvedGoosedPath} on port ${port} in dir ${dir}`);
|
||||||
|
|
||||||
const additionalEnv: GooseProcessEnv = {
|
const additionalEnv: GooseProcessEnv = {
|
||||||
// Set HOME for UNIX-like systems
|
|
||||||
HOME: homeDir,
|
HOME: homeDir,
|
||||||
// Set USERPROFILE for Windows
|
|
||||||
USERPROFILE: homeDir,
|
USERPROFILE: homeDir,
|
||||||
// Set APPDATA for Windows
|
|
||||||
APPDATA: process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
|
APPDATA: process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
|
||||||
// Set LOCAL_APPDATA for Windows
|
|
||||||
LOCALAPPDATA: process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
|
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 || ''}`,
|
PATH: `${path.dirname(resolvedGoosedPath)}${path.delimiter}${process.env.PATH || ''}`,
|
||||||
GOOSE_PORT: String(port),
|
GOOSE_PORT: String(port),
|
||||||
GOOSE_SERVER__SECRET_KEY: serverSecret,
|
GOOSE_SERVER__SECRET_KEY: serverSecret,
|
||||||
// Add any additional environment variables passed in
|
|
||||||
...env,
|
...env,
|
||||||
} as GooseProcessEnv;
|
} as GooseProcessEnv;
|
||||||
|
|
||||||
const processEnv: GooseProcessEnv = { ...process.env, ...additionalEnv } as GooseProcessEnv;
|
const processEnv: GooseProcessEnv = { ...process.env, ...additionalEnv } as GooseProcessEnv;
|
||||||
|
|
||||||
// Ensure proper executable path on Windows
|
|
||||||
if (isWindows && !resolvedGoosedPath.toLowerCase().endsWith('.exe')) {
|
if (isWindows && !resolvedGoosedPath.toLowerCase().endsWith('.exe')) {
|
||||||
goosedPath = resolvedGoosedPath + '.exe';
|
goosedPath = resolvedGoosedPath + '.exe';
|
||||||
} else {
|
} else {
|
||||||
@@ -135,15 +141,11 @@ export const startGoosed = async (
|
|||||||
cwd: dir,
|
cwd: dir,
|
||||||
env: processEnv,
|
env: processEnv,
|
||||||
stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
|
||||||
// Hide terminal window on Windows
|
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
// Run detached on Windows only to avoid terminal windows
|
|
||||||
detached: isWindows,
|
detached: isWindows,
|
||||||
// Never use shell to avoid command injection - this is critical for security
|
|
||||||
shell: false,
|
shell: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log spawn options for debugging (excluding sensitive env vars)
|
|
||||||
const safeSpawnOptions = {
|
const safeSpawnOptions = {
|
||||||
...spawnOptions,
|
...spawnOptions,
|
||||||
env: Object.keys(spawnOptions.env || {}).reduce(
|
env: Object.keys(spawnOptions.env || {}).reduce(
|
||||||
@@ -160,12 +162,10 @@ export const startGoosed = async (
|
|||||||
};
|
};
|
||||||
log.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
|
log.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
|
||||||
|
|
||||||
// Security: Use only hardcoded, safe arguments
|
|
||||||
const safeArgs = ['agent'];
|
const safeArgs = ['agent'];
|
||||||
|
|
||||||
const goosedProcess: ChildProcess = spawn(goosedPath, safeArgs, spawnOptions);
|
const goosedProcess: ChildProcess = spawn(goosedPath, safeArgs, spawnOptions);
|
||||||
|
|
||||||
// Only unref on Windows to allow it to run independently of the parent
|
|
||||||
if (isWindows && goosedProcess.unref) {
|
if (isWindows && goosedProcess.unref) {
|
||||||
goosedProcess.unref();
|
goosedProcess.unref();
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,7 @@ export const startGoosed = async (
|
|||||||
|
|
||||||
goosedProcess.on('error', (err: Error) => {
|
goosedProcess.on('error', (err: Error) => {
|
||||||
log.error(`Failed to start goosed on port ${port} and dir ${dir}`, err);
|
log.error(`Failed to start goosed on port ${port} and dir ${dir}`, err);
|
||||||
throw err; // Propagate the error
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
const try_kill_goose = () => {
|
const try_kill_goose = () => {
|
||||||
@@ -207,14 +207,18 @@ export const startGoosed = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure goosed is terminated when the app quits
|
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
log.info('App quitting, terminating goosed server');
|
log.info('App quitting, terminating goosed server');
|
||||||
try_kill_goose();
|
try_kill_goose();
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info(`Goosed server successfully started on port ${port}`);
|
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 => {
|
const getGoosedBinaryPath = (app: Electron.App): string => {
|
||||||
|
|||||||
+96
-39
@@ -466,16 +466,23 @@ const getBundledConfig = (): BundledConfig => {
|
|||||||
const { defaultProvider, defaultModel, predefinedModels, baseUrlShare, version } =
|
const { defaultProvider, defaultModel, predefinedModels, baseUrlShare, version } =
|
||||||
getBundledConfig();
|
getBundledConfig();
|
||||||
|
|
||||||
const SERVER_SECRET = process.env.GOOSE_EXTERNAL_BACKEND
|
const GENERATED_SECRET = crypto.randomBytes(32).toString('hex');
|
||||||
? 'test'
|
|
||||||
: 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 = {
|
let appConfig = {
|
||||||
GOOSE_DEFAULT_PROVIDER: defaultProvider,
|
GOOSE_DEFAULT_PROVIDER: defaultProvider,
|
||||||
GOOSE_DEFAULT_MODEL: defaultModel,
|
GOOSE_DEFAULT_MODEL: defaultModel,
|
||||||
GOOSE_PREDEFINED_MODELS: predefinedModels,
|
GOOSE_PREDEFINED_MODELS: predefinedModels,
|
||||||
GOOSE_API_HOST: 'http://127.0.0.1',
|
GOOSE_API_HOST: 'http://127.0.0.1',
|
||||||
GOOSE_PORT: 0,
|
|
||||||
GOOSE_WORKING_DIR: '',
|
GOOSE_WORKING_DIR: '',
|
||||||
// If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode)
|
// If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode)
|
||||||
GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true',
|
GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true',
|
||||||
@@ -503,15 +510,18 @@ const createChat = async (
|
|||||||
) => {
|
) => {
|
||||||
updateEnvironmentVariables(envToggles);
|
updateEnvironmentVariables(envToggles);
|
||||||
|
|
||||||
const envVars = {
|
const settings = loadSettings();
|
||||||
GOOSE_PATH_ROOT: process.env.GOOSE_PATH_ROOT,
|
const serverSecret = getServerSecret(settings);
|
||||||
};
|
|
||||||
const [port, workingDir, goosedProcess, errorLog] = await startGoosed(
|
const goosedResult = await startGoosed({
|
||||||
app,
|
app,
|
||||||
SERVER_SECRET,
|
serverSecret,
|
||||||
dir || os.homedir(),
|
dir: dir || os.homedir(),
|
||||||
envVars
|
env: { GOOSE_PATH_ROOT: process.env.GOOSE_PATH_ROOT },
|
||||||
);
|
externalGoosed: settings.externalGoosed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { baseUrl, workingDir, process: goosedProcess, errorLog } = goosedResult;
|
||||||
|
|
||||||
const mainWindowState = windowStateKeeper({
|
const mainWindowState = windowStateKeeper({
|
||||||
defaultWidth: 940,
|
defaultWidth: 940,
|
||||||
@@ -534,14 +544,13 @@ const createChat = async (
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
spellcheck: true,
|
spellcheck: true,
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
// Enable features needed for Web Speech API
|
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
additionalArguments: [
|
additionalArguments: [
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
...appConfig,
|
...appConfig,
|
||||||
GOOSE_PORT: port,
|
GOOSE_API_HOST: baseUrl,
|
||||||
GOOSE_WORKING_DIR: workingDir,
|
GOOSE_WORKING_DIR: workingDir,
|
||||||
REQUEST_DIR: dir,
|
REQUEST_DIR: dir,
|
||||||
GOOSE_BASE_URL_SHARE: baseUrlShare,
|
GOOSE_BASE_URL_SHARE: baseUrlShare,
|
||||||
@@ -552,7 +561,7 @@ const createChat = async (
|
|||||||
scheduledJobId: scheduledJobId,
|
scheduledJobId: scheduledJobId,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
partition: 'persist:goose', // Add this line to ensure persistence
|
partition: 'persist:goose',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -567,10 +576,10 @@ const createChat = async (
|
|||||||
|
|
||||||
const goosedClient = createClient(
|
const goosedClient = createClient(
|
||||||
createConfig({
|
createConfig({
|
||||||
baseUrl: `http://127.0.0.1:${port}`,
|
baseUrl,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'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);
|
const serverReady = await checkServerStatus(goosedClient, errorLog);
|
||||||
if (!serverReady) {
|
if (!serverReady) {
|
||||||
dialog.showMessageBoxSync({
|
const isUsingExternalBackend = settings.externalGoosed?.enabled;
|
||||||
type: 'error',
|
|
||||||
title: 'Goose Failed to Start',
|
if (isUsingExternalBackend) {
|
||||||
message: 'The backend server failed to start.',
|
const response = dialog.showMessageBoxSync({
|
||||||
detail: errorLog.join('\n'),
|
type: 'error',
|
||||||
buttons: ['OK'],
|
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();
|
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', () => {
|
ipcMain.handle('get-secret-key', () => {
|
||||||
return SERVER_SECRET;
|
const settings = loadSettings();
|
||||||
|
return getServerSecret(settings);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-goosed-host-port', async (event) => {
|
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
|
// Add CSP headers to all sessions
|
||||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||||
callback({
|
callback({
|
||||||
@@ -1770,31 +1840,18 @@ async function appMain() {
|
|||||||
...details.responseHeaders,
|
...details.responseHeaders,
|
||||||
'Content-Security-Policy':
|
'Content-Security-Policy':
|
||||||
"default-src 'self';" +
|
"default-src 'self';" +
|
||||||
// Allow inline styles since we use them in our React components
|
|
||||||
"style-src 'self' 'unsafe-inline';" +
|
"style-src 'self' 'unsafe-inline';" +
|
||||||
// Scripts from our app and inline scripts (for theme initialization)
|
|
||||||
"script-src 'self' 'unsafe-inline';" +
|
"script-src 'self' 'unsafe-inline';" +
|
||||||
// Images from our app and data: URLs (for base64 images)
|
|
||||||
"img-src 'self' data: https:;" +
|
"img-src 'self' data: https:;" +
|
||||||
// Connect to our local API and specific external services
|
`connect-src ${buildConnectSrc()};` +
|
||||||
"connect-src 'self' http://127.0.0.1:* https://api.github.com https://github.com https://objects.githubusercontent.com" +
|
|
||||||
// Don't allow any plugins
|
|
||||||
"object-src 'none';" +
|
"object-src 'none';" +
|
||||||
// Allow all frames (iframes)
|
|
||||||
"frame-src 'self' https: http:;" +
|
"frame-src 'self' https: http:;" +
|
||||||
// Font sources - allow self, data URLs, and external fonts
|
|
||||||
"font-src 'self' data: https:;" +
|
"font-src 'self' data: https:;" +
|
||||||
// Media sources - allow microphone
|
|
||||||
"media-src 'self' mediastream:;" +
|
"media-src 'self' mediastream:;" +
|
||||||
// Form actions
|
|
||||||
"form-action 'none';" +
|
"form-action 'none';" +
|
||||||
// Base URI restriction
|
|
||||||
"base-uri 'self';" +
|
"base-uri 'self';" +
|
||||||
// Manifest files
|
|
||||||
"manifest-src 'self';" +
|
"manifest-src 'self';" +
|
||||||
// Worker sources
|
|
||||||
"worker-src 'self';" +
|
"worker-src 'self';" +
|
||||||
// Upgrade insecure requests
|
|
||||||
'upgrade-insecure-requests;',
|
'upgrade-insecure-requests;',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ type ElectronAPI = {
|
|||||||
setDockIcon: (show: boolean) => Promise<boolean>;
|
setDockIcon: (show: boolean) => Promise<boolean>;
|
||||||
getDockIconState: () => Promise<boolean>;
|
getDockIconState: () => Promise<boolean>;
|
||||||
getSettings: () => Promise<unknown | null>;
|
getSettings: () => Promise<unknown | null>;
|
||||||
|
saveSettings: (settings: unknown) => Promise<boolean>;
|
||||||
getSecretKey: () => Promise<string>;
|
getSecretKey: () => Promise<string>;
|
||||||
getGoosedHostPort: () => Promise<string | null>;
|
getGoosedHostPort: () => Promise<string | null>;
|
||||||
setWakelock: (enable: boolean) => Promise<boolean>;
|
setWakelock: (enable: boolean) => Promise<boolean>;
|
||||||
@@ -177,6 +178,7 @@ const electronAPI: ElectronAPI = {
|
|||||||
setDockIcon: (show: boolean) => ipcRenderer.invoke('set-dock-icon', show),
|
setDockIcon: (show: boolean) => ipcRenderer.invoke('set-dock-icon', show),
|
||||||
getDockIconState: () => ipcRenderer.invoke('get-dock-icon-state'),
|
getDockIconState: () => ipcRenderer.invoke('get-dock-icon-state'),
|
||||||
getSettings: () => ipcRenderer.invoke('get-settings'),
|
getSettings: () => ipcRenderer.invoke('get-settings'),
|
||||||
|
saveSettings: (settings: unknown) => ipcRenderer.invoke('save-settings', settings),
|
||||||
getSecretKey: () => ipcRenderer.invoke('get-secret-key'),
|
getSecretKey: () => ipcRenderer.invoke('get-secret-key'),
|
||||||
getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'),
|
getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'),
|
||||||
setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable),
|
setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable),
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ const App = lazy(() => import('./App'));
|
|||||||
|
|
||||||
if (!isLauncher) {
|
if (!isLauncher) {
|
||||||
console.log('window created, getting goosed connection info');
|
console.log('window created, getting goosed connection info');
|
||||||
const baseUrl = await window.electron.getGoosedHostPort();
|
const gooseApiHost = await window.electron.getGoosedHostPort();
|
||||||
if (baseUrl === null) {
|
if (gooseApiHost === null) {
|
||||||
window.alert('failed to start goose backend process');
|
window.alert('failed to start goose backend process');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('connecting at', baseUrl);
|
console.log('connecting at', gooseApiHost);
|
||||||
client.setConfig({
|
client.setConfig({
|
||||||
baseUrl,
|
baseUrl: gooseApiHost,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Secret-Key': await window.electron.getSecretKey(),
|
'X-Secret-Key': await window.electron.getSecretKey(),
|
||||||
|
|||||||
@@ -7,11 +7,18 @@ export interface EnvToggles {
|
|||||||
GOOSE_SERVER__COMPUTER_CONTROLLER: boolean;
|
GOOSE_SERVER__COMPUTER_CONTROLLER: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExternalGoosedConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
url: string;
|
||||||
|
secret: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
envToggles: EnvToggles;
|
envToggles: EnvToggles;
|
||||||
showMenuBarIcon: boolean;
|
showMenuBarIcon: boolean;
|
||||||
showDockIcon: boolean;
|
showDockIcon: boolean;
|
||||||
enableWakelock: boolean;
|
enableWakelock: boolean;
|
||||||
|
externalGoosed?: ExternalGoosedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');
|
const SETTINGS_FILE = path.join(app.getPath('userData'), 'settings.json');
|
||||||
|
|||||||
Reference in New Issue
Block a user