Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ui/desktop/src/components/MCPUIResourceRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
6 changes: 5 additions & 1 deletion ui/desktop/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
180 changes: 180 additions & 0 deletions ui/desktop/src/components/settings/app/ExternalBackendSection.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
8 changes: 2 additions & 6 deletions ui/desktop/src/config.ts
Original file line number Diff line number Diff line change
@@ -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}`;
};
64 changes: 34 additions & 30 deletions ui/desktop/src/goosed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand All @@ -66,7 +71,7 @@ const connectToExternalBackend = async (
},
} as ChildProcess;

return [port, workingDir, mockProcess, []];
return { baseUrl: url, workingDir, process: mockProcess, errorLog: [] };
};

interface GooseProcessEnv {
Expand All @@ -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);
Expand All @@ -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 {
Expand All @@ -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(
Expand All @@ -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();
}
Expand All @@ -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 = () => {
Expand All @@ -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 => {
Expand Down
Loading
Loading