diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml
index f6111dbc61f3..505374fde252 100644
--- a/.github/workflows/pr-smoke-test.yml
+++ b/.github/workflows/pr-smoke-test.yml
@@ -67,15 +67,22 @@ jobs:
- name: Build Binary for Smoke Tests
run: |
- cargo build --bin goose
+ cargo build --bin goose --bin goosed
- - name: Upload Binary for Smoke Tests
+ - name: Upload goose binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: goose-binary
path: target/debug/goose
retention-days: 1
+ - name: Upload goosed binary
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: goosed-binary
+ path: target/debug/goosed
+ retention-days: 1
+
smoke-tests:
name: Smoke Tests
runs-on: ubuntu-latest
@@ -239,3 +246,38 @@ jobs:
mkdir -p $HOME/.local/share/goose/sessions
mkdir -p $HOME/.config/goose
bash scripts/test_compaction.sh
+
+ goosed-integration-tests:
+ name: goose server HTTP integration tests
+ runs-on: ubuntu-latest
+ needs: build-binary
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ with:
+ ref: ${{ github.event.inputs.branch || github.ref }}
+
+ - name: Download Binary
+ uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
+ with:
+ name: goosed-binary
+ path: target/debug
+
+ - name: Make Binary Executable
+ run: chmod +x target/debug/goosed
+
+ - name: Install Node.js Dependencies
+ run: source ../../bin/activate-hermit && npm ci
+ working-directory: ui/desktop
+
+ - name: Run Integration Tests
+ env:
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ GOOSED_BINARY: ../../target/debug/goosed
+ GOOSE_PROVIDER: anthropic
+ GOOSE_MODEL: claude-sonnet-4-5-20250929
+ SHELL: /bin/bash
+ run: |
+ echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bash_profile
+ source ../../bin/activate-hermit && npm run test:integration:debug
+ working-directory: ui/desktop
diff --git a/ui/desktop/package.json b/ui/desktop/package.json
index 696e17ef55dd..5f63806a38e4 100644
--- a/ui/desktop/package.json
+++ b/ui/desktop/package.json
@@ -35,6 +35,9 @@
"test:run": "vitest run",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
+ "test:integration": "vitest run --config vitest.integration.config.ts",
+ "test:integration:watch": "vitest --config vitest.integration.config.ts",
+ "test:integration:debug": "DEBUG=1 vitest run --config vitest.integration.config.ts",
"prepare": "husky",
"start-alpha-gui": "ALPHA=true npm run start-gui"
},
diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx
index 283b0149954c..0ca4eadec003 100644
--- a/ui/desktop/src/App.test.tsx
+++ b/ui/desktop/src/App.test.tsx
@@ -28,11 +28,6 @@ Object.defineProperty(window, 'history', {
writable: true,
});
-// Mock dependencies
-vi.mock('./utils/providerUtils', () => ({
- initializeSystem: vi.fn().mockResolvedValue(undefined),
-}));
-
vi.mock('./utils/costDatabase', () => ({
initializeCostDatabase: vi.fn().mockResolvedValue(undefined),
}));
diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx
index 2278d262cc37..34f2df8264c4 100644
--- a/ui/desktop/src/components/BaseChat.tsx
+++ b/ui/desktop/src/components/BaseChat.tsx
@@ -34,7 +34,7 @@ import RecipeActivities from './recipes/RecipeActivities';
import { useToolCount } from './alerts/useToolCount';
import { getThinkingMessage, getTextAndImageContent } from '../types/message';
import ParameterInputModal from './ParameterInputModal';
-import { substituteParameters } from '../utils/providerUtils';
+import { substituteParameters } from '../utils/parameterSubstitution';
import { useModelAndProvider } from './ModelAndProviderContext';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import { toastSuccess } from '../toasts';
diff --git a/ui/desktop/src/components/OllamaSetup.test.tsx b/ui/desktop/src/components/OllamaSetup.test.tsx
index 5c4674a748ce..fb2ac832a887 100644
--- a/ui/desktop/src/components/OllamaSetup.test.tsx
+++ b/ui/desktop/src/components/OllamaSetup.test.tsx
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { OllamaSetup } from './OllamaSetup';
import * as ollamaDetection from '../utils/ollamaDetection';
-import * as providerUtils from '../utils/providerUtils';
import { toastService } from '../toasts';
// Mock dependencies
vi.mock('../utils/ollamaDetection');
-vi.mock('../utils/providerUtils');
vi.mock('../toasts');
// Mock useConfig hook
@@ -162,8 +160,6 @@ describe('OllamaSetup', () => {
});
it('should handle successful connection', async () => {
- vi.mocked(providerUtils.initializeSystem).mockResolvedValue(undefined);
-
render();
await waitFor(() => {
@@ -174,20 +170,12 @@ describe('OllamaSetup', () => {
expect(mockUpsert).toHaveBeenCalledWith('GOOSE_PROVIDER', 'ollama', false);
expect(mockUpsert).toHaveBeenCalledWith('GOOSE_MODEL', 'gpt-oss:20b', false);
expect(mockUpsert).toHaveBeenCalledWith('OLLAMA_HOST', 'localhost', false);
- expect(providerUtils.initializeSystem).toHaveBeenCalledWith(
- 'ollama',
- 'gpt-oss:20b',
- expect.any(Object)
- );
expect(toastService.success).toHaveBeenCalled();
expect(mockOnSuccess).toHaveBeenCalled();
});
});
it('should handle connection failure', async () => {
- const testError = new Error('Initialization failed');
- vi.mocked(providerUtils.initializeSystem).mockRejectedValue(testError);
-
render();
await waitFor(() => {
diff --git a/ui/desktop/src/components/recipes/RecipeActivities.tsx b/ui/desktop/src/components/recipes/RecipeActivities.tsx
index 3a25d912a92a..b053e38b97b0 100644
--- a/ui/desktop/src/components/recipes/RecipeActivities.tsx
+++ b/ui/desktop/src/components/recipes/RecipeActivities.tsx
@@ -1,7 +1,7 @@
import { Card } from '../ui/card';
import GooseLogo from '../GooseLogo';
import MarkdownContent from '../MarkdownContent';
-import { substituteParameters } from '../../utils/providerUtils';
+import { substituteParameters } from '../../utils/parameterSubstitution';
interface RecipeActivitiesProps {
append: (text: string) => void;
diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts
index 2528a816e833..ea55c7c319f2 100644
--- a/ui/desktop/src/goosed.ts
+++ b/ui/desktop/src/goosed.ts
@@ -1,16 +1,21 @@
-import Electron from 'electron';
-import fs from 'node:fs';
import { spawn, ChildProcess } from 'child_process';
-import { createServer } from 'net';
+import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
-import log from './utils/logger';
-import { App } from 'electron';
+import { createServer } from 'net';
import { Buffer } from 'node:buffer';
-
import { status } from './api';
-import { Client } from './api/client';
-import { ExternalGoosedConfig } from './utils/settings';
+import { Client, createClient, createConfig } from './api/client';
+
+export interface Logger {
+ info: (...args: unknown[]) => void;
+ error: (...args: unknown[]) => void;
+}
+
+export const defaultLogger: Logger = {
+ info: (...args) => console.log('[goosed]', ...args),
+ error: (...args) => console.error('[goosed]', ...args),
+};
export const findAvailablePort = (): Promise => {
return new Promise((resolve, _reject) => {
@@ -19,244 +24,310 @@ export const findAvailablePort = (): Promise => {
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as { port: number };
server.close(() => {
- log.info(`Found available port: ${port}`);
resolve(port);
});
});
});
};
-// Check if goosed server is ready by polling the status endpoint
-export const checkServerStatus = async (client: Client, errorLog: string[]): Promise => {
- const interval = 100; // ms
- const maxAttempts = 100; // 10s
+export interface FindBinaryOptions {
+ isPackaged?: boolean;
+ resourcesPath?: string;
+}
- const fatal = (line: string) => {
- const trimmed = line.trim().toLowerCase();
- return trimmed.startsWith("thread 'main' panicked at") || trimmed.startsWith('error:');
- };
+export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string => {
+ const pathFromEnv = process.env.GOOSED_BINARY;
+ if (pathFromEnv) {
+ if (fs.existsSync(pathFromEnv) && fs.statSync(pathFromEnv).isFile()) {
+ return path.resolve(pathFromEnv);
+ } else {
+ throw new Error(`Invalid GOOSED_BINARY path: ${pathFromEnv} (pwd is ${process.cwd()})`);
+ }
+ }
+ const { isPackaged = false, resourcesPath } = options;
+ const binaryName = process.platform === 'win32' ? 'goosed.exe' : 'goosed';
+
+ const possiblePaths: string[] = [];
+
+ // Packaged app paths
+ if (isPackaged && resourcesPath) {
+ possiblePaths.push(path.join(resourcesPath, 'bin', binaryName));
+ possiblePaths.push(path.join(resourcesPath, binaryName));
+ }
+
+ // Development paths
+ possiblePaths.push(
+ path.join(process.cwd(), 'src', 'bin', binaryName),
+ path.join(process.cwd(), '..', '..', 'target', 'release', binaryName),
+ path.join(process.cwd(), '..', '..', 'target', 'debug', binaryName)
+ );
+
+ for (const p of possiblePaths) {
+ try {
+ if (fs.existsSync(p) && fs.statSync(p).isFile()) {
+ return p;
+ }
+ } catch {
+ // continue
+ }
+ }
+
+ throw new Error(
+ `Goosed binary not found in any of the possible paths: ${possiblePaths.join(', ')}`
+ );
+};
+
+export const checkServerStatus = async (client: Client, errorLog: string[]): Promise => {
+ const timeout = 10000;
+ const interval = 100;
+ const maxAttempts = Math.ceil(timeout / interval);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
- if (errorLog.some(fatal)) {
- log.error('Detected fatal error in server logs');
+ if (errorLog.some(isFatalError)) {
return false;
}
+
try {
await status({ client, throwOnError: true });
return true;
} catch {
- if (attempt === maxAttempts) {
- log.error(`Server failed to respond after ${(interval * maxAttempts) / 1000} seconds`);
- }
+ await new Promise((resolve) => setTimeout(resolve, interval));
}
- await new Promise((resolve) => setTimeout(resolve, interval));
}
+
return false;
};
-export interface GoosedResult {
- baseUrl: string;
- workingDir: string;
- process: ChildProcess;
- errorLog: string[];
-}
+export const isFatalError = (line: string): boolean => {
+ const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i];
+ return fatalPatterns.some((pattern) => pattern.test(line));
+};
-const connectToExternalBackend = (workingDir: string, url: string): GoosedResult => {
- log.info(`Using external goosed backend at ${url}`);
+export const buildGoosedEnv = (
+ port: number,
+ secretKey: string,
+ binaryPath?: string
+): Record => {
+ // Environment variable naming follows the config crate convention:
+ // - GOOSE_ prefix with _ separator for top-level fields (GOOSE_PORT, GOOSE_HOST)
+ // - __ separator for nested fields (GOOSE_SERVER__SECRET_KEY)
+ const homeDir = process.env.HOME || os.homedir();
+ const env: Record = {
+ GOOSE_PORT: port.toString(),
+ GOOSE_SERVER__SECRET_KEY: secretKey,
+ HOME: homeDir,
+ };
- const mockProcess = {
- pid: undefined,
- kill: () => {
- log.info(`Not killing external process that is managed externally`);
- },
- } as ChildProcess;
+ // Windows-specific environment variables
+ if (process.platform === 'win32') {
+ env.USERPROFILE = homeDir;
+ env.APPDATA = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
+ env.LOCALAPPDATA = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
+ }
- return { baseUrl: url, workingDir, process: mockProcess, errorLog: [] };
-};
+ // Add binary directory to PATH for any dependencies
+ const pathKey = process.platform === 'win32' ? 'Path' : 'PATH';
+ const currentPath = process.env[pathKey] || '';
+ if (binaryPath) {
+ env[pathKey] = `${path.dirname(binaryPath)}${path.delimiter}${currentPath}`;
+ } else if (currentPath) {
+ env[pathKey] = currentPath;
+ }
-interface GooseProcessEnv {
- [key: string]: string | undefined;
+ return env;
+};
- HOME: string;
- USERPROFILE: string;
- APPDATA: string;
- LOCALAPPDATA: string;
- PATH: string;
- GOOSE_PORT: string;
- GOOSE_SERVER__SECRET_KEY?: string;
+// Configuration for external goosed server
+export interface ExternalGoosedConfig {
+ enabled: boolean;
+ url?: string;
+ secret?: string;
}
export interface StartGoosedOptions {
- app: App;
+ dir?: string;
serverSecret: string;
- dir: string;
- env?: Partial;
+ env?: Record;
externalGoosed?: ExternalGoosedConfig;
+ isPackaged?: boolean;
+ resourcesPath?: string;
+ logger?: Logger;
+}
+
+export interface GoosedResult {
+ baseUrl: string;
+ workingDir: string;
+ process: ChildProcess | null;
+ errorLog: string[];
+ cleanup: () => Promise;
+ client: Client;
}
+const goosedClientForUrlAndSecret = (url: string, secret: string): Client => {
+ return createClient(
+ createConfig({
+ baseUrl: url,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Secret-Key': secret,
+ },
+ })
+ );
+};
+
export const startGoosed = async (options: StartGoosedOptions): Promise => {
- const { app, serverSecret, dir: inputDir, env = {}, externalGoosed } = options;
- const isWindows = process.platform === 'win32';
- const homeDir = os.homedir();
- const dir = path.resolve(path.normalize(inputDir));
+ const {
+ dir,
+ isPackaged = false,
+ resourcesPath,
+ serverSecret,
+ env: additionalEnv = {},
+ externalGoosed,
+ logger = defaultLogger,
+ } = options;
+
+ const errorLog: string[] = [];
+ const workingDir = dir || os.homedir();
if (externalGoosed?.enabled && externalGoosed.url) {
- return connectToExternalBackend(dir, externalGoosed.url);
+ const url = externalGoosed.url.replace(/\/$/, '');
+ logger.info(`Using external goosed backend at ${url}`);
+
+ return {
+ baseUrl: url,
+ workingDir,
+ process: null,
+ errorLog,
+ cleanup: async () => {
+ logger.info('Not killing external process that is managed externally');
+ },
+ client: goosedClientForUrlAndSecret(url, serverSecret),
+ };
}
if (process.env.GOOSE_EXTERNAL_BACKEND) {
const port = process.env.GOOSE_PORT || '3000';
- return connectToExternalBackend(dir, `http://127.0.0.1:${port}`);
+ const url = `http://127.0.0.1:${port}`;
+ logger.info(`Using external goosed backend from env at ${url}`);
+
+ return {
+ baseUrl: url,
+ workingDir,
+ process: null,
+ errorLog,
+ cleanup: async () => {
+ logger.info('Not killing external process that is managed externally');
+ },
+ client: goosedClientForUrlAndSecret(url, serverSecret),
+ };
}
- let goosedPath = getGoosedBinaryPath(app);
-
- const resolvedGoosedPath = path.resolve(goosedPath);
+ const goosedPath = findGoosedBinaryPath({ isPackaged, resourcesPath });
const port = await findAvailablePort();
- const stderrLines: string[] = [];
+ logger.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}`);
- log.info(`Starting goosed from: ${resolvedGoosedPath} on port ${port} in dir ${dir}`);
+ const baseUrl = `http://127.0.0.1:${port}`;
- const additionalEnv: GooseProcessEnv = {
- HOME: homeDir,
- USERPROFILE: homeDir,
- APPDATA: process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
- LOCALAPPDATA: process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
- PATH: `${path.dirname(resolvedGoosedPath)}${path.delimiter}${process.env.PATH || ''}`,
- GOOSE_PORT: String(port),
- GOOSE_SERVER__SECRET_KEY: serverSecret,
- ...env,
- } as GooseProcessEnv;
-
- const processEnv: GooseProcessEnv = { ...process.env, ...additionalEnv } as GooseProcessEnv;
-
- if (isWindows && !resolvedGoosedPath.toLowerCase().endsWith('.exe')) {
- goosedPath = resolvedGoosedPath + '.exe';
- } else {
- goosedPath = resolvedGoosedPath;
+ const spawnEnv = {
+ ...process.env,
+ ...buildGoosedEnv(port, serverSecret, goosedPath),
+ };
+
+ for (const [key, value] of Object.entries(additionalEnv)) {
+ if (value !== undefined) {
+ spawnEnv[key] = value;
+ }
}
- log.info(`Binary path resolved to: ${goosedPath}`);
+ const isWindows = process.platform === 'win32';
const spawnOptions = {
- cwd: dir,
- env: processEnv,
- stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
+ env: spawnEnv,
+ cwd: workingDir,
windowsHide: true,
detached: isWindows,
- shell: false,
+ shell: false as const,
+ stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
};
const safeSpawnOptions = {
...spawnOptions,
- env: Object.keys(spawnOptions.env || {}).reduce(
- (acc, key) => {
- if (key.includes('SECRET') || key.includes('PASSWORD') || key.includes('TOKEN')) {
- acc[key] = '[REDACTED]';
- } else {
- acc[key] = spawnOptions.env![key] || '';
- }
- return acc;
- },
- {} as Record
+ env: Object.fromEntries(
+ Object.entries(spawnOptions.env).map(([k, v]) =>
+ k.toLowerCase().includes('secret') || k.toLowerCase().includes('key')
+ ? [k, '[REDACTED]']
+ : [k, v]
+ )
),
};
- log.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
-
- const safeArgs = ['agent'];
+ logger.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
- const goosedProcess: ChildProcess = spawn(goosedPath, safeArgs, spawnOptions);
-
- if (isWindows && goosedProcess.unref) {
- goosedProcess.unref();
- }
+ const goosedProcess = spawn(goosedPath, ['agent'], spawnOptions);
goosedProcess.stdout?.on('data', (data: Buffer) => {
- log.info(`goosed stdout for port ${port} and dir ${dir}: ${data.toString()}`);
+ logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${data.toString()}`);
});
goosedProcess.stderr?.on('data', (data: Buffer) => {
- const lines = data
- .toString()
- .split('\n')
- .filter((l) => l.trim());
- lines.forEach((line) => {
- log.error(`goosed stderr for port ${port} and dir ${dir}: ${line}`);
- stderrLines.push(line);
- });
+ const lines = data.toString().split('\n');
+ for (const line of lines) {
+ if (line.trim()) {
+ errorLog.push(line);
+ if (isFatalError(line)) {
+ logger.error(`goosed stderr for port ${port} and dir ${workingDir}: ${line}`);
+ }
+ }
+ }
});
- goosedProcess.on('close', (code: number | null) => {
- log.info(`goosed process exited with code ${code} for port ${port} and dir ${dir}`);
+ goosedProcess.on('exit', (code) => {
+ logger.info(`goosed process exited with code ${code} for port ${port} and dir ${workingDir}`);
});
- goosedProcess.on('error', (err: Error) => {
- log.error(`Failed to start goosed on port ${port} and dir ${dir}`, err);
- throw err;
+ goosedProcess.on('error', (err) => {
+ logger.error(`Failed to start goosed on port ${port} and dir ${workingDir}`, err);
+ errorLog.push(err.message);
});
- const try_kill_goose = () => {
- try {
- if (isWindows) {
- const pid = goosedProcess.pid?.toString() || '0';
- spawn('taskkill', ['/pid', pid, '/T', '/F'], { shell: false });
- } else {
- goosedProcess.kill?.();
+ const cleanup = async (): Promise => {
+ return new Promise((resolve) => {
+ if (!goosedProcess || goosedProcess.killed) {
+ resolve();
+ return;
}
- } catch (error) {
- log.error('Error while terminating goosed process:', error);
- }
- };
-
- app.on('will-quit', () => {
- log.info('App quitting, terminating goosed server');
- try_kill_goose();
- });
- log.info(`Goosed server successfully started on port ${port}`);
- return {
- baseUrl: `http://127.0.0.1:${port}`,
- workingDir: dir,
- process: goosedProcess,
- errorLog: stderrLines,
- };
-};
-
-const getGoosedBinaryPath = (app: Electron.App): string => {
- let executableName = process.platform === 'win32' ? 'goosed.exe' : 'goosed';
-
- let possiblePaths: string[];
- if (!app.isPackaged) {
- possiblePaths = [
- path.join(process.cwd(), 'src', 'bin', executableName),
- path.join(process.cwd(), 'bin', executableName),
- path.join(process.cwd(), '..', '..', 'target', 'debug', executableName),
- path.join(process.cwd(), '..', '..', 'target', 'release', executableName),
- ];
- } else {
- possiblePaths = [path.join(process.resourcesPath, 'bin', executableName)];
- }
-
- for (const binPath of possiblePaths) {
- try {
- const resolvedPath = path.resolve(binPath);
+ goosedProcess.on('close', () => {
+ resolve();
+ });
- if (fs.existsSync(resolvedPath)) {
- const stats = fs.statSync(resolvedPath);
- if (stats.isFile()) {
- return resolvedPath;
+ logger.info('Terminating goosed server');
+ try {
+ if (process.platform === 'win32') {
+ spawn('taskkill', ['/pid', goosedProcess.pid!.toString(), '/f', '/t']);
} else {
- log.error(`Path exists but is not a regular file: ${resolvedPath}`);
+ goosedProcess.kill('SIGTERM');
}
+ } catch (error) {
+ logger.error('Error while terminating goosed process:', error);
}
- } catch (error) {
- log.error(`Error checking path ${binPath}:`, error);
- }
- }
- throw new Error(
- `Could not find ${executableName} binary in any of the expected locations: ${possiblePaths.join(
- ', '
- )}`
- );
+ setTimeout(() => {
+ if (goosedProcess && !goosedProcess.killed && process.platform !== 'win32') {
+ goosedProcess.kill('SIGKILL');
+ }
+ resolve();
+ }, 5000);
+ });
+ };
+
+ logger.info(`Goosed server successfully started on port ${port}`);
+
+ return {
+ baseUrl,
+ workingDir,
+ process: goosedProcess,
+ errorLog,
+ cleanup,
+ client: goosedClientForUrlAndSecret(baseUrl, serverSecret),
+ };
};
diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts
index e5240b83c643..7ed4fb4856b0 100644
--- a/ui/desktop/src/hooks/useRecipeManager.ts
+++ b/ui/desktop/src/hooks/useRecipeManager.ts
@@ -3,7 +3,7 @@ import { Recipe, scanRecipe } from '../recipe';
import { createUserMessage } from '../types/message';
import { Message } from '../api';
-import { substituteParameters } from '../utils/providerUtils';
+import { substituteParameters } from '../utils/parameterSubstitution';
import { updateSessionUserRecipeValues } from '../api';
import { useChatContext } from '../contexts/ChatContext';
import { ChatType } from '../types/chat';
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index 897430f68154..72fa64c90902 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -23,7 +23,8 @@ import path from 'node:path';
import os from 'node:os';
import { spawn } from 'child_process';
import 'dotenv/config';
-import { checkServerStatus, startGoosed } from './goosed';
+import { checkServerStatus } from './goosed';
+import { startGoosed } from './goosed';
import { expandTilde } from './utils/pathUtils';
import log from './utils/logger';
import { ensureWinShims } from './utils/winShims';
@@ -43,7 +44,7 @@ import {
} from './utils/autoUpdater';
import { UPDATES_ENABLED } from './updates';
import './utils/recipeHash';
-import { Client, createClient, createConfig } from './api/client';
+import { Client } from './api/client';
import { GooseApp } from './api';
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity';
@@ -483,14 +484,27 @@ const createChat = async (
const serverSecret = getServerSecret(settings);
const goosedResult = await startGoosed({
- app,
serverSecret,
dir: dir || os.homedir(),
env: { GOOSE_PATH_ROOT: process.env.GOOSE_PATH_ROOT },
externalGoosed: settings.externalGoosed,
+ isPackaged: app.isPackaged,
+ resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
+ logger: log,
});
- const { baseUrl, workingDir, process: goosedProcess, errorLog } = goosedResult;
+ app.on('will-quit', async () => {
+ log.info('App quitting, terminating goosed server');
+ await goosedResult.cleanup();
+ });
+
+ const {
+ baseUrl,
+ workingDir,
+ process: goosedProcess,
+ errorLog,
+ client: goosedClient,
+ } = goosedResult;
const mainWindowState = windowStateKeeper({
defaultWidth: 940,
@@ -543,15 +557,6 @@ const createChat = async (
.catch((err) => log.info('failed to install react dev tools:', err));
}
- const goosedClient = createClient(
- createConfig({
- baseUrl,
- headers: {
- 'Content-Type': 'application/json',
- 'X-Secret-Key': serverSecret,
- },
- })
- );
goosedClients.set(mainWindow.id, goosedClient);
const serverReady = await checkServerStatus(goosedClient, errorLog);
diff --git a/ui/desktop/src/utils/__tests__/providerUtils.test.ts b/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts
similarity index 97%
rename from ui/desktop/src/utils/__tests__/providerUtils.test.ts
rename to ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts
index a64848cc0aaa..7fe58c9601ba 100644
--- a/ui/desktop/src/utils/__tests__/providerUtils.test.ts
+++ b/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
-import { substituteParameters } from '../providerUtils';
+import { substituteParameters } from '../parameterSubstitution';
-describe('providerUtils', () => {
+describe('parameterSubstitution', () => {
describe('substituteParameters', () => {
it('should substitute simple parameters', () => {
const text = 'Hello {{name}}, welcome to {{app}}!';
@@ -83,12 +83,12 @@ describe('providerUtils', () => {
it('should handle complex substitution scenario', () => {
const text = `
Welcome {{user_name}}!
-
+
Your account details:
- ID: {{user_id}}
- Email: {{user_email}}
- App: {{app_name}}
-
+
Thank you for using {{app_name}}!
`;
@@ -102,12 +102,12 @@ describe('providerUtils', () => {
const result = substituteParameters(text, params);
const expected = `
Welcome John Doe!
-
+
Your account details:
- ID: 12345
- Email: john@example.com
- App: MyApp
-
+
Thank you for using MyApp!
`;
diff --git a/ui/desktop/src/utils/parameterSubstitution.ts b/ui/desktop/src/utils/parameterSubstitution.ts
new file mode 100644
index 000000000000..c3489e651b7c
--- /dev/null
+++ b/ui/desktop/src/utils/parameterSubstitution.ts
@@ -0,0 +1,11 @@
+export const substituteParameters = (text: string, params: Record): string => {
+ let substitutedText = text;
+
+ for (const key in params) {
+ // Escape special characters in the key (parameter) and match optional whitespace
+ const regex = new RegExp(`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*}}`, 'g');
+ substitutedText = substitutedText.replace(regex, params[key]);
+ }
+
+ return substitutedText;
+};
diff --git a/ui/desktop/src/utils/providerUtils.ts b/ui/desktop/src/utils/providerUtils.ts
deleted file mode 100644
index 40627920e3c7..000000000000
--- a/ui/desktop/src/utils/providerUtils.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import {
- initializeBundledExtensions,
- syncBundledExtensions,
-} from '../components/settings/extensions';
-import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext';
-import { Recipe, updateAgentProvider, updateFromSession } from '../api';
-
-// Helper function to substitute parameters in text
-export const substituteParameters = (text: string, params: Record): string => {
- let substitutedText = text;
-
- for (const key in params) {
- // Escape special characters in the key (parameter) and match optional whitespace
- const regex = new RegExp(`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*}}`, 'g');
- substitutedText = substitutedText.replace(regex, params[key]);
- }
-
- return substitutedText;
-};
-
-export const initializeSystem = async (
- sessionId: string,
- provider: string,
- model: string,
- options?: {
- getExtensions?: (b: boolean) => Promise;
- addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise;
- recipeParameters?: Record | null;
- recipe?: Recipe;
- }
-) => {
- try {
- console.log(
- 'initializing agent with provider',
- provider,
- 'model',
- model,
- 'sessionId',
- sessionId
- );
- await updateAgentProvider({
- body: {
- session_id: sessionId,
- provider,
- model,
- },
- throwOnError: true,
- });
-
- if (!sessionId) {
- console.log('This will not end well');
- }
- await updateFromSession({
- body: {
- session_id: sessionId,
- },
- throwOnError: true,
- });
-
- if (!options?.getExtensions || !options?.addExtension) {
- console.warn('Extension helpers not provided in alpha mode');
- return;
- }
-
- // Initialize or sync built-in extensions into config.yaml
- let refreshedExtensions = await options.getExtensions(false);
-
- if (refreshedExtensions.length === 0) {
- await initializeBundledExtensions(options.addExtension);
- } else {
- await syncBundledExtensions(refreshedExtensions, options.addExtension);
- }
- } catch (error) {
- console.error('Failed to initialize agent:', error);
- throw error;
- }
-};
diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts
new file mode 100644
index 000000000000..3e621f846534
--- /dev/null
+++ b/ui/desktop/tests/integration/goosed.test.ts
@@ -0,0 +1,308 @@
+/**
+ * Integration tests for the goosed binary using the TypeScript API client.
+ *
+ * These tests spawn a real goosed process and issue requests via the
+ * auto-generated API client to verify the server is working correctly.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { setupGoosed, type GoosedTestContext } from './setup';
+import {
+ status,
+ readConfig,
+ providers,
+ startAgent,
+ stopAgent,
+ listSessions,
+ getSession,
+ updateAgentProvider,
+ reply,
+} from '../../src/api';
+import { execSync } from 'child_process';
+import os from 'node:os';
+
+const CONSTRAINED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
+
+function getUserPath(): string[] {
+ try {
+ const userShell = process.env.SHELL || '/bin/bash';
+ const path = execSync(`${userShell} -l -i -c 'echo $PATH'`, {
+ encoding: 'utf-8',
+ timeout: 5000,
+ env: {
+ PATH: CONSTRAINED_PATH,
+ },
+ }).trim();
+
+ const delimiter = process.platform === 'win32' ? ';' : ':';
+ return path.split(delimiter).filter((entry: string) => entry.length > 0);
+ } catch (error) {
+ console.error('Error executing shell:', error);
+ throw error;
+ }
+}
+
+describe('goosed API integration tests', () => {
+ let ctx: GoosedTestContext;
+
+ beforeAll(async () => {
+ const configYaml = `
+extensions:
+ developer:
+ enabled: true
+ type: builtin
+ name: developer
+ description: General development tools useful for software engineering.
+ display_name: Developer
+ timeout: 300
+ bundled: true
+ available_tools: []
+`;
+
+ ctx = await setupGoosed({ pathOverride: '/usr/bin:/bin', configYaml });
+ });
+
+ afterAll(async () => {
+ await ctx.cleanup();
+ });
+
+ describe('health', () => {
+ it('should respond to status endpoint', async () => {
+ const response = await status({ client: ctx.client });
+ expect(response.response).toBeOkResponse();
+ expect(response.data).toBeDefined();
+ });
+ });
+
+ describe('configuration', () => {
+ it('should read config value (or return null for missing key)', async () => {
+ const response = await readConfig({
+ client: ctx.client,
+ body: {
+ key: 'GOOSE_PROVIDER',
+ is_secret: false,
+ },
+ });
+ expect(response.response).toBeOkResponse();
+ });
+ });
+
+ describe('providers', () => {
+ it('should list available providers', async () => {
+ const response = await providers({ client: ctx.client });
+ expect(response.response).toBeOkResponse();
+ expect(response.data).toBeDefined();
+ expect(Array.isArray(response.data)).toBe(true);
+ });
+ });
+
+ describe('sessions', () => {
+ it('should start an agent and create a session', async () => {
+ const startResponse = await startAgent({
+ client: ctx.client,
+ body: {
+ working_dir: os.tmpdir(),
+ },
+ });
+ expect(startResponse.response).toBeOkResponse();
+ expect(startResponse.data).toBeDefined();
+
+ const session = startResponse.data!;
+ expect(session.id).toBeDefined();
+ expect(session.name).toBeDefined();
+
+ const getResponse = await getSession({
+ client: ctx.client,
+ path: {
+ session_id: session.id,
+ },
+ });
+ expect(getResponse.response).toBeOkResponse();
+ expect(getResponse.data).toBeDefined();
+ expect(getResponse.data!.id).toBe(session.id);
+ });
+
+ it('should list sessions', async () => {
+ const sessionsResponse = await listSessions({ client: ctx.client });
+ expect(sessionsResponse.response).toBeOkResponse();
+ expect(sessionsResponse.data).toBeDefined();
+ expect(sessionsResponse.data!.sessions).toBeDefined();
+ expect(Array.isArray(sessionsResponse.data!.sessions)).toBe(true);
+ });
+ });
+
+ describe('messaging', () => {
+ it('should accept a message request to /reply endpoint', async () => {
+ // Start a session first
+ const startResponse = await startAgent({
+ client: ctx.client,
+ body: {
+ working_dir: os.tmpdir(),
+ },
+ });
+ expect(startResponse.response).toBeOkResponse();
+ const sessionId = startResponse.data!.id;
+
+ const abortController = new AbortController();
+ const { stream } = await reply({
+ client: ctx.client,
+ body: {
+ session_id: sessionId,
+ user_message: {
+ role: 'user',
+ created: Math.floor(Date.now() / 1000),
+ content: [
+ {
+ type: 'text',
+ text: 'Hello',
+ },
+ ],
+ metadata: {
+ userVisible: true,
+ agentVisible: true,
+ },
+ },
+ },
+ throwOnError: true,
+ signal: abortController.signal,
+ });
+
+ const timeout = setTimeout(() => abortController.abort(), 1000);
+ try {
+ for await (const event of stream) {
+ expect(event).toBeDefined();
+ break;
+ }
+ } catch {
+ // Aborted or error, that's fine
+ }
+ clearTimeout(timeout);
+
+ await stopAgent({
+ client: ctx.client,
+ body: {
+ session_id: sessionId,
+ },
+ });
+ });
+ });
+
+ describe('the developer tool', () => {
+ it('should see the full PATH when calling the developer tool', async (testContext) => {
+ const currentPath = getUserPath();
+
+ const pathEntry = currentPath.find((entry) => !CONSTRAINED_PATH.includes(entry));
+ if (!pathEntry) {
+ expect.fail(`Could not find a path entry not in ${CONSTRAINED_PATH}`);
+ }
+
+ let configResponse = await readConfig({
+ client: ctx.client,
+ body: {
+ key: 'GOOSE_PROVIDER',
+ is_secret: false,
+ },
+ });
+
+ let providerName = configResponse.data as string | null | undefined;
+
+ if (!providerName) {
+ testContext.skip('Skipping tool execution test - no GOOSE_PROVIDER configured');
+ return;
+ }
+
+ const modelResponse = await readConfig({
+ client: ctx.client,
+ body: {
+ key: 'GOOSE_MODEL',
+ is_secret: false,
+ },
+ });
+ const modelName = (modelResponse.data as string | null) || undefined;
+
+ const startResponse = await startAgent({
+ client: ctx.client,
+ body: {
+ working_dir: os.tmpdir(),
+ },
+ });
+ expect(startResponse.response).toBeOkResponse();
+ const sessionId = startResponse.data!.id;
+
+ const providerResponse = await updateAgentProvider({
+ client: ctx.client,
+ body: {
+ session_id: sessionId,
+ provider: providerName,
+ model: modelName,
+ },
+ });
+ expect(providerResponse.response).toBeOkResponse();
+
+ const abortController = new AbortController();
+ const { stream } = await reply({
+ client: ctx.client,
+ body: {
+ session_id: sessionId,
+ user_message: {
+ role: 'user',
+ created: Math.floor(Date.now() / 1000),
+ content: [
+ {
+ type: 'text',
+ text: 'Use your developer shell tool to read $PATH and return its content directly, with no further information about it',
+ },
+ ],
+ metadata: {
+ userVisible: true,
+ agentVisible: true,
+ },
+ },
+ },
+ throwOnError: true,
+ signal: abortController.signal,
+ });
+
+ let returnedPath: string | undefined = undefined;
+ const timeout = setTimeout(() => abortController.abort(), 60000); // 60s timeout
+
+ try {
+ for await (const event of stream) {
+ console.log('stream: ', JSON.stringify(event));
+
+ if (event.type === 'Message') {
+ const content = event.message?.content?.[0];
+ if (content?.type === 'toolResponse') {
+ const toolResult = content as {
+ toolResult?: { value?: { content?: Array<{ text?: string }> } };
+ };
+ const output = toolResult?.toolResult?.value?.content?.[0]?.text;
+ if (output && output.includes('/usr')) {
+ clearTimeout(timeout);
+ abortController.abort();
+ returnedPath = output;
+ break;
+ }
+ }
+ }
+ }
+ } catch (error) {
+ // Aborted or error
+ if (!(error instanceof Error && error.name === 'AbortError')) {
+ console.log('Stream error: ', error);
+ }
+ }
+ clearTimeout(timeout);
+
+ await stopAgent({
+ client: ctx.client,
+ body: {
+ session_id: sessionId,
+ },
+ });
+
+ expect(returnedPath, 'the agent should return a value for $PATH').toBeDefined();
+ expect(returnedPath, '$PATH should contain the expected entry').toContain(pathEntry);
+ });
+ });
+});
diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts
new file mode 100644
index 000000000000..6d6e208299a7
--- /dev/null
+++ b/ui/desktop/tests/integration/setup.ts
@@ -0,0 +1,134 @@
+/**
+ * Integration test setup for testing the goosed binary via the TypeScript API client.
+ *
+ * This test suite spawns a real goosed process and issues requests via the
+ * auto-generated API client.
+ */
+
+import type { ChildProcess } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import type { Client } from '../../src/api/client';
+import { startGoosed as startGoosedBase, checkServerStatus, type Logger } from '../../src/goosed';
+import { expect } from 'vitest';
+
+function stringifyResponse(response: Response) {
+ const details = {
+ ok: response.ok,
+ status: response.status,
+ statusText: response.statusText,
+ url: response.url,
+ headers: response.headers ? Object.fromEntries(response.headers) : undefined,
+ };
+ return JSON.stringify(details, null, 2);
+}
+
+expect.extend({
+ toBeOkResponse(response) {
+ const pass = response.ok === true;
+ return {
+ pass,
+ message: () =>
+ pass
+ ? 'expected response not to be ok'
+ : `expected response to be ok, got: ${stringifyResponse(response)}`,
+ };
+ },
+});
+
+const TEST_SECRET_KEY = 'test';
+
+export interface GoosedTestContext {
+ client: Client;
+ baseUrl: string;
+ secretKey: string;
+ process: ChildProcess | null;
+ cleanup: () => Promise;
+}
+
+export async function setupGoosed({
+ pathOverride,
+ configYaml,
+}: {
+ pathOverride?: string;
+ configYaml?: string;
+}): Promise {
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'goose-app-root-'));
+
+ if (configYaml) {
+ await fs.promises.mkdir(path.join(tempDir, 'config'), { recursive: true });
+ await fs.promises.writeFile(path.join(tempDir, 'config', 'config.yaml'), configYaml);
+ }
+
+ const testLogger: Logger = {
+ info: (...args) => {
+ if (process.env.DEBUG) {
+ console.log('[goosed]', ...args);
+ }
+ },
+ error: (...args) => console.error('[goosed]', ...args),
+ };
+
+ const additionalEnv: Record = {
+ GOOSE_PATH_ROOT: tempDir,
+ };
+
+ if (pathOverride) {
+ additionalEnv.PATH = pathOverride;
+ }
+
+ const {
+ baseUrl,
+ process: goosedProcess,
+ client,
+ cleanup: baseCleanup,
+ errorLog,
+ } = await startGoosedBase({
+ serverSecret: TEST_SECRET_KEY,
+ env: additionalEnv,
+ logger: testLogger,
+ });
+
+ if (!goosedProcess) {
+ throw new Error('Expected goosed process to be started, but got external backend');
+ }
+
+ const cleanup = async (): Promise => {
+ // dump server logs to test logs, visible if there are test failures
+ try {
+ const logsPath = path.join(tempDir, 'state', 'logs', 'server');
+ if (fs.existsSync(logsPath)) {
+ const logDirs = await fs.promises.readdir(logsPath);
+ for (const logDir of logDirs) {
+ const logFiles = await fs.promises.readdir(path.join(logsPath, logDir));
+ for (const logFile of logFiles) {
+ const logPath = path.join(logsPath, logDir, logFile);
+ const logContent = await fs.promises.readFile(logPath, 'utf8');
+ console.log(logContent);
+ }
+ }
+ }
+ } catch {
+ // Logs may not exist
+ }
+
+ await baseCleanup();
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
+ };
+
+ const serverReady = await checkServerStatus(client, errorLog);
+ if (!serverReady) {
+ await cleanup();
+ console.error('Server stderr:', errorLog.join('\n'));
+ throw new Error('Failed to start goosed');
+ }
+
+ return {
+ client,
+ baseUrl,
+ secretKey: TEST_SECRET_KEY,
+ process: goosedProcess,
+ cleanup,
+ };
+}
diff --git a/ui/desktop/tests/integration/vitest.d.ts b/ui/desktop/tests/integration/vitest.d.ts
new file mode 100644
index 000000000000..9b98e4d1240d
--- /dev/null
+++ b/ui/desktop/tests/integration/vitest.d.ts
@@ -0,0 +1,10 @@
+import 'vitest';
+
+declare module 'vitest' {
+ interface Assertion {
+ toBeOkResponse(): T;
+ }
+ interface AsymmetricMatchersContaining {
+ toBeOkResponse(): unknown;
+ }
+}
diff --git a/ui/desktop/tsconfig.json b/ui/desktop/tsconfig.json
index e99ed414b0c4..3bbd14aea9e3 100644
--- a/ui/desktop/tsconfig.json
+++ b/ui/desktop/tsconfig.json
@@ -38,8 +38,8 @@
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
- "noImplicitReturns": true
+ "noImplicitReturns": true,
},
- "include": ["src"],
- "references": [{ "path": "./tsconfig.node.json" }]
+ "include": ["src", "tests/integration"],
+ "references": [{ "path": "./tsconfig.node.json" }],
}
diff --git a/ui/desktop/vitest.integration.config.ts b/ui/desktop/vitest.integration.config.ts
new file mode 100644
index 000000000000..0d9d453f9982
--- /dev/null
+++ b/ui/desktop/vitest.integration.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig } from 'vitest/config';
+import path from 'path';
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['tests/integration/**/*.test.ts'],
+ testTimeout: 60000,
+ hookTimeout: 60000,
+ pool: 'forks',
+ singleFork: true,
+ silent: 'passed-only',
+ },
+});