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', + }, +});