From 6050d394d5c0f1c9a3172a78e59fd9d0c31442ce Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 09:55:45 -0500 Subject: [PATCH 01/40] Scaffold a goosed integration test --- ui/desktop/package.json | 3 + ui/desktop/tests/integration/README.md | 133 +++++++++++++++ ui/desktop/tests/integration/goosed.test.ts | 53 ++++++ ui/desktop/tests/integration/setup.ts | 178 ++++++++++++++++++++ ui/desktop/vitest.integration.config.ts | 21 +++ 5 files changed, 388 insertions(+) create mode 100644 ui/desktop/tests/integration/README.md create mode 100644 ui/desktop/tests/integration/goosed.test.ts create mode 100644 ui/desktop/tests/integration/setup.ts create mode 100644 ui/desktop/vitest.integration.config.ts diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 1912239493d6..7515b3ecf9a2 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/tests/integration/README.md b/ui/desktop/tests/integration/README.md new file mode 100644 index 000000000000..be3d33cba04d --- /dev/null +++ b/ui/desktop/tests/integration/README.md @@ -0,0 +1,133 @@ +# Goosed Integration Tests + +This test suite validates the `goosed` binary by issuing requests through the TypeScript API client. + +## Overview + +These tests spawn a real `goosed` process and exercise the HTTP API endpoints using the auto-generated TypeScript client from `src/api/`. This ensures the server binary works correctly end-to-end. + +## Prerequisites + +1. Build the goosed binary: + ```bash + cd /path/to/goose + cargo build + ``` + +2. Install npm dependencies: + ```bash + cd ui/desktop + npm install + ``` + +## Running Tests + +```bash +# Run all integration tests +npm run test:integration + +# Run in watch mode (re-runs on file changes) +npm run test:integration:watch + +# Run with debug output +npm run test:integration:debug +``` + +## Writing Tests + +### Basic Structure + +```typescript +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { startGoosed, type GoosedTestContext } from './setup'; +import { someApiFunction } from '../../src/api'; + +describe('my feature tests', () => { + let ctx: GoosedTestContext; + + beforeAll(async () => { + ctx = await startGoosed(); + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + it('should do something', async () => { + const response = await someApiFunction({ client: ctx.client }); + expect(response.response.ok).toBe(true); + }); +}); +``` + +### Test Context + +The `startGoosed()` function returns a context object with: + +- `client` - Configured API client with authentication headers +- `port` - The port the server is running on +- `baseUrl` - Full base URL (e.g., `http://127.0.0.1:12345`) +- `cleanup()` - Function to stop the server process + +### Sharing a Server Instance + +For tests that can share a server instance (faster execution): + +```typescript +import { getSharedContext, cleanupSharedContext } from './setup'; + +describe('shared server tests', () => { + let ctx: GoosedTestContext; + + beforeAll(async () => { + ctx = await getSharedContext(); + }); + + afterAll(async () => { + await cleanupSharedContext(); + }); + + // ... tests +}); +``` + +### Available API Functions + +Import API functions from `../../src/api`. Common ones include: + +- `status` - Health check endpoint +- `providers` - List available LLM providers +- `readConfig` - Read a configuration value +- `readAllConfig` - Read all configuration +- `listSessions` - List chat sessions +- `startAgent` - Start the AI agent + +See `src/api/sdk.gen.ts` for all available functions. + +## Architecture + +``` +tests/integration/ +├── setup.ts # Test utilities (server spawn, client creation) +├── goosed.test.ts # Main API tests +└── README.md # This file +``` + +The setup handles: +- Finding the goosed binary (debug or release build) +- Spawning with a random available port +- Waiting for server readiness +- Configuring the API client with authentication +- Graceful shutdown and cleanup + +## Troubleshooting + +### "Binary not found" error +Build the project first: `cargo build` from the repository root. + +### Tests timing out +- Increase `testTimeout` in `vitest.integration.config.ts` +- Check if goosed is starting correctly (run with `npm run test:integration:debug`) + +### Authentication errors (401) +The setup automatically configures the `X-Secret-Key` header. If you're making manual requests, include this header with value `test`. diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts new file mode 100644 index 000000000000..2f184484040b --- /dev/null +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -0,0 +1,53 @@ +/** + * 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 { startGoosed, type GoosedTestContext } from './setup'; +import { status, readConfig, providers } from '../../src/api'; + +describe('goosed API integration tests', () => { + let ctx: GoosedTestContext; + + beforeAll(async () => { + ctx = await startGoosed(); + }); + + afterAll(async () => { + await ctx.cleanup(); + }); + + describe('health', () => { + it('should respond to status endpoint', async () => { + const response = await status({ client: ctx.client }); + expect(response.response.ok).toBe(true); + 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, + }, + }); + // The endpoint should succeed even if the key doesn't exist + expect(response.response.ok).toBe(true); + }); + }); + + describe('providers', () => { + it('should list available providers', async () => { + const response = await providers({ client: ctx.client }); + expect(response.response.ok).toBe(true); + expect(response.data).toBeDefined(); + expect(Array.isArray(response.data)).toBe(true); + }); + }); +}); diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts new file mode 100644 index 000000000000..f3ed7468c567 --- /dev/null +++ b/ui/desktop/tests/integration/setup.ts @@ -0,0 +1,178 @@ +/** + * 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 from ui/desktop/src/api. + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { createClient, createConfig } from '../../src/api/client'; +import type { Client } from '../../src/api/client'; + +// Secret key for authenticating with goosed (must match GOOSE_SERVER__SECRET_KEY env var) +const TEST_SECRET_KEY = 'test'; + +export interface GoosedTestContext { + client: Client; + baseUrl: string; + port: number; + secretKey: string; + process: ChildProcess; + cleanup: () => Promise; +} + +let portCounter = 13100; + +function getNextPort(): number { + return portCounter++; +} + +function findGoosedBinary(): string { + const goosedBinaryEnv = process.env.GOOSED_BINARY; + if (goosedBinaryEnv) { + return goosedBinaryEnv; + } + + const possiblePaths = [ + path.join(process.cwd(), 'src', 'bin', 'goosed'), + path.join(process.cwd(), 'bin', 'goosed'), + path.join(process.cwd(), '..', '..', 'target', 'debug', 'goosed'), + path.join(process.cwd(), '..', '..', 'target', 'release', 'goosed'), + ]; + + for (const binPath of possiblePaths) { + const resolvedPath = path.resolve(binPath); + if (fs.existsSync(resolvedPath)) { + return resolvedPath; + } + } + + throw new Error( + `Could not find goosed binary in any of the expected locations: ${possiblePaths.join(', ')}` + ); +} + +async function waitForServer(baseUrl: string, timeoutMs = 10000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(`${baseUrl}/status`); + if (response.ok) { + return; + } + } catch { + // Server not ready yet + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Server at ${baseUrl} did not become ready within ${timeoutMs}ms`); +} + +export async function startGoosed(): Promise { + const port = getNextPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const goosedPath = findGoosedBinary(); + + const goosedProcess = spawn(goosedPath, ['agent'], { + env: { + ...process.env, + GOOSE_PORT: port.toString(), + GOOSE_SERVER__SECRET_KEY: TEST_SECRET_KEY, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const stderrLines: string[] = []; + + goosedProcess.stdout?.on('data', (data: Buffer) => { + if (process.env.DEBUG) { + console.log(`[goosed:${port}:stdout]`, data.toString()); + } + }); + + goosedProcess.stderr?.on('data', (data: Buffer) => { + const lines = data + .toString() + .split('\n') + .filter((l) => l.trim()); + lines.forEach((line) => { + stderrLines.push(line); + if (process.env.DEBUG) { + console.error(`[goosed:${port}:stderr]`, line); + } + }); + }); + + goosedProcess.on('error', (err: Error) => { + console.error(`Failed to start goosed on port ${port}:`, err); + }); + + try { + await waitForServer(baseUrl); + } catch (error) { + goosedProcess.kill(); + console.error('Server stderr:', stderrLines.join('\n')); + throw error; + } + + // Create client with authentication header + const client = createClient( + createConfig({ + baseUrl, + headers: { + 'X-Secret-Key': TEST_SECRET_KEY, + }, + }) + ); + + const cleanup = async (): Promise => { + return new Promise((resolve) => { + if (goosedProcess.killed) { + resolve(); + return; + } + + goosedProcess.on('close', () => { + resolve(); + }); + + goosedProcess.kill('SIGTERM'); + + // Force kill after timeout + setTimeout(() => { + if (!goosedProcess.killed) { + goosedProcess.kill('SIGKILL'); + } + resolve(); + }, 5000); + }); + }; + + return { + client, + baseUrl, + port, + secretKey: TEST_SECRET_KEY, + process: goosedProcess, + cleanup, + }; +} + +// Global test context for shared server instance +let sharedContext: GoosedTestContext | null = null; + +export async function getSharedGoosed(): Promise { + if (!sharedContext) { + sharedContext = await startGoosed(); + } + return sharedContext; +} + +export async function cleanupSharedGoosed(): Promise { + if (sharedContext) { + await sharedContext.cleanup(); + sharedContext = null; + } +} diff --git a/ui/desktop/vitest.integration.config.ts b/ui/desktop/vitest.integration.config.ts new file mode 100644 index 000000000000..ed1d0c350acc --- /dev/null +++ b/ui/desktop/vitest.integration.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + globals: true, + environment: 'node', + include: ['tests/integration/**/*.test.ts'], + testTimeout: 30000, + hookTimeout: 30000, + pool: 'forks', + singleFork: true, + }, +}); From 50bced72add2a9338b1aebbb4a2effb1c14ec374 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 10:02:46 -0500 Subject: [PATCH 02/40] clean up readme --- ui/desktop/tests/integration/README.md | 107 +------------------------ 1 file changed, 2 insertions(+), 105 deletions(-) diff --git a/ui/desktop/tests/integration/README.md b/ui/desktop/tests/integration/README.md index be3d33cba04d..aa8df2a0273e 100644 --- a/ui/desktop/tests/integration/README.md +++ b/ui/desktop/tests/integration/README.md @@ -2,16 +2,12 @@ This test suite validates the `goosed` binary by issuing requests through the TypeScript API client. -## Overview - -These tests spawn a real `goosed` process and exercise the HTTP API endpoints using the auto-generated TypeScript client from `src/api/`. This ensures the server binary works correctly end-to-end. - ## Prerequisites 1. Build the goosed binary: ```bash - cd /path/to/goose - cargo build + # in the project root + cargo build --bin goosed ``` 2. Install npm dependencies: @@ -32,102 +28,3 @@ npm run test:integration:watch # Run with debug output npm run test:integration:debug ``` - -## Writing Tests - -### Basic Structure - -```typescript -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { startGoosed, type GoosedTestContext } from './setup'; -import { someApiFunction } from '../../src/api'; - -describe('my feature tests', () => { - let ctx: GoosedTestContext; - - beforeAll(async () => { - ctx = await startGoosed(); - }); - - afterAll(async () => { - await ctx.cleanup(); - }); - - it('should do something', async () => { - const response = await someApiFunction({ client: ctx.client }); - expect(response.response.ok).toBe(true); - }); -}); -``` - -### Test Context - -The `startGoosed()` function returns a context object with: - -- `client` - Configured API client with authentication headers -- `port` - The port the server is running on -- `baseUrl` - Full base URL (e.g., `http://127.0.0.1:12345`) -- `cleanup()` - Function to stop the server process - -### Sharing a Server Instance - -For tests that can share a server instance (faster execution): - -```typescript -import { getSharedContext, cleanupSharedContext } from './setup'; - -describe('shared server tests', () => { - let ctx: GoosedTestContext; - - beforeAll(async () => { - ctx = await getSharedContext(); - }); - - afterAll(async () => { - await cleanupSharedContext(); - }); - - // ... tests -}); -``` - -### Available API Functions - -Import API functions from `../../src/api`. Common ones include: - -- `status` - Health check endpoint -- `providers` - List available LLM providers -- `readConfig` - Read a configuration value -- `readAllConfig` - Read all configuration -- `listSessions` - List chat sessions -- `startAgent` - Start the AI agent - -See `src/api/sdk.gen.ts` for all available functions. - -## Architecture - -``` -tests/integration/ -├── setup.ts # Test utilities (server spawn, client creation) -├── goosed.test.ts # Main API tests -└── README.md # This file -``` - -The setup handles: -- Finding the goosed binary (debug or release build) -- Spawning with a random available port -- Waiting for server readiness -- Configuring the API client with authentication -- Graceful shutdown and cleanup - -## Troubleshooting - -### "Binary not found" error -Build the project first: `cargo build` from the repository root. - -### Tests timing out -- Increase `testTimeout` in `vitest.integration.config.ts` -- Check if goosed is starting correctly (run with `npm run test:integration:debug`) - -### Authentication errors (401) -The setup automatically configures the `X-Secret-Key` header. If you're making manual requests, include this header with value `test`. From 4463f1795851159b2ca83026f9b0409cc516b7a5 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 10:15:01 -0500 Subject: [PATCH 03/40] reply tests --- ui/desktop/tests/integration/goosed.test.ts | 128 +++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index 2f184484040b..2978e1edd1b4 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -7,7 +7,15 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { startGoosed, type GoosedTestContext } from './setup'; -import { status, readConfig, providers } from '../../src/api'; +import { + status, + readConfig, + providers, + startAgent, + stopAgent, + listSessions, + getSession, +} from '../../src/api'; describe('goosed API integration tests', () => { let ctx: GoosedTestContext; @@ -37,7 +45,6 @@ describe('goosed API integration tests', () => { is_secret: false, }, }); - // The endpoint should succeed even if the key doesn't exist expect(response.response.ok).toBe(true); }); }); @@ -50,4 +57,121 @@ describe('goosed API integration tests', () => { 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: '/tmp', + }, + }); + console.log(startResponse); + expect(startResponse.response.ok).toBe(true); + expect(startResponse.data).toBeDefined(); + + const session = startResponse.data!; + expect(session.id).toBeDefined(); + expect(session.name).toBeDefined(); + + // Verify we can retrieve the session by ID + // Note: path parameter is 'session_id' not 'id' + const getResponse = await getSession({ + client: ctx.client, + path: { + session_id: session.id, + }, + }); + expect(getResponse.response.ok).toBe(true); + expect(getResponse.data).toBeDefined(); + expect(getResponse.data!.id).toBe(session.id); + + // Cleanup - stop the agent + const stopResponse = await stopAgent({ + client: ctx.client, + body: { + session_id: session.id, + }, + }); + console.log(stopResponse); + expect(stopResponse.response.ok).toBe(true); + }); + + it('should list sessions', async () => { + const sessionsResponse = await listSessions({ client: ctx.client }); + expect(sessionsResponse.response.ok).toBe(true); + 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: '/tmp', + }, + }); + expect(startResponse.response.ok).toBe(true); + const sessionId = startResponse.data!.id; + + // Send a message via the /reply endpoint + // The ChatRequest requires session_id and user_message (Message type) + // Note: Message fields use camelCase due to #[serde(rename_all = "camelCase")] + const sseResponse = await fetch(`${ctx.baseUrl}/reply`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': ctx.secretKey, + Accept: 'text/event-stream', + }, + body: JSON.stringify({ + session_id: sessionId, + user_message: { + role: 'user', + created: Math.floor(Date.now() / 1000), // Unix timestamp in seconds + content: [ + { + type: 'text', + text: 'Hello', + }, + ], + metadata: { + userVisible: true, + agentVisible: true, + }, + }, + }), + }); + + // The endpoint should accept the request format + // 200 = success, the SSE stream will contain the response or error + expect(sseResponse.status).toBe(200); + + // Read just enough to verify the stream works + const reader = sseResponse.body?.getReader(); + if (reader) { + // Cancel after a short read - we just want to verify the endpoint works + setTimeout(() => reader.cancel(), 1000); + try { + const { value } = await reader.read(); + // We should get some data back (either response or error about provider) + expect(value).toBeDefined(); + } catch { + // Reader was cancelled, that's fine + } + } + + // Cleanup + await stopAgent({ + client: ctx.client, + body: { + session_id: sessionId, + }, + }); + }); + }); }); From 90f8440999257554c72928b55415ae528aab64c6 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 10:53:40 -0500 Subject: [PATCH 04/40] Nearly there --- ui/desktop/src/App.test.tsx | 5 - ui/desktop/src/components/BaseChat.tsx | 2 +- .../src/components/OllamaSetup.test.tsx | 11 -- .../components/recipes/RecipeActivities.tsx | 2 +- ui/desktop/src/hooks/useRecipeManager.ts | 2 +- ....test.ts => parameterSubstitution.test.ts} | 10 +- ui/desktop/src/utils/parameterSubtitution.ts | 11 ++ ui/desktop/src/utils/providerUtils.ts | 77 --------- ui/desktop/tests/integration/goosed.test.ts | 152 ++++++++++++++++-- 9 files changed, 157 insertions(+), 115 deletions(-) rename ui/desktop/src/utils/__tests__/{providerUtils.test.ts => parameterSubstitution.test.ts} (98%) create mode 100644 ui/desktop/src/utils/parameterSubtitution.ts delete mode 100644 ui/desktop/src/utils/providerUtils.ts 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 7b335ff1abbe..7698bd82e672 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/parameterSubtitution'; import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; import { toastSuccess } from '../toasts'; import { Recipe } from '../recipe'; diff --git a/ui/desktop/src/components/OllamaSetup.test.tsx b/ui/desktop/src/components/OllamaSetup.test.tsx index 5c4674a748ce..01ae9931d133 100644 --- a/ui/desktop/src/components/OllamaSetup.test.tsx +++ b/ui/desktop/src/components/OllamaSetup.test.tsx @@ -2,7 +2,6 @@ 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 @@ -162,8 +161,6 @@ describe('OllamaSetup', () => { }); it('should handle successful connection', async () => { - vi.mocked(providerUtils.initializeSystem).mockResolvedValue(undefined); - render(); await waitFor(() => { @@ -174,20 +171,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..49ee027e37d2 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/parameterSubtitution'; interface RecipeActivitiesProps { append: (text: string) => void; diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index e5240b83c643..d0ad588e45bb 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/parameterSubtitution'; import { updateSessionUserRecipeValues } from '../api'; import { useChatContext } from '../contexts/ChatContext'; import { ChatType } from '../types/chat'; diff --git a/ui/desktop/src/utils/__tests__/providerUtils.test.ts b/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts similarity index 98% rename from ui/desktop/src/utils/__tests__/providerUtils.test.ts rename to ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts index a64848cc0aaa..81f834166e2f 100644 --- a/ui/desktop/src/utils/__tests__/providerUtils.test.ts +++ b/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { substituteParameters } from '../providerUtils'; +import { substituteParameters } from '../parameterSubtitution'; describe('providerUtils', () => { describe('substituteParameters', () => { @@ -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/parameterSubtitution.ts b/ui/desktop/src/utils/parameterSubtitution.ts new file mode 100644 index 000000000000..c3489e651b7c --- /dev/null +++ b/ui/desktop/src/utils/parameterSubtitution.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 index 2978e1edd1b4..9bf2f3e5dd9a 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -15,6 +15,8 @@ import { stopAgent, listSessions, getSession, + updateAgentProvider, + upsertConfig, } from '../../src/api'; describe('goosed API integration tests', () => { @@ -66,7 +68,6 @@ describe('goosed API integration tests', () => { working_dir: '/tmp', }, }); - console.log(startResponse); expect(startResponse.response.ok).toBe(true); expect(startResponse.data).toBeDefined(); @@ -85,16 +86,6 @@ describe('goosed API integration tests', () => { expect(getResponse.response.ok).toBe(true); expect(getResponse.data).toBeDefined(); expect(getResponse.data!.id).toBe(session.id); - - // Cleanup - stop the agent - const stopResponse = await stopAgent({ - client: ctx.client, - body: { - session_id: session.id, - }, - }); - console.log(stopResponse); - expect(stopResponse.response.ok).toBe(true); }); it('should list sessions', async () => { @@ -118,9 +109,6 @@ describe('goosed API integration tests', () => { expect(startResponse.response.ok).toBe(true); const sessionId = startResponse.data!.id; - // Send a message via the /reply endpoint - // The ChatRequest requires session_id and user_message (Message type) - // Note: Message fields use camelCase due to #[serde(rename_all = "camelCase")] const sseResponse = await fetch(`${ctx.baseUrl}/reply`, { method: 'POST', headers: { @@ -165,6 +153,142 @@ describe('goosed API integration tests', () => { } } + // Cleanup - may fail if agent wasn't fully instantiated + await stopAgent({ + client: ctx.client, + body: { + session_id: sessionId, + }, + }); + }); + + it('should execute a tool and return results', async () => { + // This test requires a configured provider + // Check if GOOSE_PROVIDER is set in config, or use env var to configure it + let configResponse = await readConfig({ + client: ctx.client, + body: { + key: 'GOOSE_PROVIDER', + is_secret: false, + }, + }); + + // response.data is the config value directly (or null/undefined if not set) + let providerName = configResponse.data as string | null | undefined; + + if (!providerName) { + console.log('Skipping tool execution test - no GOOSE_PROVIDER configured'); + return; + } + + // Read model from config (or use default) + const modelResponse = await readConfig({ + client: ctx.client, + body: { + key: 'GOOSE_MODEL', + is_secret: false, + }, + }); + const modelName = (modelResponse.data as string | null) || undefined; + + // Start a session + const startResponse = await startAgent({ + client: ctx.client, + body: { + working_dir: '/tmp', + }, + }); + expect(startResponse.response.ok).toBe(true); + const sessionId = startResponse.data!.id; + + // Configure the provider (and optionally model) for this session + const providerResponse = await updateAgentProvider({ + client: ctx.client, + body: { + session_id: sessionId, + provider: providerName, + model: modelName, + }, + }); + expect(providerResponse.response.ok).toBe(true); + + // Send a message that requires tool use + const sseResponse = await fetch(`${ctx.baseUrl}/reply`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': ctx.secretKey, + Accept: 'text/event-stream', + }, + body: JSON.stringify({ + 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, + }, + }, + }), + }); + + expect(sseResponse.status).toBe(200); + + // Collect SSE events until the stream ends or timeout + const events: string[] = []; + const reader = sseResponse.body?.getReader(); + const decoder = new TextDecoder(); + + if (reader) { + const timeout = setTimeout(() => reader.cancel(), 60000); // 60s timeout + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + events.push(chunk); + + // Check if we got a complete response (look for assistant message with text) + const fullResponse = events.join(''); + if (fullResponse.includes('"role":"assistant"') && fullResponse.includes('/usr')) { + // Got a response that includes PATH content + clearTimeout(timeout); + reader.cancel(); + break; + } + } + } catch { + // Reader cancelled or error + } + clearTimeout(timeout); + } + + // Verify we received events + expect(events.length).toBeGreaterThan(0); + + // The response should contain PATH-like content (directories separated by colons) + const fullResponse = events.join(''); + console.log(fullResponse); + + // Should have received some SSE data events + expect(fullResponse).toContain('data:'); + + // If provider worked, we should see tool usage or response + // The response might contain the PATH or an error about the tool + const hasPathContent = fullResponse.includes('/usr') || fullResponse.includes('/bin'); + + // At minimum, we should have gotten some meaningful response + expect(hasPathContent).toBe(true); + // Cleanup await stopAgent({ client: ctx.client, From 4cd2b40d2c3b14c08a8a1861e2bffa19ed6d33e3 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 11:27:29 -0500 Subject: [PATCH 05/40] Add a test for developer $PATH --- ui/desktop/tests/integration/goosed.test.ts | 75 ++++++++++++--------- ui/desktop/tests/integration/setup.ts | 3 +- 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index 9bf2f3e5dd9a..d2f7b4d20fdb 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -16,14 +16,27 @@ import { listSessions, getSession, updateAgentProvider, - upsertConfig, } from '../../src/api'; +function getPathEntries(): string[] { + const path = process.env.PATH; + + if (!path) { + return []; + } + + const delimiter = process.platform === 'win32' ? ';' : ':'; + + return path.split(delimiter).filter((entry) => entry.length > 0); +} + +const CONSTRAINED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; + describe('goosed API integration tests', () => { let ctx: GoosedTestContext; beforeAll(async () => { - ctx = await startGoosed(); + ctx = await startGoosed(CONSTRAINED_PATH); }); afterAll(async () => { @@ -162,7 +175,15 @@ describe('goosed API integration tests', () => { }); }); - it('should execute a tool and return results', async () => { + it('should see the full PATH when calling the developer tool', async () => { + const currentPath = getPathEntries(); + + // find a part of current path that is not in CONSTRAINED_PATH + const pathEntry = currentPath.find((entry) => !CONSTRAINED_PATH.includes(entry)); + if (!pathEntry) { + expect.fail(`Could not find a path entry not in ${CONSTRAINED_PATH}`); + } + // This test requires a configured provider // Check if GOOSE_PROVIDER is set in config, or use env var to configure it let configResponse = await readConfig({ @@ -241,11 +262,10 @@ describe('goosed API integration tests', () => { expect(sseResponse.status).toBe(200); - // Collect SSE events until the stream ends or timeout - const events: string[] = []; const reader = sseResponse.body?.getReader(); const decoder = new TextDecoder(); + let returnedPath: string = undefined; if (reader) { const timeout = setTimeout(() => reader.cancel(), 60000); // 60s timeout @@ -255,15 +275,21 @@ describe('goosed API integration tests', () => { if (done) break; const chunk = decoder.decode(value, { stream: true }); - events.push(chunk); - - // Check if we got a complete response (look for assistant message with text) - const fullResponse = events.join(''); - if (fullResponse.includes('"role":"assistant"') && fullResponse.includes('/usr')) { - // Got a response that includes PATH content - clearTimeout(timeout); - reader.cancel(); - break; + + try { + // remove data: prefix + const data = JSON.parse(chunk.replace(/^data:/, '')); + const output = data?.message?.content?.[0]?.toolResult?.value?.content?.[0]?.text; + if (output && output.includes('/usr')) { + // Got a response that includes PATH content + clearTimeout(timeout); + reader.cancel(); + returnedPath = output; + break; + } + } catch { + // The response we care about is always a complete JSON object. Others will be + // incomplete, so we expect parsing errors. } } } catch { @@ -272,30 +298,15 @@ describe('goosed API integration tests', () => { clearTimeout(timeout); } - // Verify we received events - expect(events.length).toBeGreaterThan(0); - - // The response should contain PATH-like content (directories separated by colons) - const fullResponse = events.join(''); - console.log(fullResponse); - - // Should have received some SSE data events - expect(fullResponse).toContain('data:'); - - // If provider worked, we should see tool usage or response - // The response might contain the PATH or an error about the tool - const hasPathContent = fullResponse.includes('/usr') || fullResponse.includes('/bin'); - - // At minimum, we should have gotten some meaningful response - expect(hasPathContent).toBe(true); - - // Cleanup 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 index f3ed7468c567..5606eaeefdbc 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -70,7 +70,7 @@ async function waitForServer(baseUrl: string, timeoutMs = 10000): Promise throw new Error(`Server at ${baseUrl} did not become ready within ${timeoutMs}ms`); } -export async function startGoosed(): Promise { +export async function startGoosed(path?: string): Promise { const port = getNextPort(); const baseUrl = `http://127.0.0.1:${port}`; const goosedPath = findGoosedBinary(); @@ -78,6 +78,7 @@ export async function startGoosed(): Promise { const goosedProcess = spawn(goosedPath, ['agent'], { env: { ...process.env, + ...(path && { PATH: path }), GOOSE_PORT: port.toString(), GOOSE_SERVER__SECRET_KEY: TEST_SECRET_KEY, }, From a1d049ed1563a19bf33fe6ea18af3f3ab0a92760 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 11:48:22 -0500 Subject: [PATCH 06/40] Read it straight from the $SHELL --- ui/desktop/tests/integration/goosed.test.ts | 46 ++++++++------------- 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index d2f7b4d20fdb..fc05ea63124d 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -17,17 +17,22 @@ import { getSession, updateAgentProvider, } from '../../src/api'; - -function getPathEntries(): string[] { - const path = process.env.PATH; - - if (!path) { - return []; +import { execSync } from 'child_process'; + +function getUserPath(): string[] { + try { + const userShell = process.env.SHELL || '/bin/bash'; + const path = execSync(`${userShell} -i -c 'echo $PATH'`, { + encoding: 'utf-8', + timeout: 5000, + }).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; } - - const delimiter = process.platform === 'win32' ? ';' : ':'; - - return path.split(delimiter).filter((entry) => entry.length > 0); } const CONSTRAINED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; @@ -88,8 +93,6 @@ describe('goosed API integration tests', () => { expect(session.id).toBeDefined(); expect(session.name).toBeDefined(); - // Verify we can retrieve the session by ID - // Note: path parameter is 'session_id' not 'id' const getResponse = await getSession({ client: ctx.client, path: { @@ -148,25 +151,19 @@ describe('goosed API integration tests', () => { }), }); - // The endpoint should accept the request format - // 200 = success, the SSE stream will contain the response or error expect(sseResponse.status).toBe(200); - // Read just enough to verify the stream works const reader = sseResponse.body?.getReader(); if (reader) { - // Cancel after a short read - we just want to verify the endpoint works setTimeout(() => reader.cancel(), 1000); try { const { value } = await reader.read(); - // We should get some data back (either response or error about provider) expect(value).toBeDefined(); } catch { // Reader was cancelled, that's fine } } - // Cleanup - may fail if agent wasn't fully instantiated await stopAgent({ client: ctx.client, body: { @@ -176,7 +173,7 @@ describe('goosed API integration tests', () => { }); it('should see the full PATH when calling the developer tool', async () => { - const currentPath = getPathEntries(); + const currentPath = getUserPath(); // find a part of current path that is not in CONSTRAINED_PATH const pathEntry = currentPath.find((entry) => !CONSTRAINED_PATH.includes(entry)); @@ -184,8 +181,6 @@ describe('goosed API integration tests', () => { expect.fail(`Could not find a path entry not in ${CONSTRAINED_PATH}`); } - // This test requires a configured provider - // Check if GOOSE_PROVIDER is set in config, or use env var to configure it let configResponse = await readConfig({ client: ctx.client, body: { @@ -194,7 +189,6 @@ describe('goosed API integration tests', () => { }, }); - // response.data is the config value directly (or null/undefined if not set) let providerName = configResponse.data as string | null | undefined; if (!providerName) { @@ -202,7 +196,6 @@ describe('goosed API integration tests', () => { return; } - // Read model from config (or use default) const modelResponse = await readConfig({ client: ctx.client, body: { @@ -212,7 +205,6 @@ describe('goosed API integration tests', () => { }); const modelName = (modelResponse.data as string | null) || undefined; - // Start a session const startResponse = await startAgent({ client: ctx.client, body: { @@ -222,7 +214,6 @@ describe('goosed API integration tests', () => { expect(startResponse.response.ok).toBe(true); const sessionId = startResponse.data!.id; - // Configure the provider (and optionally model) for this session const providerResponse = await updateAgentProvider({ client: ctx.client, body: { @@ -233,7 +224,6 @@ describe('goosed API integration tests', () => { }); expect(providerResponse.response.ok).toBe(true); - // Send a message that requires tool use const sseResponse = await fetch(`${ctx.baseUrl}/reply`, { method: 'POST', headers: { @@ -265,7 +255,7 @@ describe('goosed API integration tests', () => { const reader = sseResponse.body?.getReader(); const decoder = new TextDecoder(); - let returnedPath: string = undefined; + let returnedPath: string | undefined = undefined; if (reader) { const timeout = setTimeout(() => reader.cancel(), 60000); // 60s timeout @@ -277,11 +267,9 @@ describe('goosed API integration tests', () => { const chunk = decoder.decode(value, { stream: true }); try { - // remove data: prefix const data = JSON.parse(chunk.replace(/^data:/, '')); const output = data?.message?.content?.[0]?.toolResult?.value?.content?.[0]?.text; if (output && output.includes('/usr')) { - // Got a response that includes PATH content clearTimeout(timeout); reader.cancel(); returnedPath = output; From 52b9a471da5f72eac5cdf72febb2ba4a96b88092 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 11:49:43 -0500 Subject: [PATCH 07/40] don't need that --- ui/desktop/tests/integration/README.md | 30 -------------------------- 1 file changed, 30 deletions(-) delete mode 100644 ui/desktop/tests/integration/README.md diff --git a/ui/desktop/tests/integration/README.md b/ui/desktop/tests/integration/README.md deleted file mode 100644 index aa8df2a0273e..000000000000 --- a/ui/desktop/tests/integration/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Goosed Integration Tests - -This test suite validates the `goosed` binary by issuing requests through the TypeScript API client. - -## Prerequisites - -1. Build the goosed binary: - ```bash - # in the project root - cargo build --bin goosed - ``` - -2. Install npm dependencies: - ```bash - cd ui/desktop - npm install - ``` - -## Running Tests - -```bash -# Run all integration tests -npm run test:integration - -# Run in watch mode (re-runs on file changes) -npm run test:integration:watch - -# Run with debug output -npm run test:integration:debug -``` From 700d873c6dde0010120fde2ae790fb8a157ab90a Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 12:09:58 -0500 Subject: [PATCH 08/40] Add to CI --- .github/workflows/ci.yml | 54 ++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad6a1d2d1719..e1011879894a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for file changes - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 id: filter with: filters: | @@ -41,7 +41,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1 - name: Run cargo fmt run: cargo fmt --check @@ -55,7 +55,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1 - name: Install Dependencies run: | @@ -63,7 +63,7 @@ jobs: sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev - name: Cache Cargo artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Build and Test run: | @@ -75,6 +75,16 @@ jobs: env: RUST_MIN_STACK: 8388608 + - name: Build goosed binary + run: cargo build --package goosed + + - name: Upload goosed binary + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: goosed-linux + path: target/debug/goosed + retention-days: 1 + rust-lint: name: Lint Rust Code @@ -84,9 +94,9 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1 - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Lint run: | @@ -109,7 +119,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1 - name: Install Dependencies run: | @@ -117,7 +127,7 @@ jobs: sudo apt install -y libdbus-1-dev libxcb1-dev - name: Cache Cargo artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 - name: Install Node.js Dependencies for OpenAPI Check run: source ../../bin/activate-hermit && npm ci @@ -162,3 +172,31 @@ jobs: - name: Run Tests run: source ../../bin/activate-hermit && npm run test:run working-directory: ui/desktop + + desktop-integration: + name: Desktop Integration Tests + runs-on: ubuntu-latest + needs: [changes, rust-build-and-test] + if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' + steps: + - name: Checkout Code + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Download goosed binary + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: goosed-linux + path: target/debug + + - name: Make goosed 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: + GOOSED_BINARY: ../../target/debug/goosed + run: source ../../bin/activate-hermit && npm run test:integration + working-directory: ui/desktop From 82862f4bb8b3ee4a0abb9db16309f5b96f1b8a9d Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 12:14:00 -0500 Subject: [PATCH 09/40] typo --- ui/desktop/src/components/BaseChat.tsx | 2 +- ui/desktop/src/components/recipes/RecipeActivities.tsx | 2 +- ui/desktop/src/hooks/useRecipeManager.ts | 2 +- ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts | 2 +- .../utils/{parameterSubtitution.ts => parameterSubstitution.ts} | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename ui/desktop/src/utils/{parameterSubtitution.ts => parameterSubstitution.ts} (100%) diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 9660193960ad..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/parameterSubtitution'; +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/recipes/RecipeActivities.tsx b/ui/desktop/src/components/recipes/RecipeActivities.tsx index 49ee027e37d2..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/parameterSubtitution'; +import { substituteParameters } from '../../utils/parameterSubstitution'; interface RecipeActivitiesProps { append: (text: string) => void; diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index d0ad588e45bb..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/parameterSubtitution'; +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/utils/__tests__/parameterSubstitution.test.ts b/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts index 81f834166e2f..40061d2d3b8e 100644 --- a/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts +++ b/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { substituteParameters } from '../parameterSubtitution'; +import { substituteParameters } from '../parameterSubstitution'; describe('providerUtils', () => { describe('substituteParameters', () => { diff --git a/ui/desktop/src/utils/parameterSubtitution.ts b/ui/desktop/src/utils/parameterSubstitution.ts similarity index 100% rename from ui/desktop/src/utils/parameterSubtitution.ts rename to ui/desktop/src/utils/parameterSubstitution.ts From 946444753bf6967341b290d088d19ad2b6d7e30b Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 12:33:47 -0500 Subject: [PATCH 10/40] Move to pr-smoke-test because we need a live provider --- .github/workflows/ci.yml | 54 +++++------------------------ .github/workflows/pr-smoke-test.yml | 31 +++++++++++++++++ 2 files changed, 39 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1011879894a..ad6a1d2d1719 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Check for file changes - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # pin@v3 id: filter with: filters: | @@ -41,7 +41,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Run cargo fmt run: cargo fmt --check @@ -55,7 +55,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Install Dependencies run: | @@ -63,7 +63,7 @@ jobs: sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev - name: Cache Cargo artifacts - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + uses: Swatinem/rust-cache@v2 - name: Build and Test run: | @@ -75,16 +75,6 @@ jobs: env: RUST_MIN_STACK: 8388608 - - name: Build goosed binary - run: cargo build --package goosed - - - name: Upload goosed binary - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: goosed-linux - path: target/debug/goosed - retention-days: 1 - rust-lint: name: Lint Rust Code @@ -94,9 +84,9 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 - - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - uses: Swatinem/rust-cache@v2 - name: Lint run: | @@ -119,7 +109,7 @@ jobs: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions-rust-lang/setup-rust-toolchain@1780873c7b576612439a134613cc4cc74ce5538c # v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Install Dependencies run: | @@ -127,7 +117,7 @@ jobs: sudo apt install -y libdbus-1-dev libxcb1-dev - name: Cache Cargo artifacts - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + uses: Swatinem/rust-cache@v2 - name: Install Node.js Dependencies for OpenAPI Check run: source ../../bin/activate-hermit && npm ci @@ -172,31 +162,3 @@ jobs: - name: Run Tests run: source ../../bin/activate-hermit && npm run test:run working-directory: ui/desktop - - desktop-integration: - name: Desktop Integration Tests - runs-on: ubuntu-latest - needs: [changes, rust-build-and-test] - if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request' - steps: - - name: Checkout Code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - - name: Download goosed binary - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: goosed-linux - path: target/debug - - - name: Make goosed 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: - GOOSED_BINARY: ../../target/debug/goosed - run: source ../../bin/activate-hermit && npm run test:integration - working-directory: ui/desktop diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index f6111dbc61f3..12074e266cda 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -239,3 +239,34 @@ 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: goose-binary + path: target/debug + + - name: Make Binary Executable + run: chmod +x target/debug/goose + + - name: Install Node.js Dependencies + run: source ../../bin/activate-hermit && npm ci + working-directory: ui/desktop + + - name: Run Integration Tests + env: + GOOSED_BINARY: ../../target/debug/goosed + GOOSE_PROVIDER: anthropic + GOOSE_MODEL: claude-sonnet-4-5-20250929 + run: source ../../bin/activate-hermit && npm run test:integration + working-directory: ui/desktop From 7b16b16d29717ff97adbb9c0ca02b729c54b2d51 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 13:42:30 -0500 Subject: [PATCH 11/40] it's a different binary of course --- .github/workflows/pr-smoke-test.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 12074e266cda..72b9babf3cef 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 @@ -253,11 +260,11 @@ jobs: - name: Download Binary uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: - name: goose-binary + name: goosed-binary path: target/debug - name: Make Binary Executable - run: chmod +x target/debug/goose + run: chmod +x target/debug/goosed - name: Install Node.js Dependencies run: source ../../bin/activate-hermit && npm ci From f0dc5f4909e41d5a175e8608978f4f9447a35034 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 13:35:51 -0500 Subject: [PATCH 12/40] more reuse --- ui/desktop/src/goosed-app.ts | 106 +++++++ ui/desktop/src/goosed.ts | 428 +++++++++++++++----------- ui/desktop/src/main.ts | 3 +- ui/desktop/tests/integration/setup.ts | 81 ++--- 4 files changed, 367 insertions(+), 251 deletions(-) create mode 100644 ui/desktop/src/goosed-app.ts diff --git a/ui/desktop/src/goosed-app.ts b/ui/desktop/src/goosed-app.ts new file mode 100644 index 000000000000..2b59c1920e79 --- /dev/null +++ b/ui/desktop/src/goosed-app.ts @@ -0,0 +1,106 @@ +/** + * Electron-specific wrapper for goosed process management. + * This thin wrapper adds Electron logging and app lifecycle integration. + */ + +import { app } from 'electron'; +import log from './utils/logger'; + +import { status } from './api'; +import { Client } from './api/client'; +import { ExternalGoosedConfig } from './utils/settings'; +import { + findAvailablePort as findAvailablePortUtil, + startGoosed as startGoosedCore, + isFatalError, + Logger, + GoosedResult, +} from './goosed'; + +// Create a logger adapter from electron-log +const electronLogger: Logger = { + info: (...args) => log.info(...args), + error: (...args) => log.error(...args), +}; + +/** + * Find an available port (with logging). + */ +export const findAvailablePort = async (): Promise => { + const port = await findAvailablePortUtil(); + log.info(`Found available port: ${port}`); + return port; +}; + +/** + * Check if goosed server is ready by polling the status endpoint. + * Uses the API client for proper authentication. + */ +export const checkServerStatus = async (client: Client, errorLog: string[]): Promise => { + const interval = 100; // ms + const maxAttempts = 100; // 10s + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (errorLog.some(isFatalError)) { + log.error('Detected fatal error in server logs'); + return false; + } + + try { + await status({ client }); + return true; + } catch { + await new Promise((resolve) => setTimeout(resolve, interval)); + } + } + + log.error(`Server failed to respond after ${(interval * maxAttempts) / 1000} seconds`); + return false; +}; + +export interface StartGoosedParams { + dir?: string; + serverSecret: string; + env?: Record; + externalGoosed?: ExternalGoosedConfig; +} + +export interface StartGoosedResult { + baseUrl: string; + workingDir: string; + process: import('child_process').ChildProcess | null; + errorLog: string[]; +} + +/** + * Start or connect to a goosed server. + * This wraps the core startGoosed with Electron-specific behavior: + * - Uses electron-log for logging + * - Registers cleanup on app quit + */ +export const startGoosed = async (params: StartGoosedParams): Promise => { + const { dir, serverSecret, env, externalGoosed } = params; + + const result: GoosedResult = await startGoosedCore({ + dir, + isPackaged: app.isPackaged, + resourcesPath: app.isPackaged ? process.resourcesPath : undefined, + serverSecret, + env, + externalGoosed, + logger: electronLogger, + }); + + // Register cleanup on app quit + app.on('will-quit', () => { + log.info('App quitting, terminating goosed server'); + result.cleanup(); + }); + + return { + baseUrl: result.baseUrl, + workingDir: result.workingDir, + process: result.process, + errorLog: result.errorLog, + }; +}; diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 2528a816e833..adda9c159b3e 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -1,262 +1,318 @@ -import Electron from 'electron'; -import fs from 'node:fs'; +/** + * Goosed process management utilities. + * These utilities are designed to work in both the main Electron process + * and in Node.js test environments. + */ + 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'; +// Simple logger interface - can be console or electron-log +export interface Logger { + info: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} +// Default to console logging +export const defaultLogger: Logger = { + info: (...args) => console.log('[goosed]', ...args), + error: (...args) => console.error('[goosed]', ...args), +}; + +/** + * Find an available port by binding to port 0 and letting the OS assign one. + */ export const findAvailablePort = (): Promise => { - return new Promise((resolve, _reject) => { + return new Promise((resolve, reject) => { const server = createServer(); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { - const { port } = server.address() as { port: number }; - server.close(() => { - log.info(`Found available port: ${port}`); - resolve(port); - }); + const address = server.address(); + if (address && typeof address === 'object') { + const { port } = address; + server.close(() => resolve(port)); + } else { + server.close(); + reject(new Error('Failed to get port from server address')); + } }); }); }; -// 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:'); - }; +/** + * Find the goosed binary path, checking multiple possible locations. + */ +export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string | null => { + const { isPackaged = false, resourcesPath } = options; + const binaryName = process.platform === 'win32' ? 'goosed.exe' : 'goosed'; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - if (errorLog.some(fatal)) { - log.error('Detected fatal error in server logs'); - return false; - } + 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 { - await status({ client, throwOnError: true }); - return true; + if (fs.existsSync(p) && fs.statSync(p).isFile()) { + return p; + } } catch { - if (attempt === maxAttempts) { - log.error(`Server failed to respond after ${(interval * maxAttempts) / 1000} seconds`); + // Continue to next path + } + } + + return null; +}; + +export interface WaitForServerOptions { + timeout?: number; + interval?: number; + logger?: Logger; +} + +/** + * Wait for the goosed server to be ready by polling the status endpoint. + */ +export const waitForServer = async ( + baseUrl: string, + options: WaitForServerOptions = {} +): Promise => { + const { timeout = 10000, interval = 100, logger = defaultLogger } = options; + const maxAttempts = Math.ceil(timeout / interval); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const response = await fetch(`${baseUrl}/status`); + if (response.ok) { + return true; } + } catch { + // Server not ready yet } await new Promise((resolve) => setTimeout(resolve, interval)); } + + logger.error(`Server failed to respond after ${timeout / 1000} seconds`); return false; }; -export interface GoosedResult { - baseUrl: string; - workingDir: string; - process: ChildProcess; - errorLog: string[]; -} +/** + * Check if a log line indicates a fatal error. + */ +export const isFatalError = (line: string): boolean => { + const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i, /^error\[E\d+\]/]; + return fatalPatterns.some((pattern) => pattern.test(line)); +}; -const connectToExternalBackend = (workingDir: string, url: string): GoosedResult => { - log.info(`Using external goosed backend at ${url}`); +/** + * Build environment variables for the goosed process. + */ +export const buildGoosedEnv = (port: number, secretKey: string): Record => { + // Note: Only returns the goosed-specific env vars. Caller should spread with process.env. + // 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 env: Record = { + GOOSE_PORT: port.toString(), + GOOSE_SERVER__SECRET_KEY: secretKey, + HOME: process.env.HOME || os.homedir(), + }; - const mockProcess = { - pid: undefined, - kill: () => { - log.info(`Not killing external process that is managed externally`); - }, - } as ChildProcess; + // Handle PATH for different platforms + const pathKey = process.platform === 'win32' ? 'Path' : 'PATH'; + if (process.env[pathKey]) { + env[pathKey] = process.env[pathKey]; + } - return { baseUrl: url, workingDir, process: mockProcess, errorLog: [] }; + return env; }; -interface GooseProcessEnv { - [key: string]: string | undefined; - - 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; + isPackaged?: boolean; + resourcesPath?: string; serverSecret: string; - dir: string; - env?: Partial; + env?: Record; externalGoosed?: ExternalGoosedConfig; + logger?: Logger; } -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)); +export interface GoosedResult { + baseUrl: string; + workingDir: string; + process: ChildProcess | null; + errorLog: string[]; + cleanup: () => void; +} +/** + * Start or connect to a goosed server. + */ +export const startGoosed = async (options: StartGoosedOptions): Promise => { + const { + dir, + isPackaged = false, + resourcesPath, + serverSecret, + env: additionalEnv = {}, + externalGoosed, + logger = defaultLogger, + } = options; + + const errorLog: string[] = []; + const workingDir = dir || os.homedir(); + + // Handle external backend 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: () => { + logger.info('Not killing external process that is managed externally'); + }, + }; } - if (process.env.GOOSE_EXTERNAL_BACKEND) { - const port = process.env.GOOSE_PORT || '3000'; - return connectToExternalBackend(dir, `http://127.0.0.1:${port}`); + // Support for GOOSE_EXTERNAL_BACKEND env var (for testing) + const externalBackendUrl = process.env.GOOSE_EXTERNAL_BACKEND; + if (externalBackendUrl) { + const url = externalBackendUrl.replace(/\/$/, ''); + logger.info(`Using external goosed backend from env at ${url}`); + + return { + baseUrl: url, + workingDir, + process: null, + errorLog, + cleanup: () => { + logger.info('Not killing external process that is managed externally'); + }, + }; } - let goosedPath = getGoosedBinaryPath(app); - - const resolvedGoosedPath = path.resolve(goosedPath); + // Find binary and start local server + const goosedPath = findGoosedBinaryPath({ isPackaged, resourcesPath }); + if (!goosedPath) { + throw new Error('Could not find goosed binary'); + } const port = await findAvailablePort(); - const stderrLines: string[] = []; - - log.info(`Starting goosed from: ${resolvedGoosedPath} on port ${port} in dir ${dir}`); - - 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; + logger.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}`); + + const baseUrl = `http://127.0.0.1:${port}`; + + // Build environment + const spawnEnv = buildGoosedEnv(port, serverSecret); + + // Add any additional env vars (like GOOSE_PATH_ROOT) + for (const [key, value] of Object.entries(additionalEnv)) { + if (value !== undefined) { + spawnEnv[key] = value; + } } - log.info(`Binary path resolved to: ${goosedPath}`); + // Spawn options const spawnOptions = { - cwd: dir, - env: processEnv, - stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'], + env: spawnEnv, + cwd: workingDir, windowsHide: true, - detached: isWindows, - shell: false, }; + // Log spawn options (without secrets) 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, [], 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 = () => { + if (goosedProcess && !goosedProcess.killed) { + logger.info('Terminating goosed server'); + try { + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', goosedProcess.pid!.toString(), '/f', '/t']); + } else { + goosedProcess.kill('SIGTERM'); + } + } catch (error) { + logger.error('Error while terminating goosed process:', error); } - } catch (error) { - log.error('Error while terminating goosed process:', error); } }; - app.on('will-quit', () => { - log.info('App quitting, terminating goosed server'); - try_kill_goose(); - }); + logger.info(`Goosed server successfully started on port ${port}`); - log.info(`Goosed server successfully started on port ${port}`); return { - baseUrl: `http://127.0.0.1:${port}`, - workingDir: dir, + baseUrl, + workingDir, process: goosedProcess, - errorLog: stderrLines, + errorLog, + cleanup, }; }; - -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); - - if (fs.existsSync(resolvedPath)) { - const stats = fs.statSync(resolvedPath); - if (stats.isFile()) { - return resolvedPath; - } else { - log.error(`Path exists but is not a regular file: ${resolvedPath}`); - } - } - } 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( - ', ' - )}` - ); -}; diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 897430f68154..6713405a1ae8 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -23,7 +23,7 @@ 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, startGoosed } from './goosed-app'; import { expandTilde } from './utils/pathUtils'; import log from './utils/logger'; import { ensureWinShims } from './utils/winShims'; @@ -483,7 +483,6 @@ 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 }, diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index 5606eaeefdbc..b0d51ec08334 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -6,12 +6,15 @@ */ import { spawn, type ChildProcess } from 'node:child_process'; -import * as path from 'node:path'; -import * as fs from 'node:fs'; import { createClient, createConfig } from '../../src/api/client'; import type { Client } from '../../src/api/client'; +import { + findAvailablePort, + findGoosedBinaryPath, + waitForServer, + buildGoosedEnv, +} from '../../src/goosed'; -// Secret key for authenticating with goosed (must match GOOSE_SERVER__SECRET_KEY env var) const TEST_SECRET_KEY = 'test'; export interface GoosedTestContext { @@ -23,65 +26,20 @@ export interface GoosedTestContext { cleanup: () => Promise; } -let portCounter = 13100; - -function getNextPort(): number { - return portCounter++; -} - -function findGoosedBinary(): string { - const goosedBinaryEnv = process.env.GOOSED_BINARY; - if (goosedBinaryEnv) { - return goosedBinaryEnv; - } - - const possiblePaths = [ - path.join(process.cwd(), 'src', 'bin', 'goosed'), - path.join(process.cwd(), 'bin', 'goosed'), - path.join(process.cwd(), '..', '..', 'target', 'debug', 'goosed'), - path.join(process.cwd(), '..', '..', 'target', 'release', 'goosed'), - ]; - - for (const binPath of possiblePaths) { - const resolvedPath = path.resolve(binPath); - if (fs.existsSync(resolvedPath)) { - return resolvedPath; - } - } - - throw new Error( - `Could not find goosed binary in any of the expected locations: ${possiblePaths.join(', ')}` - ); -} - -async function waitForServer(baseUrl: string, timeoutMs = 10000): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const response = await fetch(`${baseUrl}/status`); - if (response.ok) { - return; - } - } catch { - // Server not ready yet - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(`Server at ${baseUrl} did not become ready within ${timeoutMs}ms`); -} - -export async function startGoosed(path?: string): Promise { - const port = getNextPort(); +export async function startGoosed(pathOverride?: string): Promise { + const port = await findAvailablePort(); const baseUrl = `http://127.0.0.1:${port}`; - const goosedPath = findGoosedBinary(); + const goosedPath = findGoosedBinaryPath({ + envOverride: process.env.GOOSED_BINARY, + }); + + const env = { + ...buildGoosedEnv(port, TEST_SECRET_KEY), + ...(pathOverride && { PATH: pathOverride }), + }; const goosedProcess = spawn(goosedPath, ['agent'], { - env: { - ...process.env, - ...(path && { PATH: path }), - GOOSE_PORT: port.toString(), - GOOSE_SERVER__SECRET_KEY: TEST_SECRET_KEY, - }, + env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], }); @@ -111,14 +69,13 @@ export async function startGoosed(path?: string): Promise { }); try { - await waitForServer(baseUrl); + await waitForServer(baseUrl, { errorLog: stderrLines }); } catch (error) { goosedProcess.kill(); console.error('Server stderr:', stderrLines.join('\n')); throw error; } - // Create client with authentication header const client = createClient( createConfig({ baseUrl, @@ -141,7 +98,6 @@ export async function startGoosed(path?: string): Promise { goosedProcess.kill('SIGTERM'); - // Force kill after timeout setTimeout(() => { if (!goosedProcess.killed) { goosedProcess.kill('SIGKILL'); @@ -161,7 +117,6 @@ export async function startGoosed(path?: string): Promise { }; } -// Global test context for shared server instance let sharedContext: GoosedTestContext | null = null; export async function getSharedGoosed(): Promise { From dba81c7a7e4a2ac6ecaaa971edb04902d7ed225c Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 14:25:20 -0500 Subject: [PATCH 13/40] add the key, and print response mismatches --- .github/workflows/pr-smoke-test.yml | 1 + ui/desktop/tests/integration/goosed.test.ts | 18 +++++++-------- ui/desktop/tests/integration/setup.ts | 25 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 72b9babf3cef..563ffb3a797c 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -272,6 +272,7 @@ jobs: - 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 diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index fc05ea63124d..5253ef232604 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -51,7 +51,7 @@ describe('goosed API integration tests', () => { describe('health', () => { it('should respond to status endpoint', async () => { const response = await status({ client: ctx.client }); - expect(response.response.ok).toBe(true); + expect(response.response).toBeOkResponse(); expect(response.data).toBeDefined(); }); }); @@ -65,14 +65,14 @@ describe('goosed API integration tests', () => { is_secret: false, }, }); - expect(response.response.ok).toBe(true); + expect(response.response).toBeOkResponse(); }); }); describe('providers', () => { it('should list available providers', async () => { const response = await providers({ client: ctx.client }); - expect(response.response.ok).toBe(true); + expect(response.response).toBeOkResponse(); expect(response.data).toBeDefined(); expect(Array.isArray(response.data)).toBe(true); }); @@ -86,7 +86,7 @@ describe('goosed API integration tests', () => { working_dir: '/tmp', }, }); - expect(startResponse.response.ok).toBe(true); + expect(startResponse.response).toBeOkResponse(); expect(startResponse.data).toBeDefined(); const session = startResponse.data!; @@ -99,14 +99,14 @@ describe('goosed API integration tests', () => { session_id: session.id, }, }); - expect(getResponse.response.ok).toBe(true); + 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.ok).toBe(true); + expect(sessionsResponse.response).toBeOkResponse(); expect(sessionsResponse.data).toBeDefined(); expect(sessionsResponse.data!.sessions).toBeDefined(); expect(Array.isArray(sessionsResponse.data!.sessions)).toBe(true); @@ -122,7 +122,7 @@ describe('goosed API integration tests', () => { working_dir: '/tmp', }, }); - expect(startResponse.response.ok).toBe(true); + expect(startResponse.response).toBeOkResponse(); const sessionId = startResponse.data!.id; const sseResponse = await fetch(`${ctx.baseUrl}/reply`, { @@ -211,7 +211,7 @@ describe('goosed API integration tests', () => { working_dir: '/tmp', }, }); - expect(startResponse.response.ok).toBe(true); + expect(startResponse.response).toBeOkResponse(); const sessionId = startResponse.data!.id; const providerResponse = await updateAgentProvider({ @@ -222,7 +222,7 @@ describe('goosed API integration tests', () => { model: modelName, }, }); - expect(providerResponse.response.ok).toBe(true); + expect(providerResponse.response).toBeOkResponse(); const sseResponse = await fetch(`${ctx.baseUrl}/reply`, { method: 'POST', diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index b0d51ec08334..2e18e4567217 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -14,6 +14,31 @@ import { waitForServer, buildGoosedEnv, } 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'; From 84222dfbc7ceaff9bf42cbf7932d132534a66c1c Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 14:42:34 -0500 Subject: [PATCH 14/40] log the stream --- ui/desktop/tests/integration/goosed.test.ts | 4 +++- ui/desktop/vitest.integration.config.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index 5253ef232604..59c5e36eba21 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -265,6 +265,7 @@ describe('goosed API integration tests', () => { if (done) break; const chunk = decoder.decode(value, { stream: true }); + console.log('stream: ', chunk); try { const data = JSON.parse(chunk.replace(/^data:/, '')); @@ -280,8 +281,9 @@ describe('goosed API integration tests', () => { // incomplete, so we expect parsing errors. } } - } catch { + } catch (error) { // Reader cancelled or error + console.log('Reader cancelled or error: ', error); } clearTimeout(timeout); } diff --git a/ui/desktop/vitest.integration.config.ts b/ui/desktop/vitest.integration.config.ts index ed1d0c350acc..8c29b7e18039 100644 --- a/ui/desktop/vitest.integration.config.ts +++ b/ui/desktop/vitest.integration.config.ts @@ -17,5 +17,6 @@ export default defineConfig({ hookTimeout: 30000, pool: 'forks', singleFork: true, + silent: 'passed-only', }, }); From 48153edd04dcaab9cbc7c90c206253ae366e10e8 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 16:18:29 -0500 Subject: [PATCH 15/40] config --- .../src/components/OllamaSetup.test.tsx | 1 - .../__tests__/parameterSubstitution.test.ts | 2 +- ui/desktop/tests/integration/goosed.test.ts | 18 ++++++++- ui/desktop/tests/integration/setup.ts | 37 ++++++++++--------- 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/ui/desktop/src/components/OllamaSetup.test.tsx b/ui/desktop/src/components/OllamaSetup.test.tsx index 01ae9931d133..fb2ac832a887 100644 --- a/ui/desktop/src/components/OllamaSetup.test.tsx +++ b/ui/desktop/src/components/OllamaSetup.test.tsx @@ -6,7 +6,6 @@ import { toastService } from '../toasts'; // Mock dependencies vi.mock('../utils/ollamaDetection'); -vi.mock('../utils/providerUtils'); vi.mock('../toasts'); // Mock useConfig hook diff --git a/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts b/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts index 40061d2d3b8e..7fe58c9601ba 100644 --- a/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts +++ b/ui/desktop/src/utils/__tests__/parameterSubstitution.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { substituteParameters } from '../parameterSubstitution'; -describe('providerUtils', () => { +describe('parameterSubstitution', () => { describe('substituteParameters', () => { it('should substitute simple parameters', () => { const text = 'Hello {{name}}, welcome to {{app}}!'; diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index 59c5e36eba21..eb6df9dc1789 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -18,6 +18,7 @@ import { updateAgentProvider, } from '../../src/api'; import { execSync } from 'child_process'; +import fs from 'fs'; function getUserPath(): string[] { try { @@ -41,7 +42,20 @@ describe('goosed API integration tests', () => { let ctx: GoosedTestContext; beforeAll(async () => { - ctx = await startGoosed(CONSTRAINED_PATH); + 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 startGoosed({ pathOverride: '/usr/bin:/bin', configYaml }); }); afterAll(async () => { @@ -171,7 +185,9 @@ describe('goosed API integration tests', () => { }, }); }); + }); + describe('the developer tool', () => { it('should see the full PATH when calling the developer tool', async () => { const currentPath = getUserPath(); diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index 2e18e4567217..b0329a2f1ad7 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -15,6 +15,7 @@ import { buildGoosedEnv, } from '../../src/goosed'; import { expect } from 'vitest'; +import fs from 'node:fs'; function stringifyResponse(response: Response) { const details = { @@ -51,18 +52,34 @@ export interface GoosedTestContext { cleanup: () => Promise; } -export async function startGoosed(pathOverride?: string): Promise { +export async function startGoosed({ + pathOverride, + configYaml, +}: { + pathOverride?: string; + configYaml?: string; +}): Promise { const port = await findAvailablePort(); const baseUrl = `http://127.0.0.1:${port}`; const goosedPath = findGoosedBinaryPath({ envOverride: process.env.GOOSED_BINARY, }); + // mk temp dir for app root + const tempDir = await fs.promises.mkdtemp('/tmp/goose-app-root-'); + + if (configYaml) { + await fs.promises.mkdir(`${tempDir}/config`, { recursive: true }); + await fs.promises.writeFile(`${tempDir}/config/config.yaml`, configYaml); + } + const env = { ...buildGoosedEnv(port, TEST_SECRET_KEY), ...(pathOverride && { PATH: pathOverride }), + GOOSE_PATH_ROOT: tempDir, }; + console.log('spawning goosed with env:', env); const goosedProcess = spawn(goosedPath, ['agent'], { env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], @@ -111,6 +128,8 @@ export async function startGoosed(pathOverride?: string): Promise => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + return new Promise((resolve) => { if (goosedProcess.killed) { resolve(); @@ -141,19 +160,3 @@ export async function startGoosed(pathOverride?: string): Promise { - if (!sharedContext) { - sharedContext = await startGoosed(); - } - return sharedContext; -} - -export async function cleanupSharedGoosed(): Promise { - if (sharedContext) { - await sharedContext.cleanup(); - sharedContext = null; - } -} From fe2427dc07fac3a0f2be1ae0f3b5049ef9a888a6 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 16:40:45 -0500 Subject: [PATCH 16/40] try adding a path --- .github/workflows/pr-smoke-test.yml | 4 +++- ui/desktop/tests/integration/goosed.test.ts | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 563ffb3a797c..566d670fc8f2 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -276,5 +276,7 @@ jobs: GOOSED_BINARY: ../../target/debug/goosed GOOSE_PROVIDER: anthropic GOOSE_MODEL: claude-sonnet-4-5-20250929 - run: source ../../bin/activate-hermit && npm run test:integration + run: | + echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bashrc + source ../../bin/activate-hermit && npm run test:integration working-directory: ui/desktop diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index eb6df9dc1789..8091f3883369 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -18,7 +18,6 @@ import { updateAgentProvider, } from '../../src/api'; import { execSync } from 'child_process'; -import fs from 'fs'; function getUserPath(): string[] { try { From 557a52f84aa7c056c6227f54948e60e89fff06b4 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 16:49:25 -0500 Subject: [PATCH 17/40] let's debug this --- .github/workflows/pr-smoke-test.yml | 2 +- crates/goose-mcp/src/developer/rmcp_developer.rs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 566d670fc8f2..6f1f2a97bfbe 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -278,5 +278,5 @@ jobs: GOOSE_MODEL: claude-sonnet-4-5-20250929 run: | echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bashrc - source ../../bin/activate-hermit && npm run test:integration + source ../../bin/activate-hermit && npm run test:integration:debug working-directory: ui/desktop diff --git a/crates/goose-mcp/src/developer/rmcp_developer.rs b/crates/goose-mcp/src/developer/rmcp_developer.rs index e77b5b6883ae..bdcdca7e6f04 100644 --- a/crates/goose-mcp/src/developer/rmcp_developer.rs +++ b/crates/goose-mcp/src/developer/rmcp_developer.rs @@ -1029,9 +1029,13 @@ impl DeveloperServer { let mut command = configure_shell_command(&shell_config, command, working_dir.as_deref()); if self.extend_path_with_shell { + tracing::info!("Extending PATH with shell directories"); if let Err(e) = get_shell_path_dirs() .await - .and_then(|dirs| join_paths(dirs).map_err(|e| anyhow!(e))) + .and_then(|dirs| { + tracing::info!("Found shell directories: {:?}", dirs); + join_paths(dirs).map_err(|e| anyhow!(e)) + }) .map(|path| command.env("PATH", path)) { tracing::error!("Failed to extend PATH with shell directories: {}", e) From 37346070f67f11f68de08874e59793434bd3aad2 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 17:10:33 -0500 Subject: [PATCH 18/40] see if this helps --- .github/workflows/pr-smoke-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 6f1f2a97bfbe..505374fde252 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -276,7 +276,8 @@ jobs: 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/.bashrc + echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bash_profile source ../../bin/activate-hermit && npm run test:integration:debug working-directory: ui/desktop From dc5c45907da0227226c962c3cae363e8b6be40bd Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 17:10:39 -0500 Subject: [PATCH 19/40] this was just broken --- ui/desktop/src/goosed.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index adda9c159b3e..6b295b51dc66 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -264,7 +264,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${data.toString()}`); From 48f16ab1c59ca3236dbf317e978b0de1993be9e2 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 17:14:08 -0500 Subject: [PATCH 20/40] can we read these --- ui/desktop/tests/integration/setup.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index b0329a2f1ad7..de6dd0eb785c 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -128,6 +128,17 @@ export async function startGoosed({ ); const cleanup = async (): Promise => { + // dump all logs from tempDir/state/logs/server/*/*-goosed.log to console.log + const logDirs = await fs.promises.readdir(`${tempDir}/state/logs/server`); + for (const logDir of logDirs) { + const logFiles = await fs.promises.readdir(`${tempDir}/state/logs/server/${logDir}`); + for (const logFile of logFiles) { + const logPath = `${tempDir}/state/logs/server/${logDir}/${logFile}`; + const logContent = await fs.promises.readFile(logPath, 'utf8'); + console.log(logContent); + } + } + await fs.promises.rm(tempDir, { recursive: true, force: true }); return new Promise((resolve) => { From 459b7bd6151d214b58b3699af3c84a90ec403feb Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 19:41:18 -0500 Subject: [PATCH 21/40] exclude the node part of the path --- ui/desktop/tests/integration/goosed.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index 8091f3883369..d65be510478b 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -190,8 +190,12 @@ extensions: it('should see the full PATH when calling the developer tool', async () => { const currentPath = getUserPath(); - // find a part of current path that is not in CONSTRAINED_PATH - const pathEntry = currentPath.find((entry) => !CONSTRAINED_PATH.includes(entry)); + // find a part of current path that is not in CONSTRAINED_PATH. + // also excludes anything with 'node_modules', because we'd expect that + // to be added by this test itself + const pathEntry = currentPath.find( + (entry) => !CONSTRAINED_PATH.includes(entry) && !entry.includes('node_modules') + ); if (!pathEntry) { expect.fail(`Could not find a path entry not in ${CONSTRAINED_PATH}`); } From 97d437e13fcde6e697c267d15e781283a12f4eae Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 19:55:51 -0500 Subject: [PATCH 22/40] use the constrained path to get the user path --- ui/desktop/tests/integration/goosed.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index d65be510478b..71ef56158768 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -19,12 +19,17 @@ import { } from '../../src/api'; import { execSync } from 'child_process'; +const CONSTRAINED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; + function getUserPath(): string[] { try { const userShell = process.env.SHELL || '/bin/bash'; const path = execSync(`${userShell} -i -c 'echo $PATH'`, { encoding: 'utf-8', timeout: 5000, + env: { + PATH: CONSTRAINED_PATH, + }, }).trim(); const delimiter = process.platform === 'win32' ? ';' : ':'; @@ -35,8 +40,6 @@ function getUserPath(): string[] { } } -const CONSTRAINED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; - describe('goosed API integration tests', () => { let ctx: GoosedTestContext; @@ -190,12 +193,7 @@ extensions: it('should see the full PATH when calling the developer tool', async () => { const currentPath = getUserPath(); - // find a part of current path that is not in CONSTRAINED_PATH. - // also excludes anything with 'node_modules', because we'd expect that - // to be added by this test itself - const pathEntry = currentPath.find( - (entry) => !CONSTRAINED_PATH.includes(entry) && !entry.includes('node_modules') - ); + const pathEntry = currentPath.find((entry) => !CONSTRAINED_PATH.includes(entry)); if (!pathEntry) { expect.fail(`Could not find a path entry not in ${CONSTRAINED_PATH}`); } From 3f643089e501fadc53c28463fb589f3227ee8280 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 20:52:21 -0500 Subject: [PATCH 23/40] login of course --- ui/desktop/tests/integration/goosed.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index 71ef56158768..a5c29c14e48d 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -24,7 +24,7 @@ const CONSTRAINED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; function getUserPath(): string[] { try { const userShell = process.env.SHELL || '/bin/bash'; - const path = execSync(`${userShell} -i -c 'echo $PATH'`, { + const path = execSync(`${userShell} -l -i -c 'echo $PATH'`, { encoding: 'utf-8', timeout: 5000, env: { From 80fdc0da5218d6a37976c707ec441e1b9bbb26b0 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 19:43:36 -0500 Subject: [PATCH 24/40] REVERT ME: let's make sure this fails --- crates/goose-server/src/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 57f104d47e9b..d7fe1a47d5fe 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -31,7 +31,7 @@ pub struct AppState { fn spawn_developer(r: tokio::io::DuplexStream, w: tokio::io::DuplexStream) { let bash_env = Paths::config_dir().join(".bash_env"); let server = DeveloperServer::new() - .extend_path_with_shell(true) + // .extend_path_with_shell(true) .bash_env_file(Some(bash_env)); tokio::spawn(async move { match server.serve((r, w)).await { From a2f730fa9672f1201bf87417f73073ba8bccb250 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Thu, 12 Feb 2026 21:13:44 -0500 Subject: [PATCH 25/40] Revert "REVERT ME: let's make sure this fails" This reverts commit 80fdc0da5218d6a37976c707ec441e1b9bbb26b0. --- crates/goose-server/src/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index d7fe1a47d5fe..57f104d47e9b 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -31,7 +31,7 @@ pub struct AppState { fn spawn_developer(r: tokio::io::DuplexStream, w: tokio::io::DuplexStream) { let bash_env = Paths::config_dir().join(".bash_env"); let server = DeveloperServer::new() - // .extend_path_with_shell(true) + .extend_path_with_shell(true) .bash_env_file(Some(bash_env)); tokio::spawn(async move { match server.serve((r, w)).await { From 028b2fde3dbba4318e4f1379fe4020ef9a7b912b Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 09:04:37 -0500 Subject: [PATCH 26/40] paths --- ui/desktop/tests/integration/goosed.test.ts | 7 +++--- ui/desktop/tests/integration/setup.ts | 28 +++++++++++---------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index a5c29c14e48d..a3c99ceada45 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -18,6 +18,7 @@ import { updateAgentProvider, } from '../../src/api'; import { execSync } from 'child_process'; +import os from 'node:os'; const CONSTRAINED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin'; @@ -99,7 +100,7 @@ extensions: const startResponse = await startAgent({ client: ctx.client, body: { - working_dir: '/tmp', + working_dir: os.tmpdir(), }, }); expect(startResponse.response).toBeOkResponse(); @@ -135,7 +136,7 @@ extensions: const startResponse = await startAgent({ client: ctx.client, body: { - working_dir: '/tmp', + working_dir: os.tmpdir(), }, }); expect(startResponse.response).toBeOkResponse(); @@ -225,7 +226,7 @@ extensions: const startResponse = await startAgent({ client: ctx.client, body: { - working_dir: '/tmp', + working_dir: os.tmpdir(), }, }); expect(startResponse.response).toBeOkResponse(); diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index de6dd0eb785c..67b4b58aa30f 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -16,6 +16,8 @@ import { } from '../../src/goosed'; import { expect } from 'vitest'; import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; function stringifyResponse(response: Response) { const details = { @@ -61,16 +63,15 @@ export async function startGoosed({ }): Promise { const port = await findAvailablePort(); const baseUrl = `http://127.0.0.1:${port}`; - const goosedPath = findGoosedBinaryPath({ - envOverride: process.env.GOOSED_BINARY, - }); + const pathFromEnv = process.env.GOOSED_BINARY; + const goosedPath = pathFromEnv ?? findGoosedBinaryPath(); // mk temp dir for app root - const tempDir = await fs.promises.mkdtemp('/tmp/goose-app-root-'); + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'goose-app-root-')); if (configYaml) { - await fs.promises.mkdir(`${tempDir}/config`, { recursive: true }); - await fs.promises.writeFile(`${tempDir}/config/config.yaml`, configYaml); + await fs.promises.mkdir(path.join(tempDir, 'config'), { recursive: true }); + await fs.promises.writeFile(path.join(tempDir, 'config', 'config.yaml'), configYaml); } const env = { @@ -128,20 +129,19 @@ export async function startGoosed({ ); const cleanup = async (): Promise => { - // dump all logs from tempDir/state/logs/server/*/*-goosed.log to console.log - const logDirs = await fs.promises.readdir(`${tempDir}/state/logs/server`); + const logDirs = await fs.promises.readdir(path.join(tempDir, 'state', 'logs', 'server')); for (const logDir of logDirs) { - const logFiles = await fs.promises.readdir(`${tempDir}/state/logs/server/${logDir}`); + const logFiles = await fs.promises.readdir( + path.join(tempDir, 'state', 'logs', 'server', logDir) + ); for (const logFile of logFiles) { - const logPath = `${tempDir}/state/logs/server/${logDir}/${logFile}`; + const logPath = path.join(tempDir, 'state', 'logs', 'server', logDir, logFile); const logContent = await fs.promises.readFile(logPath, 'utf8'); console.log(logContent); } } - await fs.promises.rm(tempDir, { recursive: true, force: true }); - - return new Promise((resolve) => { + return new Promise((resolve) => { if (goosedProcess.killed) { resolve(); return; @@ -159,6 +159,8 @@ export async function startGoosed({ } resolve(); }, 5000); + }).then(async () => { + await fs.promises.rm(tempDir, { recursive: true, force: true }); }); }; From e32ffb09a17f913d0b5e7e270f6c2965696cf608 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 09:13:23 -0500 Subject: [PATCH 27/40] clean up --- ui/desktop/src/goosed.ts | 35 ++++----------------------- ui/desktop/tests/integration/setup.ts | 8 +++--- 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 6b295b51dc66..deacbbc9e432 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -11,21 +11,16 @@ import path from 'node:path'; import { createServer } from 'net'; import { Buffer } from 'node:buffer'; -// Simple logger interface - can be console or electron-log export interface Logger { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void; } -// Default to console logging export const defaultLogger: Logger = { info: (...args) => console.log('[goosed]', ...args), error: (...args) => console.error('[goosed]', ...args), }; -/** - * Find an available port by binding to port 0 and letting the OS assign one. - */ export const findAvailablePort = (): Promise => { return new Promise((resolve, reject) => { const server = createServer(); @@ -50,10 +45,7 @@ export interface FindBinaryOptions { resourcesPath?: string; } -/** - * Find the goosed binary path, checking multiple possible locations. - */ -export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string | null => { +export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string => { const { isPackaged = false, resourcesPath } = options; const binaryName = process.platform === 'win32' ? 'goosed.exe' : 'goosed'; @@ -78,11 +70,13 @@ export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string | return p; } } catch { - // Continue to next path + // continue } } - return null; + throw new Error( + `Goosed binary not found in any of the possible paths: ${possiblePaths.join(', ')}` + ); }; export interface WaitForServerOptions { @@ -91,9 +85,6 @@ export interface WaitForServerOptions { logger?: Logger; } -/** - * Wait for the goosed server to be ready by polling the status endpoint. - */ export const waitForServer = async ( baseUrl: string, options: WaitForServerOptions = {} @@ -117,17 +108,11 @@ export const waitForServer = async ( return false; }; -/** - * Check if a log line indicates a fatal error. - */ export const isFatalError = (line: string): boolean => { const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i, /^error\[E\d+\]/]; return fatalPatterns.some((pattern) => pattern.test(line)); }; -/** - * Build environment variables for the goosed process. - */ export const buildGoosedEnv = (port: number, secretKey: string): Record => { // Note: Only returns the goosed-specific env vars. Caller should spread with process.env. // Environment variable naming follows the config crate convention: @@ -173,9 +158,6 @@ export interface GoosedResult { cleanup: () => void; } -/** - * Start or connect to a goosed server. - */ export const startGoosed = async (options: StartGoosedOptions): Promise => { const { dir, @@ -190,7 +172,6 @@ export const startGoosed = async (options: StartGoosedOptions): Promise Date: Fri, 13 Feb 2026 09:27:27 -0500 Subject: [PATCH 28/40] More clean up --- ui/desktop/src/goosed-app.ts | 2 +- ui/desktop/src/goosed.ts | 7 +- ui/desktop/tests/integration/goosed.test.ts | 4 +- ui/desktop/tests/integration/setup.ts | 117 +++++++++----------- 4 files changed, 62 insertions(+), 68 deletions(-) diff --git a/ui/desktop/src/goosed-app.ts b/ui/desktop/src/goosed-app.ts index 2b59c1920e79..c35542f26652 100644 --- a/ui/desktop/src/goosed-app.ts +++ b/ui/desktop/src/goosed-app.ts @@ -47,7 +47,7 @@ export const checkServerStatus = async (client: Client, errorLog: string[]): Pro } try { - await status({ client }); + await status({ client, throwOnError: true }); return true; } catch { await new Promise((resolve) => setTimeout(resolve, interval)); diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index deacbbc9e432..eaea57b78b96 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -114,7 +114,6 @@ export const isFatalError = (line: string): boolean => { }; export const buildGoosedEnv = (port: number, secretKey: string): Record => { - // Note: Only returns the goosed-specific env vars. Caller should spread with process.env. // 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) @@ -124,7 +123,6 @@ export const buildGoosedEnv = (port: number, secretKey: string): Record { - it('should see the full PATH when calling the developer tool', async () => { + 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)); @@ -210,7 +210,7 @@ extensions: let providerName = configResponse.data as string | null | undefined; if (!providerName) { - console.log('Skipping tool execution test - no GOOSE_PROVIDER configured'); + testContext.skip('Skipping tool execution test - no GOOSE_PROVIDER configured'); return; } diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index 64d7456ed370..5fa6188aac57 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -5,18 +5,13 @@ * auto-generated API client. */ -import { spawn, type ChildProcess } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createClient, createConfig } from '../../src/api/client'; import type { Client } from '../../src/api/client'; -import { - findAvailablePort, - findGoosedBinaryPath, - waitForServer, - buildGoosedEnv, -} from '../../src/goosed'; +import { startGoosed as startGoosedBase, waitForServer, type Logger } from '../../src/goosed'; import { expect } from 'vitest'; function stringifyResponse(response: Response) { @@ -61,12 +56,6 @@ export async function startGoosed({ pathOverride?: string; configYaml?: string; }): Promise { - const port = await findAvailablePort(); - const baseUrl = `http://127.0.0.1:${port}`; - const pathFromEnv = process.env.GOOSED_BINARY; - const goosedPath = pathFromEnv ?? findGoosedBinaryPath(); - - // mk temp dir for app root const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'goose-app-root-')); if (configYaml) { @@ -74,54 +63,53 @@ export async function startGoosed({ await fs.promises.writeFile(path.join(tempDir, 'config', 'config.yaml'), configYaml); } - const env = { - ...buildGoosedEnv(port, TEST_SECRET_KEY), - ...(pathOverride && { PATH: pathOverride }), - GOOSE_PATH_ROOT: tempDir, + const testLogger: Logger = { + info: (...args) => { + if (process.env.DEBUG) { + console.log('[goosed]', ...args); + } + }, + error: (...args) => console.error('[goosed]', ...args), }; - console.log('spawning goosed with env:', env); - const goosedProcess = spawn(goosedPath, ['agent'], { - env: { ...process.env, ...env }, - stdio: ['pipe', 'pipe', 'pipe'], - }); + const additionalEnv: Record = { + GOOSE_PATH_ROOT: tempDir, + }; - const stderrLines: string[] = []; + if (pathOverride) { + additionalEnv.PATH = pathOverride; + } - goosedProcess.stdout?.on('data', (data: Buffer) => { - if (process.env.DEBUG) { - console.log(`[goosed:${port}:stdout]`, data.toString()); - } + const result = await startGoosedBase({ + serverSecret: TEST_SECRET_KEY, + env: additionalEnv, + logger: testLogger, }); - goosedProcess.stderr?.on('data', (data: Buffer) => { - const lines = data - .toString() - .split('\n') - .filter((l) => l.trim()); - lines.forEach((line) => { - stderrLines.push(line); - if (process.env.DEBUG) { - console.error(`[goosed:${port}:stderr]`, line); - } - }); - }); + if (!result.process) { + throw new Error('Expected goosed process to be started, but got external backend'); + } - goosedProcess.on('error', (err: Error) => { - console.error(`Failed to start goosed on port ${port}:`, err); - }); + const port = parseInt(new URL(result.baseUrl).port, 10); try { - await waitForServer(baseUrl, { errorLog: stderrLines }); + const serverReady = await waitForServer(result.baseUrl, { + logger: testLogger, + }); + if (!serverReady) { + result.cleanup(); + console.error('Server stderr:', result.errorLog.join('\n')); + throw new Error('Server failed to start'); + } } catch (error) { - goosedProcess.kill(); - console.error('Server stderr:', stderrLines.join('\n')); + result.cleanup(); + console.error('Server stderr:', result.errorLog.join('\n')); throw error; } const client = createClient( createConfig({ - baseUrl, + baseUrl: result.baseUrl, headers: { 'X-Secret-Key': TEST_SECRET_KEY, }, @@ -129,33 +117,38 @@ export async function startGoosed({ ); const cleanup = async (): Promise => { - const logDirs = await fs.promises.readdir(path.join(tempDir, 'state', 'logs', 'server')); - for (const logDir of logDirs) { - const logFiles = await fs.promises.readdir( - path.join(tempDir, 'state', 'logs', 'server', logDir) - ); - for (const logFile of logFiles) { - const logPath = path.join(tempDir, 'state', 'logs', 'server', logDir, logFile); - const logContent = await fs.promises.readFile(logPath, 'utf8'); - console.log(logContent); + 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, that's okay } return new Promise((resolve) => { - if (goosedProcess.killed) { + if (!result.process || result.process.killed) { resolve(); return; } - goosedProcess.on('close', () => { + result.process.on('close', () => { resolve(); }); - goosedProcess.kill('SIGTERM'); + result.process.kill('SIGTERM'); setTimeout(() => { - if (!goosedProcess.killed) { - goosedProcess.kill('SIGKILL'); + if (result.process && !result.process.killed) { + result.process.kill('SIGKILL'); } resolve(); }, 5000); @@ -166,10 +159,10 @@ export async function startGoosed({ return { client, - baseUrl, + baseUrl: result.baseUrl, port, secretKey: TEST_SECRET_KEY, - process: goosedProcess, + process: result.process, cleanup, }; } From ec3ac0bd3461abe6c95f4e4ab75761fa0ef638a3 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 09:39:00 -0500 Subject: [PATCH 29/40] more clean up --- .../goose-mcp/src/developer/rmcp_developer.rs | 6 +---- ui/desktop/src/goosed.ts | 22 +++++-------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/crates/goose-mcp/src/developer/rmcp_developer.rs b/crates/goose-mcp/src/developer/rmcp_developer.rs index bdcdca7e6f04..e77b5b6883ae 100644 --- a/crates/goose-mcp/src/developer/rmcp_developer.rs +++ b/crates/goose-mcp/src/developer/rmcp_developer.rs @@ -1029,13 +1029,9 @@ impl DeveloperServer { let mut command = configure_shell_command(&shell_config, command, working_dir.as_deref()); if self.extend_path_with_shell { - tracing::info!("Extending PATH with shell directories"); if let Err(e) = get_shell_path_dirs() .await - .and_then(|dirs| { - tracing::info!("Found shell directories: {:?}", dirs); - join_paths(dirs).map_err(|e| anyhow!(e)) - }) + .and_then(|dirs| join_paths(dirs).map_err(|e| anyhow!(e))) .map(|path| command.env("PATH", path)) { tracing::error!("Failed to extend PATH with shell directories: {}", e) diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index eaea57b78b96..3cfbdd5f055c 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -1,9 +1,3 @@ -/** - * Goosed process management utilities. - * These utilities are designed to work in both the main Electron process - * and in Node.js test environments. - */ - import { spawn, ChildProcess } from 'child_process'; import fs from 'node:fs'; import os from 'node:os'; @@ -22,20 +16,14 @@ export const defaultLogger: Logger = { }; export const findAvailablePort = (): Promise => { - return new Promise((resolve, reject) => { + return new Promise((resolve, _reject) => { const server = createServer(); - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const address = server.address(); - if (address && typeof address === 'object') { - const { port } = address; - server.close(() => resolve(port)); - } else { - server.close(); - reject(new Error('Failed to get port from server address')); - } + const { port } = server.address() as { port: number }; + server.close(() => { + resolve(port); + }); }); }); }; From 5c2b511ac3138d63d36d3ac04055a3f746888fa7 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 09:54:51 -0500 Subject: [PATCH 30/40] External backend --- ui/desktop/src/goosed.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 3cfbdd5f055c..ff22460d2db3 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -173,9 +173,9 @@ export const startGoosed = async (options: StartGoosedOptions): Promise Date: Fri, 13 Feb 2026 10:15:25 -0500 Subject: [PATCH 31/40] Missing windows bits --- ui/desktop/src/goosed.ts | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index ff22460d2db3..7cff8ad6524e 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -101,19 +101,35 @@ export const isFatalError = (line: string): boolean => { return fatalPatterns.some((pattern) => pattern.test(line)); }; -export const buildGoosedEnv = (port: number, secretKey: string): Record => { +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: process.env.HOME || os.homedir(), + HOME: homeDir, }; + // 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'); + } + + // Add binary directory to PATH for any dependencies const pathKey = process.platform === 'win32' ? 'Path' : 'PATH'; - if (process.env[pathKey]) { - env[pathKey] = process.env[pathKey]; + const currentPath = process.env[pathKey] || ''; + if (binaryPath) { + env[pathKey] = `${path.dirname(binaryPath)}${path.delimiter}${currentPath}`; + } else if (currentPath) { + env[pathKey] = currentPath; } return env; @@ -190,9 +206,6 @@ export const startGoosed = async (options: StartGoosedOptions): Promise Date: Fri, 13 Feb 2026 10:25:44 -0500 Subject: [PATCH 32/40] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ui/desktop/tests/integration/setup.ts | 11 +++++------ ui/desktop/vitest.integration.config.ts | 6 ++---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index 5fa6188aac57..28b4557dcecd 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -99,12 +99,11 @@ export async function startGoosed({ if (!serverReady) { result.cleanup(); console.error('Server stderr:', result.errorLog.join('\n')); - throw new Error('Server failed to start'); - } - } catch (error) { - result.cleanup(); - console.error('Server stderr:', result.errorLog.join('\n')); - throw error; + const serverReady = await waitForServer(baseUrl); + if (!serverReady) { + goosedProcess.kill(); + console.error('Server stderr:', stderrLines.join('\n')); + throw new Error(`Failed to start goosed on port ${port}: server did not become ready`); } const client = createClient( diff --git a/ui/desktop/vitest.integration.config.ts b/ui/desktop/vitest.integration.config.ts index 8c29b7e18039..0d9d453f9982 100644 --- a/ui/desktop/vitest.integration.config.ts +++ b/ui/desktop/vitest.integration.config.ts @@ -1,9 +1,7 @@ import { defineConfig } from 'vitest/config'; -import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ - plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), @@ -13,8 +11,8 @@ export default defineConfig({ globals: true, environment: 'node', include: ['tests/integration/**/*.test.ts'], - testTimeout: 30000, - hookTimeout: 30000, + testTimeout: 60000, + hookTimeout: 60000, pool: 'forks', singleFork: true, silent: 'passed-only', From c433ca9dffad05be82df8ec908cb81d5fadb0ab5 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 11:00:15 -0500 Subject: [PATCH 33/40] simplify more --- ui/desktop/src/goosed-app.ts | 106 -------------------------- ui/desktop/src/goosed.ts | 52 ++++++++----- ui/desktop/src/main.ts | 30 +++++--- ui/desktop/tests/integration/setup.ts | 15 +--- 4 files changed, 53 insertions(+), 150 deletions(-) delete mode 100644 ui/desktop/src/goosed-app.ts diff --git a/ui/desktop/src/goosed-app.ts b/ui/desktop/src/goosed-app.ts deleted file mode 100644 index c35542f26652..000000000000 --- a/ui/desktop/src/goosed-app.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Electron-specific wrapper for goosed process management. - * This thin wrapper adds Electron logging and app lifecycle integration. - */ - -import { app } from 'electron'; -import log from './utils/logger'; - -import { status } from './api'; -import { Client } from './api/client'; -import { ExternalGoosedConfig } from './utils/settings'; -import { - findAvailablePort as findAvailablePortUtil, - startGoosed as startGoosedCore, - isFatalError, - Logger, - GoosedResult, -} from './goosed'; - -// Create a logger adapter from electron-log -const electronLogger: Logger = { - info: (...args) => log.info(...args), - error: (...args) => log.error(...args), -}; - -/** - * Find an available port (with logging). - */ -export const findAvailablePort = async (): Promise => { - const port = await findAvailablePortUtil(); - log.info(`Found available port: ${port}`); - return port; -}; - -/** - * Check if goosed server is ready by polling the status endpoint. - * Uses the API client for proper authentication. - */ -export const checkServerStatus = async (client: Client, errorLog: string[]): Promise => { - const interval = 100; // ms - const maxAttempts = 100; // 10s - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - if (errorLog.some(isFatalError)) { - log.error('Detected fatal error in server logs'); - return false; - } - - try { - await status({ client, throwOnError: true }); - return true; - } catch { - await new Promise((resolve) => setTimeout(resolve, interval)); - } - } - - log.error(`Server failed to respond after ${(interval * maxAttempts) / 1000} seconds`); - return false; -}; - -export interface StartGoosedParams { - dir?: string; - serverSecret: string; - env?: Record; - externalGoosed?: ExternalGoosedConfig; -} - -export interface StartGoosedResult { - baseUrl: string; - workingDir: string; - process: import('child_process').ChildProcess | null; - errorLog: string[]; -} - -/** - * Start or connect to a goosed server. - * This wraps the core startGoosed with Electron-specific behavior: - * - Uses electron-log for logging - * - Registers cleanup on app quit - */ -export const startGoosed = async (params: StartGoosedParams): Promise => { - const { dir, serverSecret, env, externalGoosed } = params; - - const result: GoosedResult = await startGoosedCore({ - dir, - isPackaged: app.isPackaged, - resourcesPath: app.isPackaged ? process.resourcesPath : undefined, - serverSecret, - env, - externalGoosed, - logger: electronLogger, - }); - - // Register cleanup on app quit - app.on('will-quit', () => { - log.info('App quitting, terminating goosed server'); - result.cleanup(); - }); - - return { - baseUrl: result.baseUrl, - workingDir: result.workingDir, - process: result.process, - errorLog: result.errorLog, - }; -}; diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 7cff8ad6524e..f46b3de9b4bc 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { createServer } from 'net'; import { Buffer } from 'node:buffer'; +import { status } from './api'; +import { Client, createClient, createConfig } from './api/client'; export interface Logger { info: (...args: unknown[]) => void; @@ -67,37 +69,29 @@ export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string => ); }; -export interface WaitForServerOptions { - timeout?: number; - interval?: number; - logger?: Logger; -} - -export const waitForServer = async ( - baseUrl: string, - options: WaitForServerOptions = {} -): Promise => { - const { timeout = 10000, interval = 100, logger = defaultLogger } = options; +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(isFatalError)) { + return false; + } + try { - const response = await fetch(`${baseUrl}/status`); - if (response.ok) { - return true; - } + await status({ client, throwOnError: true }); + return true; } catch { - // Server not ready yet + await new Promise((resolve) => setTimeout(resolve, interval)); } - await new Promise((resolve) => setTimeout(resolve, interval)); } - logger.error(`Server failed to respond after ${timeout / 1000} seconds`); return false; }; export const isFatalError = (line: string): boolean => { - const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i, /^error\[E\d+\]/]; + const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i]; return fatalPatterns.some((pattern) => pattern.test(line)); }; @@ -144,11 +138,11 @@ export interface ExternalGoosedConfig { export interface StartGoosedOptions { dir?: string; - isPackaged?: boolean; - resourcesPath?: string; serverSecret: string; env?: Record; externalGoosed?: ExternalGoosedConfig; + isPackaged?: boolean; + resourcesPath?: string; logger?: Logger; } @@ -158,8 +152,21 @@ export interface GoosedResult { process: ChildProcess | null; errorLog: string[]; cleanup: () => void; + 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 { dir, @@ -186,6 +193,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { logger.info('Not killing external process that is managed externally'); }, + client: goosedClientForUrlAndSecret(url, serverSecret), }; } @@ -202,6 +210,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { logger.info('Not killing external process that is managed externally'); }, + client: goosedClientForUrlAndSecret(url, serverSecret), }; } @@ -295,5 +304,6 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { + log.info('App quitting, terminating goosed server'); + goosedResult.cleanup(); + }); + + const { + baseUrl, + workingDir, + process: goosedProcess, + errorLog, + client: goosedClient, + } = goosedResult; const mainWindowState = windowStateKeeper({ defaultWidth: 940, @@ -542,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/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index 28b4557dcecd..3fa94d5091c9 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -11,7 +11,7 @@ import os from 'node:os'; import path from 'node:path'; import { createClient, createConfig } from '../../src/api/client'; import type { Client } from '../../src/api/client'; -import { startGoosed as startGoosedBase, waitForServer, type Logger } from '../../src/goosed'; +import { startGoosed as startGoosedBase, checkServerStatus, type Logger } from '../../src/goosed'; import { expect } from 'vitest'; function stringifyResponse(response: Response) { @@ -92,17 +92,10 @@ export async function startGoosed({ const port = parseInt(new URL(result.baseUrl).port, 10); - try { - const serverReady = await waitForServer(result.baseUrl, { - logger: testLogger, - }); - if (!serverReady) { - result.cleanup(); - console.error('Server stderr:', result.errorLog.join('\n')); - const serverReady = await waitForServer(baseUrl); + const serverReady = await checkServerStatus(result.client, result.errorLog); if (!serverReady) { - goosedProcess.kill(); - console.error('Server stderr:', stderrLines.join('\n')); + result.cleanup(); + console.error('Server stderr:', result.errorLog.join('\n')); throw new Error(`Failed to start goosed on port ${port}: server did not become ready`); } From 0eac3b73dc4cad49bf01abf0834b1cc903e70a93 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 11:20:53 -0500 Subject: [PATCH 34/40] no port --- ui/desktop/tests/integration/setup.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index 3fa94d5091c9..f7f1c60b773b 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -43,7 +43,6 @@ const TEST_SECRET_KEY = 'test'; export interface GoosedTestContext { client: Client; baseUrl: string; - port: number; secretKey: string; process: ChildProcess; cleanup: () => Promise; @@ -85,29 +84,19 @@ export async function startGoosed({ env: additionalEnv, logger: testLogger, }); + const client = result.client; if (!result.process) { throw new Error('Expected goosed process to be started, but got external backend'); } - const port = parseInt(new URL(result.baseUrl).port, 10); - const serverReady = await checkServerStatus(result.client, result.errorLog); if (!serverReady) { result.cleanup(); console.error('Server stderr:', result.errorLog.join('\n')); - throw new Error(`Failed to start goosed on port ${port}: server did not become ready`); + throw new Error('Failed to start goosed'); } - const client = createClient( - createConfig({ - baseUrl: result.baseUrl, - headers: { - 'X-Secret-Key': TEST_SECRET_KEY, - }, - }) - ); - const cleanup = async (): Promise => { try { const logsPath = path.join(tempDir, 'state', 'logs', 'server'); @@ -152,7 +141,6 @@ export async function startGoosed({ return { client, baseUrl: result.baseUrl, - port, secretKey: TEST_SECRET_KEY, process: result.process, cleanup, From 08d5f6f1d9338e9108a6aa82d8f523fa0b365805 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 11:24:49 -0500 Subject: [PATCH 35/40] Use sdk type for /reply --- ui/desktop/tests/integration/goosed.test.ts | 94 +++++++++------------ 1 file changed, 40 insertions(+), 54 deletions(-) diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index 98c3501f5751..adb60a21f86e 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -16,6 +16,7 @@ import { listSessions, getSession, updateAgentProvider, + reply, } from '../../src/api'; import { execSync } from 'child_process'; import os from 'node:os'; @@ -142,18 +143,14 @@ extensions: expect(startResponse.response).toBeOkResponse(); const sessionId = startResponse.data!.id; - const sseResponse = await fetch(`${ctx.baseUrl}/reply`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': ctx.secretKey, - Accept: 'text/event-stream', - }, - body: JSON.stringify({ + 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), // Unix timestamp in seconds + created: Math.floor(Date.now() / 1000), content: [ { type: 'text', @@ -165,21 +162,21 @@ extensions: agentVisible: true, }, }, - }), + }, + throwOnError: true, + signal: abortController.signal, }); - expect(sseResponse.status).toBe(200); - - const reader = sseResponse.body?.getReader(); - if (reader) { - setTimeout(() => reader.cancel(), 1000); - try { - const { value } = await reader.read(); - expect(value).toBeDefined(); - } catch { - // Reader was cancelled, that's fine + 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, @@ -242,14 +239,10 @@ extensions: }); expect(providerResponse.response).toBeOkResponse(); - const sseResponse = await fetch(`${ctx.baseUrl}/reply`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': ctx.secretKey, - Accept: 'text/event-stream', - }, - body: JSON.stringify({ + const abortController = new AbortController(); + const { stream } = await reply({ + client: ctx.client, + body: { session_id: sessionId, user_message: { role: 'user', @@ -265,46 +258,39 @@ extensions: agentVisible: true, }, }, - }), + }, + throwOnError: true, + signal: abortController.signal, }); - expect(sseResponse.status).toBe(200); - - const reader = sseResponse.body?.getReader(); - const decoder = new TextDecoder(); - let returnedPath: string | undefined = undefined; - if (reader) { - const timeout = setTimeout(() => reader.cancel(), 60000); // 60s timeout - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; + const timeout = setTimeout(() => abortController.abort(), 60000); // 60s timeout - const chunk = decoder.decode(value, { stream: true }); - console.log('stream: ', chunk); + try { + for await (const event of stream) { + console.log('stream: ', JSON.stringify(event)); - try { - const data = JSON.parse(chunk.replace(/^data:/, '')); - const output = data?.message?.content?.[0]?.toolResult?.value?.content?.[0]?.text; + 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); - reader.cancel(); + abortController.abort(); returnedPath = output; break; } - } catch { - // The response we care about is always a complete JSON object. Others will be - // incomplete, so we expect parsing errors. } } - } catch (error) { - // Reader cancelled or error - console.log('Reader cancelled or error: ', error); } - clearTimeout(timeout); + } catch (error) { + // Aborted or error + if (!(error instanceof Error && error.name === 'AbortError')) { + console.log('Stream error: ', error); + } } + clearTimeout(timeout); await stopAgent({ client: ctx.client, From 2ced089ec0149a5bd9a71f6c9605f34fd9b218d7 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 11:33:17 -0500 Subject: [PATCH 36/40] more cleanup --- ui/desktop/tests/integration/goosed.test.ts | 8 +++-- ui/desktop/tests/integration/setup.ts | 34 ++++++++++++--------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts index adb60a21f86e..3e621f846534 100644 --- a/ui/desktop/tests/integration/goosed.test.ts +++ b/ui/desktop/tests/integration/goosed.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { startGoosed, type GoosedTestContext } from './setup'; +import { setupGoosed, type GoosedTestContext } from './setup'; import { status, readConfig, @@ -59,7 +59,7 @@ extensions: available_tools: [] `; - ctx = await startGoosed({ pathOverride: '/usr/bin:/bin', configYaml }); + ctx = await setupGoosed({ pathOverride: '/usr/bin:/bin', configYaml }); }); afterAll(async () => { @@ -273,7 +273,9 @@ extensions: 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 toolResult = content as { + toolResult?: { value?: { content?: Array<{ text?: string }> } }; + }; const output = toolResult?.toolResult?.value?.content?.[0]?.text; if (output && output.includes('/usr')) { clearTimeout(timeout); diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index f7f1c60b773b..1b0f01297b77 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -9,7 +9,6 @@ import type { ChildProcess } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { createClient, createConfig } from '../../src/api/client'; import type { Client } from '../../src/api/client'; import { startGoosed as startGoosedBase, checkServerStatus, type Logger } from '../../src/goosed'; import { expect } from 'vitest'; @@ -48,7 +47,7 @@ export interface GoosedTestContext { cleanup: () => Promise; } -export async function startGoosed({ +export async function setupGoosed({ pathOverride, configYaml, }: { @@ -79,21 +78,26 @@ export async function startGoosed({ additionalEnv.PATH = pathOverride; } - const result = await startGoosedBase({ + const { + baseUrl, + process: goosedProcess, + client, + cleanup: baseCleanup, + errorLog, + } = await startGoosedBase({ serverSecret: TEST_SECRET_KEY, env: additionalEnv, logger: testLogger, }); - const client = result.client; - if (!result.process) { + if (!goosedProcess) { throw new Error('Expected goosed process to be started, but got external backend'); } - const serverReady = await checkServerStatus(result.client, result.errorLog); + const serverReady = await checkServerStatus(client, errorLog); if (!serverReady) { - result.cleanup(); - console.error('Server stderr:', result.errorLog.join('\n')); + baseCleanup(); + console.error('Server stderr:', errorLog.join('\n')); throw new Error('Failed to start goosed'); } @@ -116,20 +120,20 @@ export async function startGoosed({ } return new Promise((resolve) => { - if (!result.process || result.process.killed) { + if (!goosedProcess || goosedProcess.killed) { resolve(); return; } - result.process.on('close', () => { + goosedProcess.on('close', () => { resolve(); }); - result.process.kill('SIGTERM'); + goosedProcess.kill('SIGTERM'); setTimeout(() => { - if (result.process && !result.process.killed) { - result.process.kill('SIGKILL'); + if (goosedProcess && !goosedProcess.killed) { + goosedProcess.kill('SIGKILL'); } resolve(); }, 5000); @@ -140,9 +144,9 @@ export async function startGoosed({ return { client, - baseUrl: result.baseUrl, + baseUrl, secretKey: TEST_SECRET_KEY, - process: result.process, + process: goosedProcess, cleanup, }; } From 38d8df6ba8974acc9225eae406c082acd59f4116 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 11:50:01 -0500 Subject: [PATCH 37/40] better cleanup --- ui/desktop/src/goosed.ts | 28 +++++++++++++++++++++------ ui/desktop/src/main.ts | 4 ++-- ui/desktop/tests/integration/setup.ts | 26 ++++--------------------- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index f46b3de9b4bc..6faad69073ad 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -151,7 +151,7 @@ export interface GoosedResult { workingDir: string; process: ChildProcess | null; errorLog: string[]; - cleanup: () => void; + cleanup: () => Promise; client: Client; } @@ -190,7 +190,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { + cleanup: async () => { logger.info('Not killing external process that is managed externally'); }, client: goosedClientForUrlAndSecret(url, serverSecret), @@ -207,7 +207,7 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { + cleanup: async () => { logger.info('Not killing external process that is managed externally'); }, client: goosedClientForUrlAndSecret(url, serverSecret), @@ -281,8 +281,17 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { - if (goosedProcess && !goosedProcess.killed) { + const cleanup = async (): Promise => { + return new Promise((resolve) => { + if (!goosedProcess || goosedProcess.killed) { + resolve(); + return; + } + + goosedProcess.on('close', () => { + resolve(); + }); + logger.info('Terminating goosed server'); try { if (process.platform === 'win32') { @@ -293,7 +302,14 @@ export const startGoosed = async (options: StartGoosedOptions): Promise { + if (goosedProcess && !goosedProcess.killed && process.platform !== 'win32') { + goosedProcess.kill('SIGKILL'); + } + resolve(); + }, 5000); + }); }; logger.info(`Goosed server successfully started on port ${port}`); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index d161fce8117b..72fa64c90902 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -493,9 +493,9 @@ const createChat = async ( logger: log, }); - app.on('will-quit', () => { + app.on('will-quit', async () => { log.info('App quitting, terminating goosed server'); - goosedResult.cleanup(); + await goosedResult.cleanup(); }); const { diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index 1b0f01297b77..949edb681aba 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -102,6 +102,7 @@ export async function setupGoosed({ } 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)) { @@ -116,30 +117,11 @@ export async function setupGoosed({ } } } catch { - // Logs may not exist, that's okay + // Logs may not exist } - return new Promise((resolve) => { - if (!goosedProcess || goosedProcess.killed) { - resolve(); - return; - } - - goosedProcess.on('close', () => { - resolve(); - }); - - goosedProcess.kill('SIGTERM'); - - setTimeout(() => { - if (goosedProcess && !goosedProcess.killed) { - goosedProcess.kill('SIGKILL'); - } - resolve(); - }, 5000); - }).then(async () => { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - }); + await baseCleanup(); + await fs.promises.rm(tempDir, { recursive: true, force: true }); }; return { From e249f3015a161c2fc7ff3be340c956cf8992fc38 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 14:15:33 -0500 Subject: [PATCH 38/40] Keep this, it's useful --- ui/desktop/src/goosed.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index 6faad69073ad..ea55c7c319f2 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -36,6 +36,14 @@ export interface FindBinaryOptions { } 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'; From bc3813e8cb0f92e8f5400a6ba8c71270755611ed Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 14:22:28 -0500 Subject: [PATCH 39/40] Improve types --- ui/desktop/tests/integration/setup.ts | 2 +- ui/desktop/tests/integration/vitest.d.ts | 10 ++++++++++ ui/desktop/tsconfig.json | 6 +++--- 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 ui/desktop/tests/integration/vitest.d.ts diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index 949edb681aba..75b10ca28c69 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -43,7 +43,7 @@ export interface GoosedTestContext { client: Client; baseUrl: string; secretKey: string; - process: ChildProcess; + process: ChildProcess | null; cleanup: () => Promise; } 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" }], } From 50eb21318596b16c099e13a633abc9afcaa04a42 Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Fri, 13 Feb 2026 14:24:24 -0500 Subject: [PATCH 40/40] run the full cleanup on error --- ui/desktop/tests/integration/setup.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts index 75b10ca28c69..6d6e208299a7 100644 --- a/ui/desktop/tests/integration/setup.ts +++ b/ui/desktop/tests/integration/setup.ts @@ -94,13 +94,6 @@ export async function setupGoosed({ throw new Error('Expected goosed process to be started, but got external backend'); } - const serverReady = await checkServerStatus(client, errorLog); - if (!serverReady) { - baseCleanup(); - console.error('Server stderr:', errorLog.join('\n')); - throw new Error('Failed to start goosed'); - } - const cleanup = async (): Promise => { // dump server logs to test logs, visible if there are test failures try { @@ -124,6 +117,13 @@ export async function setupGoosed({ 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,