From 31cc0c4401116b28b390b3a96d8967fa1d91077d Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Mon, 29 Jun 2026 13:13:53 +1000
Subject: [PATCH 01/34] ui connect to acp directly with env control
---
ui/desktop/src/acp/__tests__/url.test.ts | 22 +
ui/desktop/src/acp/url.ts | 13 +
.../src/components/MCPUIResourceRenderer.tsx | 9 +-
.../src/components/McpApps/McpAppRenderer.tsx | 41 +-
ui/desktop/src/gooseServe.ts | 316 +++++++++++
ui/desktop/src/main.ts | 491 ++++++++++++------
ui/desktop/src/renderer.tsx | 25 +-
7 files changed, 715 insertions(+), 202 deletions(-)
create mode 100644 ui/desktop/src/acp/__tests__/url.test.ts
create mode 100644 ui/desktop/src/acp/url.ts
create mode 100644 ui/desktop/src/gooseServe.ts
diff --git a/ui/desktop/src/acp/__tests__/url.test.ts b/ui/desktop/src/acp/__tests__/url.test.ts
new file mode 100644
index 000000000000..d9543effe273
--- /dev/null
+++ b/ui/desktop/src/acp/__tests__/url.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest';
+import { httpOriginFromAcpWebSocketUrl } from '../url';
+
+describe('httpOriginFromAcpWebSocketUrl', () => {
+ it('converts ws ACP URLs to HTTP origins', () => {
+ expect(httpOriginFromAcpWebSocketUrl('ws://127.0.0.1:64027/acp?token=secret')).toBe(
+ 'http://127.0.0.1:64027'
+ );
+ });
+
+ it('converts wss ACP URLs to HTTPS origins', () => {
+ expect(httpOriginFromAcpWebSocketUrl('wss://example.com/acp?token=secret')).toBe(
+ 'https://example.com'
+ );
+ });
+
+ it('rejects non-WebSocket URLs', () => {
+ expect(() => httpOriginFromAcpWebSocketUrl('http://127.0.0.1:64027/acp')).toThrow(
+ 'ACP URL must use ws: or wss:'
+ );
+ });
+});
diff --git a/ui/desktop/src/acp/url.ts b/ui/desktop/src/acp/url.ts
new file mode 100644
index 000000000000..2f035205d80f
--- /dev/null
+++ b/ui/desktop/src/acp/url.ts
@@ -0,0 +1,13 @@
+export function httpOriginFromAcpWebSocketUrl(acpUrl: string): string {
+ const url = new URL(acpUrl);
+
+ if (url.protocol === 'ws:') {
+ url.protocol = 'http:';
+ } else if (url.protocol === 'wss:') {
+ url.protocol = 'https:';
+ } else {
+ throw new Error(`ACP URL must use ws: or wss:, got ${url.protocol}`);
+ }
+
+ return url.origin;
+}
diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx
index 861b03a20b34..523905daa6df 100644
--- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx
+++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx
@@ -138,8 +138,13 @@ export default function MCPUIResourceRenderer({
const intl = useIntl();
const { resolvedTheme } = useTheme();
const [proxyUrl, setProxyUrl] = useState(undefined);
+ const backendAcpOnly = window.appConfig.get('GOOSE_BACKEND_ACP_ONLY') === true;
useEffect(() => {
+ if (backendAcpOnly) {
+ return;
+ }
+
const fetchProxyUrl = async () => {
try {
const gooseApiHost = await window.electron.getGoosedHostPort();
@@ -147,7 +152,7 @@ export default function MCPUIResourceRenderer({
if (gooseApiHost && secretKey) {
setProxyUrl(`${gooseApiHost}/mcp-ui-proxy?secret=${encodeURIComponent(secretKey)}`);
} else {
- console.error('Failed to get goosed host/port or secret key');
+ console.error('Failed to get REST backend host/port or secret key');
}
} catch (error) {
console.error('Error fetching MCP-UI Proxy URL:', error);
@@ -155,7 +160,7 @@ export default function MCPUIResourceRenderer({
};
fetchProxyUrl().catch(console.error);
- }, []);
+ }, [backendAcpOnly]);
const handleUIAction = async (actionEvent: UIActionResult): Promise => {
// result to pass back to the MCP-UI
diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx
index 27c5de93cc37..b405d00c4dff 100644
--- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx
+++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx
@@ -27,6 +27,7 @@ import type { CallToolResult, JSONRPCRequest, Tool } from '@modelcontextprotocol
import { GripHorizontal, Maximize2, PictureInPicture2, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { callMcpAppTool, readMcpAppResource } from '../../acp/mcp-apps';
+import { httpOriginFromAcpWebSocketUrl } from '../../acp/url';
import { getCachedTools } from './toolsCache';
import { AppEvents } from '../../constants/events';
import { useTheme } from '../../contexts/ThemeContext';
@@ -175,31 +176,31 @@ function getContainerDimensions(
async function fetchMcpAppProxyUrl(csp: McpUiResourceCsp | null): Promise {
try {
- const baseUrl = await window.electron.getGoosedHostPort();
+ const acpUrl = await window.electron.getAcpUrl();
const secretKey = await window.electron.getSecretKey();
- if (!baseUrl || !secretKey) {
- console.error('[McpAppRenderer] Failed to get goosed host/port or secret key');
+ if (!acpUrl || !secretKey) {
+ console.error('[McpAppRenderer] Failed to get ACP URL or secret key');
return null;
}
- const params = new URLSearchParams();
- params.set('secret', secretKey);
+ const proxyUrl = new URL('/mcp-app-proxy', httpOriginFromAcpWebSocketUrl(acpUrl));
+ proxyUrl.searchParams.set('secret', secretKey);
if (csp?.connectDomains?.length) {
- params.set('connect_domains', csp.connectDomains.join(','));
+ proxyUrl.searchParams.set('connect_domains', csp.connectDomains.join(','));
}
if (csp?.resourceDomains?.length) {
- params.set('resource_domains', csp.resourceDomains.join(','));
+ proxyUrl.searchParams.set('resource_domains', csp.resourceDomains.join(','));
}
if (csp?.frameDomains?.length) {
- params.set('frame_domains', csp.frameDomains.join(','));
+ proxyUrl.searchParams.set('frame_domains', csp.frameDomains.join(','));
}
if (csp?.baseUriDomains?.length) {
- params.set('base_uri_domains', csp.baseUriDomains.join(','));
+ proxyUrl.searchParams.set('base_uri_domains', csp.baseUriDomains.join(','));
}
- return `${baseUrl}/mcp-app-proxy?${params.toString()}`;
+ return proxyUrl.toString();
} catch (error) {
console.error('[McpAppRenderer] Error fetching MCP App Proxy URL:', error);
return null;
@@ -415,15 +416,20 @@ export default function McpAppRenderer({
const effectiveInlineHeight = iframeHeight || DEFAULT_IFRAME_HEIGHT;
+ const backendAcpOnly = window.appConfig.get('GOOSE_BACKEND_ACP_ONLY') === true;
const [containerWidth, setContainerWidth] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);
- const [apiHost, setApiHost] = useState(null);
+ const [restApiHost, setRestApiHost] = useState(null);
const [secretKey, setSecretKey] = useState(null);
useEffect(() => {
- window.electron.getGoosedHostPort().then(setApiHost);
+ if (backendAcpOnly) {
+ return;
+ }
+
+ window.electron.getGoosedHostPort().then(setRestApiHost);
window.electron.getSecretKey().then(setSecretKey);
- }, []);
+ }, [backendAcpOnly]);
// Fetch the resource from the extension to get HTML and metadata (CSP, permissions, etc.).
// If cachedHtml is provided we show it immediately; the fetch updates metadata and
@@ -675,12 +681,15 @@ export default function McpAppRenderer({
const handleFallbackRequest = useCallback(
async (request: JSONRPCRequest, _extra: RequestHandlerExtra) => {
if (request.method === 'sampling/createMessage') {
- if (!sessionId || !apiHost || !secretKey) {
+ if (backendAcpOnly) {
+ throw new Error('Sampling fallback is not available in direct ACP mode');
+ }
+ if (!sessionId || !restApiHost || !secretKey) {
throw new Error('Session not initialized for sampling request');
}
const { messages, systemPrompt, maxTokens } =
request.params as unknown as SamplingCreateMessageParams;
- const response = await fetch(`${apiHost}/sessions/${sessionId}/sampling/message`, {
+ const response = await fetch(`${restApiHost}/sessions/${sessionId}/sampling/message`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -705,7 +714,7 @@ export default function McpAppRenderer({
message: `Unhandled JSON-RPC method: ${request.method ?? ''}`,
};
},
- [sessionId, apiHost, secretKey]
+ [backendAcpOnly, sessionId, restApiHost, secretKey]
);
const handleError = useCallback((err: Error) => {
diff --git a/ui/desktop/src/gooseServe.ts b/ui/desktop/src/gooseServe.ts
new file mode 100644
index 000000000000..66d97f5821bb
--- /dev/null
+++ b/ui/desktop/src/gooseServe.ts
@@ -0,0 +1,316 @@
+import { spawn, type ChildProcess } from 'child_process';
+import fs from 'node:fs';
+import { createServer } from 'node:net';
+import os from 'node:os';
+import path from 'node:path';
+
+export interface Logger {
+ info: (...args: unknown[]) => void;
+ error: (...args: unknown[]) => void;
+}
+
+export const defaultLogger: Logger = {
+ info: (...args) => console.log('[goose-serve]', ...args),
+ error: (...args) => console.error('[goose-serve]', ...args),
+};
+
+export interface FindGooseBinaryOptions {
+ isPackaged?: boolean;
+ resourcesPath?: string;
+}
+
+export interface StartGooseServeOptions extends FindGooseBinaryOptions {
+ dir?: string;
+ serverSecret: string;
+ env?: Record;
+ logger?: Logger;
+}
+
+export interface GooseServeResult {
+ acpUrl: string;
+ workingDir: string;
+ process: ChildProcess;
+ errorLog: string[];
+ cleanup: () => Promise;
+}
+
+const existingFile = (candidate: string): boolean => {
+ try {
+ return fs.existsSync(candidate) && fs.statSync(candidate).isFile();
+ } catch {
+ return false;
+ }
+};
+
+export const findGooseBinaryPath = (options: FindGooseBinaryOptions = {}): string => {
+ const pathFromEnv = process.env.GOOSE_BINARY;
+ if (pathFromEnv) {
+ const resolvedPath = path.resolve(pathFromEnv);
+ if (existingFile(resolvedPath)) {
+ return resolvedPath;
+ }
+ throw new Error(`Invalid GOOSE_BINARY path: ${pathFromEnv} (pwd is ${process.cwd()})`);
+ }
+
+ const { isPackaged = false, resourcesPath } = options;
+ const binaryName = process.platform === 'win32' ? 'goose.exe' : 'goose';
+ const possiblePaths: string[] = [];
+
+ if (isPackaged && resourcesPath) {
+ possiblePaths.push(path.join(resourcesPath, 'bin', binaryName));
+ possiblePaths.push(path.join(resourcesPath, binaryName));
+ }
+
+ 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 candidate of possiblePaths) {
+ if (existingFile(candidate)) {
+ return candidate;
+ }
+ }
+
+ throw new Error(
+ `Goose binary not found in any of the possible paths: ${possiblePaths.join(', ')}`
+ );
+};
+
+const findAvailablePort = (): Promise => {
+ 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(() => {
+ resolve(port);
+ });
+ });
+ });
+};
+
+const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms));
+
+const isFatalError = (line: string): boolean => {
+ const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i];
+ return fatalPatterns.some((pattern) => pattern.test(line));
+};
+
+const appendTail = (target: string[], lines: string[], maxLines = 100): void => {
+ for (const line of lines) {
+ if (line.trim()) {
+ target.push(line);
+ }
+ }
+ if (target.length > maxLines) {
+ target.splice(0, target.length - maxLines);
+ }
+};
+
+const fetchStatus = async (statusUrl: string): Promise => {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 1000);
+
+ try {
+ const response = await fetch(statusUrl, { signal: controller.signal });
+ return response.ok;
+ } catch {
+ return false;
+ } finally {
+ clearTimeout(timeout);
+ }
+};
+
+const waitForGooseServeReady = async (
+ statusUrl: string,
+ errorLog: string[],
+ shouldStopWaiting: () => boolean
+): Promise => {
+ const timeout = 30000;
+ const interval = 100;
+ const deadline = Date.now() + timeout;
+
+ while (Date.now() < deadline) {
+ if (shouldStopWaiting() || errorLog.some(isFatalError)) {
+ return false;
+ }
+
+ if (await fetchStatus(statusUrl)) {
+ return true;
+ }
+
+ await delay(interval);
+ }
+
+ return false;
+};
+
+const buildAcpUrl = (port: number, token: string): string => {
+ const url = new URL(`http://127.0.0.1:${port}/acp`);
+ url.protocol = 'ws:';
+ url.searchParams.set('token', token);
+ return url.toString();
+};
+
+const buildGooseServeEnv = (
+ serverSecret: string,
+ binaryPath: string,
+ additionalEnv: Record
+): Record => {
+ const homeDir = process.env.HOME || os.homedir();
+ const pathKey = process.platform === 'win32' ? 'Path' : 'PATH';
+ const currentPath = process.env[pathKey] || '';
+
+ const env: Record = {
+ ...process.env,
+ HOME: homeDir,
+ [pathKey]: `${path.dirname(binaryPath)}${path.delimiter}${currentPath}`,
+ };
+
+ 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');
+ }
+
+ for (const [key, value] of Object.entries(additionalEnv)) {
+ if (value !== undefined) {
+ env[key] = value;
+ }
+ }
+
+ env.GOOSE_SERVER__SECRET_KEY = serverSecret;
+
+ return env;
+};
+
+export const startGooseServe = async ({
+ dir,
+ serverSecret,
+ env: additionalEnv = {},
+ isPackaged,
+ resourcesPath,
+ logger = defaultLogger,
+}: StartGooseServeOptions): Promise => {
+ const workingDir = dir || process.cwd();
+ const secretKey = serverSecret.trim();
+ if (!secretKey) {
+ throw new Error('GOOSE_SERVER__SECRET_KEY is required for goose serve');
+ }
+
+ const goosePath = findGooseBinaryPath({ isPackaged, resourcesPath });
+ const port = await findAvailablePort();
+ const statusUrl = `http://127.0.0.1:${port}/status`;
+ const acpUrl = buildAcpUrl(port, secretKey);
+ const errorLog: string[] = [];
+
+ logger.info(`Starting goose serve from: ${goosePath} on port ${port} in dir ${workingDir}`);
+
+ const gooseProcess = spawn(goosePath, ['serve', '--host', '127.0.0.1', '--port', String(port)], {
+ env: buildGooseServeEnv(secretKey, goosePath, additionalEnv),
+ cwd: workingDir,
+ windowsHide: true,
+ detached: process.platform === 'win32',
+ shell: false,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ let exited = false;
+ let spawnFailed = false;
+ let exitCode: number | null = null;
+ let exitSignal: NodeJS.Signals | null = null;
+
+ gooseProcess.stdout?.resume();
+
+ const onStderrData = (data: Buffer) => {
+ const lines = data.toString().split('\n');
+ appendTail(errorLog, lines);
+ for (const line of lines) {
+ if (line.trim() && isFatalError(line)) {
+ logger.error(`goose serve stderr for port ${port} and dir ${workingDir}: ${line}`);
+ }
+ }
+ };
+
+ gooseProcess.stderr?.on('data', onStderrData);
+
+ gooseProcess.on('exit', (code, signal) => {
+ exited = true;
+ exitCode = code;
+ exitSignal = signal;
+ logger.info(
+ `goose serve process exited with code ${code} and signal ${signal} for port ${port} and dir ${workingDir}`
+ );
+ });
+
+ gooseProcess.on('error', (error) => {
+ spawnFailed = true;
+ errorLog.push(error.message);
+ logger.error(`Failed to start goose serve on port ${port} and dir ${workingDir}`, error);
+ });
+
+ const cleanup = async (): Promise => {
+ return new Promise((resolve) => {
+ if (exited || gooseProcess.killed) {
+ resolve();
+ return;
+ }
+
+ let resolved = false;
+ const finish = () => {
+ if (!resolved) {
+ resolved = true;
+ resolve();
+ }
+ };
+
+ gooseProcess.once('close', finish);
+
+ logger.info('Terminating goose serve');
+ try {
+ if (process.platform === 'win32') {
+ if (gooseProcess.pid) {
+ spawn('taskkill', ['/pid', gooseProcess.pid.toString(), '/f', '/t']);
+ }
+ } else {
+ gooseProcess.kill('SIGTERM');
+ }
+ } catch (error) {
+ logger.error('Error while terminating goose serve process:', error);
+ }
+
+ setTimeout(() => {
+ if (!exited && !gooseProcess.killed && process.platform !== 'win32') {
+ gooseProcess.kill('SIGKILL');
+ }
+ finish();
+ }, 5000);
+ });
+ };
+
+ const ready = await waitForGooseServeReady(statusUrl, errorLog, () => exited || spawnFailed);
+ gooseProcess.stderr?.off('data', onStderrData);
+ gooseProcess.stderr?.resume();
+
+ if (!ready) {
+ await cleanup();
+ const exitDetails = exited
+ ? ` Process exited with code ${exitCode} and signal ${exitSignal}.`
+ : '';
+ const stderrDetails = errorLog.length ? ` Stderr: ${errorLog.join('\n')}` : '';
+ throw new Error(
+ `goose serve did not become ready on ${statusUrl}.${exitDetails}${stderrDetails}`
+ );
+ }
+
+ return {
+ acpUrl,
+ workingDir,
+ process: gooseProcess,
+ errorLog,
+ cleanup,
+ };
+};
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index 6f166939ea6a..cd0939dd4a1f 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -27,6 +27,7 @@ import { execFileSync, spawn, execFile } from 'child_process';
import 'dotenv/config';
import { checkServerStatus } from './goosed';
import { startGoosed } from './goosed';
+import { startGooseServe } from './gooseServe';
import { createClient, createConfig } from './api/client';
import { expandTilde } from './utils/pathUtils';
import log from './utils/logger';
@@ -799,6 +800,7 @@ let appConfig = {
GOOSE_DEFAULT_MODEL: defaultModel,
GOOSE_PREDEFINED_MODELS: predefinedModels,
GOOSE_API_HOST: 'https://localhost',
+ GOOSE_BACKEND_ACP_ONLY: process.env.GOOSE_BACKEND_ACP_ONLY === 'true',
GOOSE_PATH_ROOT: resolveGoosePathRoot(),
GOOSE_WORKING_DIR: '',
// Start with the env-var override; the OS region locale is filled in after app.ready
@@ -813,6 +815,13 @@ const windowMap = new Map();
const goosedClients = new Map();
const appWindows = new Map();
+interface GooseServeLease {
+ acpUrl: string;
+ cleanup: () => Promise;
+ windowIds: Set;
+ cleanedUp: boolean;
+}
+
interface GoosedLease {
client: Client;
cleanup: () => Promise;
@@ -821,6 +830,7 @@ interface GoosedLease {
}
const goosedLeasesByWindowId = new Map();
+const gooseServeLeasesByWindowId = new Map();
const cleanupGoosedLease = async (lease: GoosedLease) => {
if (lease.cleanedUp) {
@@ -862,6 +872,43 @@ const releaseWindowGoosedLease = async (windowId: number) => {
}
};
+const cleanupGooseServeLease = async (lease: GooseServeLease) => {
+ if (lease.cleanedUp) {
+ return;
+ }
+
+ lease.cleanedUp = true;
+ for (const windowId of lease.windowIds) {
+ gooseServeLeasesByWindowId.delete(windowId);
+ }
+ lease.windowIds.clear();
+
+ try {
+ await lease.cleanup();
+ } catch (error) {
+ log.error('Failed to cleanup goose serve backend:', error);
+ }
+};
+
+const attachWindowToGooseServeLease = (windowId: number, lease: GooseServeLease) => {
+ lease.windowIds.add(windowId);
+ gooseServeLeasesByWindowId.set(windowId, lease);
+};
+
+const releaseWindowGooseServeLease = async (windowId: number) => {
+ const lease = gooseServeLeasesByWindowId.get(windowId);
+ gooseServeLeasesByWindowId.delete(windowId);
+
+ if (!lease) {
+ return;
+ }
+
+ lease.windowIds.delete(windowId);
+ if (lease.windowIds.size === 0) {
+ await cleanupGooseServeLease(lease);
+ }
+};
+
const windowPowerSaveBlockers = new Map(); // windowId -> blockerId
// Track pending initial messages per window
const pendingInitialMessages = new Map(); // windowId -> initialMessage
@@ -892,107 +939,155 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
recipeParameters,
} = options;
const settings = getSettings();
- const serverSecret = getServerSecret(settings);
+ const backendAcpOnly = appConfig.GOOSE_BACKEND_ACP_ONLY === true;
+ const serverSecret = backendAcpOnly ? GENERATED_SECRET : getServerSecret(settings);
+ let baseUrl = '';
+ let workingDir = dir || os.homedir();
+ let goosedResult: Awaited> | null = null;
+ let gooseServeLease: GooseServeLease | null = null;
+
+ if (backendAcpOnly) {
+ trustedExternalHostname = null;
+ pinnedCertFingerprint = null;
- // Update the cached trusted-external-hostname so the TLS handlers allow
- // connections to the configured remote backend.
- if (settings.externalGoosed?.enabled && settings.externalGoosed.url) {
- try {
- trustedExternalHostname = new URL(settings.externalGoosed.url).hostname;
- } catch {
+ const gooseServeResult = await startGooseServe({
+ serverSecret,
+ dir: workingDir,
+ env: {
+ GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
+ },
+ isPackaged: app.isPackaged,
+ resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
+ logger: log,
+ });
+
+ workingDir = gooseServeResult.workingDir;
+ gooseServeLease = {
+ acpUrl: gooseServeResult.acpUrl,
+ cleanup: gooseServeResult.cleanup,
+ windowIds: new Set(),
+ cleanedUp: false,
+ };
+ } else {
+ // Update the cached trusted-external-hostname so the TLS handlers allow
+ // connections to the configured remote backend.
+ if (settings.externalGoosed?.enabled && settings.externalGoosed.url) {
+ try {
+ trustedExternalHostname = new URL(settings.externalGoosed.url).hostname;
+ } catch {
+ trustedExternalHostname = null;
+ }
+ } else {
trustedExternalHostname = null;
}
- } else {
- trustedExternalHostname = null;
- }
- // If the user provided a cert fingerprint for the external backend, pin it
- // directly (skips TOFU). Otherwise reset so the first handshake pins via TOFU.
- if (settings.externalGoosed?.enabled && settings.externalGoosed.certFingerprint) {
- pinnedCertFingerprint = normalizeFingerprint(settings.externalGoosed.certFingerprint);
- } else {
- pinnedCertFingerprint = null;
- }
+ // If the user provided a cert fingerprint for the external backend, pin it
+ // directly (skips TOFU). Otherwise reset so the first handshake pins via TOFU.
+ if (settings.externalGoosed?.enabled && settings.externalGoosed.certFingerprint) {
+ pinnedCertFingerprint = normalizeFingerprint(settings.externalGoosed.certFingerprint);
+ } else {
+ pinnedCertFingerprint = null;
+ }
- const goosedResult = await startGoosed({
- serverSecret,
- dir: dir || os.homedir(),
- env: {
- GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
- },
- externalGoosed: settings.externalGoosed,
- isPackaged: app.isPackaged,
- resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
- logger: log,
- diagnosticsDir: STARTUP_LOGS_DIR,
- });
+ goosedResult = await startGoosed({
+ serverSecret,
+ dir: workingDir,
+ env: {
+ GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
+ },
+ externalGoosed: settings.externalGoosed,
+ isPackaged: app.isPackaged,
+ resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
+ logger: log,
+ diagnosticsDir: STARTUP_LOGS_DIR,
+ });
+
+ // For locally-spawned goosed, pin using the fingerprint from stdout.
+ // For external backends the TOFU path in the cert handlers will pin
+ // the fingerprint on the first successful TLS handshake.
+ if (goosedResult.certFingerprint) {
+ pinnedCertFingerprint = goosedResult.certFingerprint;
+ }
- // For locally-spawned goosed, pin using the fingerprint from stdout.
- // For external backends the TOFU path in the cert handlers will pin
- // the fingerprint on the first successful TLS handshake.
- if (goosedResult.certFingerprint) {
- pinnedCertFingerprint = goosedResult.certFingerprint;
+ baseUrl = goosedResult.baseUrl;
+ workingDir = goosedResult.workingDir;
}
- const {
- baseUrl,
- workingDir,
- errorLog,
- stopErrorLogCollection,
- startupDiagnosticsPath,
- getStartupDiagnostics,
- recordStartupEvent,
- } = goosedResult;
-
- const mainWindowState = windowStateKeeper({
- defaultWidth: 940,
- defaultHeight: 800,
- });
+ const cleanupUnregisteredGooseServeLease = async () => {
+ if (!gooseServeLease) {
+ return;
+ }
- const mainWindow = new BrowserWindow({
- titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
- trafficLightPosition: process.platform === 'darwin' ? { x: 20, y: 16 } : undefined,
- vibrancy: process.platform === 'darwin' ? 'window' : undefined,
- frame: process.platform !== 'darwin',
- // windowStateKeeper persists the outer window bounds (getBounds), so the
- // window must be restored by outer bounds too. With useContentSize the saved
- // outer height is reapplied as the content height, growing the window by the
- // frame height on every launch on framed platforms (#9363).
- x: mainWindowState.x,
- y: mainWindowState.y,
- width: mainWindowState.width,
- height: mainWindowState.height,
- minWidth: 480,
- minHeight: 400,
- resizable: true,
- icon: path.join(__dirname, '../images/icon.icns'),
- webPreferences: {
- spellcheck: settings.spellcheckEnabled ?? true,
- preload: path.join(__dirname, 'preload.js'),
- webSecurity: true,
- nodeIntegration: false,
- contextIsolation: true,
- additionalArguments: [
- JSON.stringify({
- ...appConfig,
- GOOSE_LOCALE: getConfiguredGooseLocale(),
- GOOSE_API_HOST: baseUrl,
- GOOSE_WORKING_DIR: workingDir,
- REQUEST_DIR: dir,
- GOOSE_VERSION: version,
- recipeDeeplink: recipeDeeplink,
- recipeId: recipeId,
- recipeParameters: recipeParameters,
- scheduledJobId: scheduledJobId,
- SECURITY_ML_MODEL_MAPPING: process.env.SECURITY_ML_MODEL_MAPPING,
- SECURITY_PROMPT_ENABLED_OVERRIDE: process.env.SECURITY_PROMPT_ENABLED_OVERRIDE,
- SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE:
- process.env.SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE,
- }),
- ],
- partition: 'persist:goose',
- },
- });
+ const lease = gooseServeLease;
+ gooseServeLease = null;
+ await cleanupGooseServeLease(lease);
+ };
+
+ let mainWindowState: ReturnType;
+ let mainWindow: BrowserWindow;
+ try {
+ mainWindowState = windowStateKeeper({
+ defaultWidth: 940,
+ defaultHeight: 800,
+ });
+
+ mainWindow = new BrowserWindow({
+ titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
+ trafficLightPosition: process.platform === 'darwin' ? { x: 20, y: 16 } : undefined,
+ vibrancy: process.platform === 'darwin' ? 'window' : undefined,
+ frame: process.platform !== 'darwin',
+ // windowStateKeeper persists the outer window bounds (getBounds), so the
+ // window must be restored by outer bounds too. With useContentSize the saved
+ // outer height is reapplied as the content height, growing the window by the
+ // frame height on every launch on framed platforms (#9363).
+ x: mainWindowState.x,
+ y: mainWindowState.y,
+ width: mainWindowState.width,
+ height: mainWindowState.height,
+ minWidth: 480,
+ minHeight: 400,
+ resizable: true,
+ icon: path.join(__dirname, '../images/icon.icns'),
+ webPreferences: {
+ spellcheck: settings.spellcheckEnabled ?? true,
+ preload: path.join(__dirname, 'preload.js'),
+ webSecurity: true,
+ nodeIntegration: false,
+ contextIsolation: true,
+ additionalArguments: [
+ JSON.stringify({
+ ...appConfig,
+ GOOSE_LOCALE: getConfiguredGooseLocale(),
+ GOOSE_API_HOST: baseUrl,
+ GOOSE_WORKING_DIR: workingDir,
+ REQUEST_DIR: dir,
+ GOOSE_VERSION: version,
+ recipeDeeplink: recipeDeeplink,
+ recipeId: recipeId,
+ recipeParameters: recipeParameters,
+ scheduledJobId: scheduledJobId,
+ SECURITY_ML_MODEL_MAPPING: process.env.SECURITY_ML_MODEL_MAPPING,
+ SECURITY_PROMPT_ENABLED_OVERRIDE: process.env.SECURITY_PROMPT_ENABLED_OVERRIDE,
+ SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE:
+ process.env.SECURITY_COMMAND_CLASSIFIER_ENABLED_OVERRIDE,
+ }),
+ ],
+ partition: 'persist:goose',
+ },
+ });
+ } catch (error) {
+ await cleanupUnregisteredGooseServeLease();
+ throw error;
+ }
+
+ if (gooseServeLease) {
+ const lease = gooseServeLease;
+ mainWindow.once('closed', () => {
+ void releaseWindowGooseServeLease(mainWindow.id);
+ });
+ attachWindowToGooseServeLease(mainWindow.id, lease);
+ gooseServeLease = null;
+ }
if (!app.isPackaged) {
installExtension(REACT_DEVELOPER_TOOLS, {
@@ -1003,87 +1098,100 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
.catch((err) => log.info('failed to install react dev tools:', err));
}
- // Re-create the client with Electron's net.fetch so requests to the local
- // self-signed HTTPS server go through the session's certificate handling.
- const goosedClient = createClient(
- createConfig({
- baseUrl,
- fetch: net.fetch as unknown as typeof globalThis.fetch,
- headers: {
- 'Content-Type': 'application/json',
- 'X-Secret-Key': serverSecret,
- },
- })
- );
- const goosedLease: GoosedLease = {
- client: goosedClient,
- cleanup: goosedResult.cleanup,
- windowIds: new Set(),
- cleanedUp: false,
- };
- attachWindowToGoosedLease(mainWindow.id, goosedLease);
- mainWindow.once('closed', () => {
- void releaseWindowGoosedLease(mainWindow.id);
- });
+ if (goosedResult) {
+ const {
+ errorLog,
+ stopErrorLogCollection,
+ startupDiagnosticsPath,
+ getStartupDiagnostics,
+ recordStartupEvent,
+ } = goosedResult;
+
+ // Re-create the client with Electron's net.fetch so requests to the local
+ // self-signed HTTPS server go through the session's certificate handling.
+ const goosedClient = createClient(
+ createConfig({
+ baseUrl,
+ fetch: net.fetch as unknown as typeof globalThis.fetch,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Secret-Key': serverSecret,
+ },
+ })
+ );
+ const goosedLease: GoosedLease = {
+ client: goosedClient,
+ cleanup: goosedResult.cleanup,
+ windowIds: new Set(),
+ cleanedUp: false,
+ };
+ attachWindowToGoosedLease(mainWindow.id, goosedLease);
+ mainWindow.once('closed', () => {
+ void releaseWindowGoosedLease(mainWindow.id);
+ });
- const serverReady = await checkServerStatus(goosedClient, errorLog, {
- onEvent: recordStartupEvent,
- });
- if (!serverReady) {
- const isUsingExternalBackend = settings.externalGoosed?.enabled;
- const diagnostics = getStartupDiagnostics();
- const stderrTail = diagnostics?.stderrTail ?? [];
- const failureDetailParts = [
- diagnostics?.childExitCode !== null || diagnostics?.childExitSignal
- ? `Child exit: code=${diagnostics?.childExitCode ?? 'null'} signal=${diagnostics?.childExitSignal ?? 'null'}`
- : 'Child exit: unavailable',
- diagnostics?.certFingerprintSeen
- ? 'TLS fingerprint observed: yes'
- : 'TLS fingerprint observed: no',
- diagnostics?.healthCheckSucceeded
- ? 'Health check observed: yes'
- : 'Health check observed: no',
- startupDiagnosticsPath ? `Startup diagnostics: ${startupDiagnosticsPath}` : '',
- errorLog.length > 0 ? `Startup errors:\n${errorLog.join('\n')}` : '',
- stderrTail.length > 0 ? `Captured startup stderr:\n${stderrTail.join('\n')}` : '',
- ].filter(Boolean);
-
- if (isUsingExternalBackend) {
- const response = dialog.showMessageBoxSync({
- type: 'error',
- title: 'External Backend Unreachable',
- message: `Could not connect to external backend at ${settings.externalGoosed?.url}`,
- detail: 'The external goosed server may not be running.',
- buttons: ['Disable External Backend & Retry', 'Quit'],
- defaultId: 0,
- cancelId: 1,
- });
+ const serverReady = await checkServerStatus(goosedClient, errorLog, {
+ onEvent: recordStartupEvent,
+ });
+ if (!serverReady) {
+ const isUsingExternalBackend = settings.externalGoosed?.enabled;
+ const diagnostics = getStartupDiagnostics();
+ const stderrTail = diagnostics?.stderrTail ?? [];
+ const failureDetailParts = [
+ diagnostics?.childExitCode !== null || diagnostics?.childExitSignal
+ ? `Child exit: code=${diagnostics?.childExitCode ?? 'null'} signal=${diagnostics?.childExitSignal ?? 'null'}`
+ : 'Child exit: unavailable',
+ diagnostics?.certFingerprintSeen
+ ? 'TLS fingerprint observed: yes'
+ : 'TLS fingerprint observed: no',
+ diagnostics?.healthCheckSucceeded
+ ? 'Health check observed: yes'
+ : 'Health check observed: no',
+ startupDiagnosticsPath ? `Startup diagnostics: ${startupDiagnosticsPath}` : '',
+ errorLog.length > 0 ? `Startup errors:\n${errorLog.join('\n')}` : '',
+ stderrTail.length > 0 ? `Captured startup stderr:\n${stderrTail.join('\n')}` : '',
+ ].filter(Boolean);
+
+ if (isUsingExternalBackend) {
+ const response = dialog.showMessageBoxSync({
+ type: 'error',
+ title: 'External Backend Unreachable',
+ message: `Could not connect to external backend at ${settings.externalGoosed?.url}`,
+ detail: 'The external goosed server may not be running.',
+ buttons: ['Disable External Backend & Retry', 'Quit'],
+ defaultId: 0,
+ cancelId: 1,
+ });
- if (response === 0) {
- updateSettings((s) => {
- if (s.externalGoosed) {
- s.externalGoosed.enabled = false;
- }
+ if (response === 0) {
+ updateSettings((s) => {
+ if (s.externalGoosed) {
+ s.externalGoosed.enabled = false;
+ }
+ });
+ mainWindow.destroy();
+ return createChat(app, { initialMessage, dir });
+ }
+ } else {
+ dialog.showMessageBoxSync({
+ type: 'error',
+ title: 'Goose Failed to Start',
+ message: 'The backend server failed to start.',
+ detail: failureDetailParts.join('\n\n'),
+ buttons: ['OK'],
});
- mainWindow.destroy();
- return createChat(app, { initialMessage, dir });
}
- } else {
- dialog.showMessageBoxSync({
- type: 'error',
- title: 'Goose Failed to Start',
- message: 'The backend server failed to start.',
- detail: failureDetailParts.join('\n\n'),
- buttons: ['OK'],
- });
+ app.quit();
+ return;
}
- app.quit();
- }
- // errorLog is only needed during startup to detect fatal errors.
- // Stop collecting stderr to avoid unbounded memory growth over long sessions.
- stopErrorLogCollection();
- errorLog.length = 0;
+ // errorLog is only needed during startup to detect fatal errors.
+ // Stop collecting stderr to avoid unbounded memory growth over long sessions.
+ stopErrorLogCollection();
+ errorLog.length = 0;
+ } else if (!backendAcpOnly) {
+ throw new Error('No desktop backend was started');
+ }
// Let windowStateKeeper manage the window
mainWindowState.manage(mainWindow);
@@ -1749,6 +1857,10 @@ ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => {
});
ipcMain.handle('get-secret-key', () => {
+ if (appConfig.GOOSE_BACKEND_ACP_ONLY === true) {
+ return GENERATED_SECRET;
+ }
+
const settings = getSettings();
return getServerSecret(settings);
});
@@ -1770,6 +1882,11 @@ ipcMain.handle('get-acp-url', async (event) => {
if (!windowId) {
return null;
}
+ const serveLease = gooseServeLeasesByWindowId.get(windowId);
+ if (serveLease) {
+ return serveLease.acpUrl;
+ }
+
const client = goosedClients.get(windowId);
const baseUrl = client?.getConfig().baseUrl;
if (!baseUrl) {
@@ -2769,11 +2886,15 @@ async function appMain() {
}
const launchingWindowId = launchingWindow.id;
- const launchingLease = goosedLeasesByWindowId.get(launchingWindowId);
- if (!launchingLease) {
- throw new Error('No goosed lease found for launching window');
+ const launchingGoosedLease = goosedLeasesByWindowId.get(launchingWindowId);
+ const launchingGooseServeLease = gooseServeLeasesByWindowId.get(launchingWindowId);
+ if (!launchingGoosedLease && !launchingGooseServeLease) {
+ throw new Error('No backend lease found for launching window');
}
+ const workingDir = app.getPath('home');
+ const restApiHost = launchingGoosedLease?.client.getConfig().baseUrl ?? '';
+
const appWindow = new BrowserWindow({
title: formatAppName(gooseApp.name),
width: gooseApp.width ?? 800,
@@ -2785,19 +2906,37 @@ async function appMain() {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
+ additionalArguments: [
+ JSON.stringify({
+ ...appConfig,
+ GOOSE_LOCALE: getConfiguredGooseLocale(),
+ GOOSE_API_HOST: restApiHost,
+ GOOSE_WORKING_DIR: workingDir,
+ GOOSE_VERSION: version,
+ }),
+ ],
partition: 'persist:goose',
},
});
- attachWindowToGoosedLease(appWindow.id, launchingLease);
+ let releaseAppWindowBackend: () => Promise;
+ if (launchingGoosedLease) {
+ attachWindowToGoosedLease(appWindow.id, launchingGoosedLease);
+ releaseAppWindowBackend = () => releaseWindowGoosedLease(appWindow.id);
+ } else if (launchingGooseServeLease) {
+ attachWindowToGooseServeLease(appWindow.id, launchingGooseServeLease);
+ releaseAppWindowBackend = () => releaseWindowGooseServeLease(appWindow.id);
+ } else {
+ throw new Error('No backend lease found for launching window');
+ }
+
appWindows.set(gooseApp.name, appWindow);
appWindow.on('closed', () => {
- void releaseWindowGoosedLease(appWindow.id);
+ void releaseAppWindowBackend();
appWindows.delete(gooseApp.name);
});
- const workingDir = app.getPath('home');
const extensionName = gooseApp.mcpServers?.[0] ?? '';
const url = getAppUrl();
@@ -2902,6 +3041,12 @@ app.on('will-quit', async () => {
await Promise.all([...goosedLeases].map(cleanupGoosedLease));
}
+ const gooseServeLeases = new Set(gooseServeLeasesByWindowId.values());
+ if (gooseServeLeases.size > 0) {
+ log.info(`App quitting, terminating ${gooseServeLeases.size} goose serve process(es)`);
+ await Promise.all([...gooseServeLeases].map(cleanupGooseServeLease));
+ }
+
for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) {
try {
powerSaveBlocker.stop(blockerId);
diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx
index e809407a9b31..0d8a6808aa93 100644
--- a/ui/desktop/src/renderer.tsx
+++ b/ui/desktop/src/renderer.tsx
@@ -36,18 +36,21 @@ function handleIntlError(err: { code: string; message?: string }) {
const isLauncher = window.location.hash === '#/launcher';
if (!isLauncher) {
- const gooseApiHost = await window.electron.getGoosedHostPort();
- if (gooseApiHost === null) {
- window.alert('failed to start goose backend process');
- return;
+ const backendAcpOnly = window.appConfig.get('GOOSE_BACKEND_ACP_ONLY') === true;
+ if (!backendAcpOnly) {
+ const gooseApiHost = await window.electron.getGoosedHostPort();
+ if (gooseApiHost === null) {
+ window.alert('failed to start goose backend process');
+ return;
+ }
+ client.setConfig({
+ baseUrl: gooseApiHost,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Secret-Key': await window.electron.getSecretKey(),
+ },
+ });
}
- client.setConfig({
- baseUrl: gooseApiHost,
- headers: {
- 'Content-Type': 'application/json',
- 'X-Secret-Key': await window.electron.getSecretKey(),
- },
- });
try {
const telemetryValue = await acpReadConfig(TELEMETRY_CONFIG_KEY, false);
From 92bf60cdb6d0ab8d94a8584aee3ad987ce472e84 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Mon, 29 Jun 2026 14:37:16 +1000
Subject: [PATCH 02/34] always point packaged goose serve in non dev/test mode
and enhance url parse
---
ui/desktop/src/acp/__tests__/url.test.ts | 20 +++---
ui/desktop/src/acp/url.ts | 7 ++-
.../src/components/McpApps/McpAppRenderer.tsx | 5 +-
ui/desktop/src/gooseServe.test.ts | 62 +++++++++++++++++++
ui/desktop/src/gooseServe.ts | 8 ++-
ui/desktop/src/main.ts | 20 ++++--
6 files changed, 104 insertions(+), 18 deletions(-)
create mode 100644 ui/desktop/src/gooseServe.test.ts
diff --git a/ui/desktop/src/acp/__tests__/url.test.ts b/ui/desktop/src/acp/__tests__/url.test.ts
index d9543effe273..308dc84c0ccb 100644
--- a/ui/desktop/src/acp/__tests__/url.test.ts
+++ b/ui/desktop/src/acp/__tests__/url.test.ts
@@ -1,21 +1,27 @@
import { describe, expect, it } from 'vitest';
-import { httpOriginFromAcpWebSocketUrl } from '../url';
+import { httpBaseFromAcpWebSocketUrl } from '../url';
-describe('httpOriginFromAcpWebSocketUrl', () => {
- it('converts ws ACP URLs to HTTP origins', () => {
- expect(httpOriginFromAcpWebSocketUrl('ws://127.0.0.1:64027/acp?token=secret')).toBe(
+describe('httpBaseFromAcpWebSocketUrl', () => {
+ it('converts ws ACP URLs to HTTP bases', () => {
+ expect(httpBaseFromAcpWebSocketUrl('ws://127.0.0.1:64027/acp?token=secret')).toBe(
'http://127.0.0.1:64027'
);
});
- it('converts wss ACP URLs to HTTPS origins', () => {
- expect(httpOriginFromAcpWebSocketUrl('wss://example.com/acp?token=secret')).toBe(
+ it('converts wss ACP URLs to HTTPS bases', () => {
+ expect(httpBaseFromAcpWebSocketUrl('wss://example.com/acp?token=secret')).toBe(
'https://example.com'
);
});
+ it('preserves path prefixes before the ACP endpoint', () => {
+ expect(httpBaseFromAcpWebSocketUrl('wss://example.com/goose/acp?token=secret')).toBe(
+ 'https://example.com/goose'
+ );
+ });
+
it('rejects non-WebSocket URLs', () => {
- expect(() => httpOriginFromAcpWebSocketUrl('http://127.0.0.1:64027/acp')).toThrow(
+ expect(() => httpBaseFromAcpWebSocketUrl('http://127.0.0.1:64027/acp')).toThrow(
'ACP URL must use ws: or wss:'
);
});
diff --git a/ui/desktop/src/acp/url.ts b/ui/desktop/src/acp/url.ts
index 2f035205d80f..4ab215afa328 100644
--- a/ui/desktop/src/acp/url.ts
+++ b/ui/desktop/src/acp/url.ts
@@ -1,4 +1,4 @@
-export function httpOriginFromAcpWebSocketUrl(acpUrl: string): string {
+export function httpBaseFromAcpWebSocketUrl(acpUrl: string): string {
const url = new URL(acpUrl);
if (url.protocol === 'ws:') {
@@ -9,5 +9,8 @@ export function httpOriginFromAcpWebSocketUrl(acpUrl: string): string {
throw new Error(`ACP URL must use ws: or wss:, got ${url.protocol}`);
}
- return url.origin;
+ const pathname = url.pathname.replace(/\/+$/, '');
+ const pathPrefix = pathname.endsWith('/acp') ? pathname.slice(0, -'/acp'.length) : pathname;
+
+ return `${url.origin}${pathPrefix}`;
}
diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx
index b405d00c4dff..31399979d1ae 100644
--- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx
+++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx
@@ -27,7 +27,7 @@ import type { CallToolResult, JSONRPCRequest, Tool } from '@modelcontextprotocol
import { GripHorizontal, Maximize2, PictureInPicture2, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { callMcpAppTool, readMcpAppResource } from '../../acp/mcp-apps';
-import { httpOriginFromAcpWebSocketUrl } from '../../acp/url';
+import { httpBaseFromAcpWebSocketUrl } from '../../acp/url';
import { getCachedTools } from './toolsCache';
import { AppEvents } from '../../constants/events';
import { useTheme } from '../../contexts/ThemeContext';
@@ -184,7 +184,8 @@ async function fetchMcpAppProxyUrl(csp: McpUiResourceCsp | null): Promise {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+
+ while (tempDirs.length > 0) {
+ const tempDir = tempDirs.pop();
+ if (tempDir) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ }
+ });
+
+ it('uses GOOSE_BINARY in development builds', () => {
+ const tempDir = makeTempDir();
+ const overridePath = makeFile(path.join(tempDir, 'override-goose'));
+ vi.stubEnv('GOOSE_BINARY', overridePath);
+
+ expect(findGooseBinaryPath({ isPackaged: false })).toBe(overridePath);
+ });
+
+ it('rejects GOOSE_BINARY in packaged builds', () => {
+ const tempDir = makeTempDir();
+ const resourcesPath = path.join(tempDir, 'resources');
+ const overridePath = makeFile(path.join(tempDir, 'override-goose'));
+ makeFile(path.join(resourcesPath, 'bin', binaryName));
+ vi.stubEnv('GOOSE_BINARY', overridePath);
+
+ expect(() => findGooseBinaryPath({ isPackaged: true, resourcesPath })).toThrow(
+ 'GOOSE_BINARY is only supported in development builds'
+ );
+ });
+
+ it('uses the bundled goose binary in packaged builds', () => {
+ const tempDir = makeTempDir();
+ const resourcesPath = path.join(tempDir, 'resources');
+ const bundledPath = makeFile(path.join(resourcesPath, 'bin', binaryName));
+
+ expect(findGooseBinaryPath({ isPackaged: true, resourcesPath })).toBe(bundledPath);
+ });
+});
diff --git a/ui/desktop/src/gooseServe.ts b/ui/desktop/src/gooseServe.ts
index 66d97f5821bb..7d41a14d7cba 100644
--- a/ui/desktop/src/gooseServe.ts
+++ b/ui/desktop/src/gooseServe.ts
@@ -43,8 +43,13 @@ const existingFile = (candidate: string): boolean => {
};
export const findGooseBinaryPath = (options: FindGooseBinaryOptions = {}): string => {
+ const { isPackaged = false, resourcesPath } = options;
const pathFromEnv = process.env.GOOSE_BINARY;
if (pathFromEnv) {
+ if (isPackaged) {
+ throw new Error('GOOSE_BINARY is only supported in development builds');
+ }
+
const resolvedPath = path.resolve(pathFromEnv);
if (existingFile(resolvedPath)) {
return resolvedPath;
@@ -52,7 +57,6 @@ export const findGooseBinaryPath = (options: FindGooseBinaryOptions = {}): strin
throw new Error(`Invalid GOOSE_BINARY path: ${pathFromEnv} (pwd is ${process.cwd()})`);
}
- const { isPackaged = false, resourcesPath } = options;
const binaryName = process.platform === 'win32' ? 'goose.exe' : 'goose';
const possiblePaths: string[] = [];
@@ -221,7 +225,7 @@ export const startGooseServe = async ({
let exited = false;
let spawnFailed = false;
let exitCode: number | null = null;
- let exitSignal: NodeJS.Signals | null = null;
+ let exitSignal: string | null = null;
gooseProcess.stdout?.resume();
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index cd0939dd4a1f..d3b12453f9f0 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -529,10 +529,13 @@ async function handleProtocolUrl(url: string, parsedUrl: URL) {
return;
} else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
const existingWindows = BrowserWindow.getAllWindows();
- const targetWindow =
- existingWindows.length > 0
- ? existingWindows[0]
- : await createChat(app, { dir: openDir || undefined });
+ let targetWindow: BrowserWindow | undefined = existingWindows[0];
+ if (!targetWindow) {
+ targetWindow = await createChat(app, { dir: openDir || undefined });
+ if (!targetWindow) {
+ return;
+ }
+ }
await processProtocolUrl(url, parsedUrl, targetWindow);
} else {
const existingWindows = BrowserWindow.getAllWindows();
@@ -544,7 +547,11 @@ async function handleProtocolUrl(url: string, parsedUrl: URL) {
}
targetWindow.focus();
} else {
- targetWindow = await createChat(app, { dir: openDir || undefined });
+ const newWindow = await createChat(app, { dir: openDir || undefined });
+ if (!newWindow) {
+ return;
+ }
+ targetWindow = newWindow;
}
if (targetWindow.webContents.isLoadingMainFrame()) {
@@ -645,6 +652,9 @@ app.on('open-url', async (_event, url) => {
} else {
openUrlHandledLaunch = true;
const newWindow = await createChat(app, { dir: openDir || undefined });
+ if (!newWindow) {
+ return;
+ }
pendingDeepLinks.set(newWindow.id, url);
}
}
From 4c45e8722a7eaeb5632447bdc58e6b215d630206 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Mon, 29 Jun 2026 15:23:56 +1000
Subject: [PATCH 03/34] added diagnose for start up
---
ui/desktop/src/gooseServe.ts | 141 ++++++++++++++++++++--
ui/desktop/src/main.ts | 39 ++++--
ui/desktop/src/startupDiagnostics.test.ts | 91 ++++++++++++++
ui/desktop/src/startupDiagnostics.ts | 108 +++++++++++++++++
4 files changed, 360 insertions(+), 19 deletions(-)
create mode 100644 ui/desktop/src/startupDiagnostics.test.ts
diff --git a/ui/desktop/src/gooseServe.ts b/ui/desktop/src/gooseServe.ts
index 7d41a14d7cba..8d4ab05ac182 100644
--- a/ui/desktop/src/gooseServe.ts
+++ b/ui/desktop/src/gooseServe.ts
@@ -3,6 +3,11 @@ import fs from 'node:fs';
import { createServer } from 'node:net';
import os from 'node:os';
import path from 'node:path';
+import {
+ appendTail as appendStartupTail,
+ createGooseServeStartupDiagnostics,
+ type GooseServeStartupDiagnostics,
+} from './startupDiagnostics';
export interface Logger {
info: (...args: unknown[]) => void;
@@ -24,6 +29,7 @@ export interface StartGooseServeOptions extends FindGooseBinaryOptions {
serverSecret: string;
env?: Record;
logger?: Logger;
+ diagnosticsDir?: string;
}
export interface GooseServeResult {
@@ -32,6 +38,9 @@ export interface GooseServeResult {
process: ChildProcess;
errorLog: string[];
cleanup: () => Promise;
+ startupDiagnosticsPath: string | null;
+ getStartupDiagnostics: () => GooseServeStartupDiagnostics | null;
+ recordStartupEvent: (name: string, details?: Record) => void;
}
const existingFile = (candidate: string): boolean => {
@@ -103,7 +112,7 @@ const isFatalError = (line: string): boolean => {
return fatalPatterns.some((pattern) => pattern.test(line));
};
-const appendTail = (target: string[], lines: string[], maxLines = 100): void => {
+const appendErrorTail = (target: string[], lines: string[], maxLines = 100): void => {
for (const line of lines) {
if (line.trim()) {
target.push(line);
@@ -131,24 +140,62 @@ const fetchStatus = async (statusUrl: string): Promise => {
const waitForGooseServeReady = async (
statusUrl: string,
errorLog: string[],
- shouldStopWaiting: () => boolean
+ shouldStopWaiting: () => boolean,
+ options: {
+ healthUrl: string;
+ onEvent?: (name: string, details?: Record) => void;
+ }
): Promise => {
const timeout = 30000;
const interval = 100;
const deadline = Date.now() + timeout;
+ const probeDetails = {
+ transport: 'plain-http',
+ method: 'GET',
+ path: '/status',
+ url: statusUrl,
+ statusUrl,
+ healthUrl: options.healthUrl,
+ };
+ options.onEvent?.('healthcheck_start', {
+ ...probeDetails,
+ timeoutMs: timeout,
+ intervalMs: interval,
+ });
+ let attempt = 1;
while (Date.now() < deadline) {
- if (shouldStopWaiting() || errorLog.some(isFatalError)) {
+ if (shouldStopWaiting()) {
+ options.onEvent?.('healthcheck_fatal_error', {
+ ...probeDetails,
+ attempt,
+ reason: 'process_unavailable',
+ });
+ return false;
+ }
+
+ if (errorLog.some(isFatalError)) {
+ options.onEvent?.('healthcheck_fatal_error', {
+ ...probeDetails,
+ attempt,
+ reason: 'fatal_stderr',
+ });
return false;
}
if (await fetchStatus(statusUrl)) {
+ options.onEvent?.('healthcheck_success', {
+ ...probeDetails,
+ attempt,
+ });
return true;
}
await delay(interval);
+ attempt += 1;
}
+ options.onEvent?.('healthcheck_timeout', { ...probeDetails, timeoutMs: timeout });
return false;
};
@@ -159,6 +206,30 @@ const buildAcpUrl = (port: number, token: string): string => {
return url.toString();
};
+const buildRedactedAcpUrl = (port: number): string => {
+ const url = new URL(`http://127.0.0.1:${port}/acp`);
+ url.protocol = 'ws:';
+ url.searchParams.set('token', 'REDACTED');
+ return url.toString();
+};
+
+const errorMessage = (error: unknown): string => {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ return String(error);
+};
+
+const withStartupDiagnosticsPath = (
+ message: string,
+ startupDiagnosticsPath: string | null
+): string => {
+ if (!startupDiagnosticsPath) {
+ return message;
+ }
+ return `${message} Startup diagnostics: ${startupDiagnosticsPath}`;
+};
+
const buildGooseServeEnv = (
serverSecret: string,
binaryPath: string,
@@ -198,20 +269,50 @@ export const startGooseServe = async ({
isPackaged,
resourcesPath,
logger = defaultLogger,
+ diagnosticsDir,
}: StartGooseServeOptions): Promise => {
const workingDir = dir || process.cwd();
+ const startupTrace = createGooseServeStartupDiagnostics(diagnosticsDir, workingDir);
+ const startupDiagnosticsPath = startupTrace?.diagnosticsPath ?? null;
const secretKey = serverSecret.trim();
if (!secretKey) {
- throw new Error('GOOSE_SERVER__SECRET_KEY is required for goose serve');
+ const message = 'GOOSE_SERVER__SECRET_KEY is required for goose serve';
+ startupTrace?.record('configuration_error', { message });
+ throw new Error(withStartupDiagnosticsPath(message, startupDiagnosticsPath));
+ }
+
+ let goosePath: string;
+ try {
+ goosePath = findGooseBinaryPath({ isPackaged, resourcesPath });
+ } catch (error) {
+ const message = errorMessage(error);
+ startupTrace?.record('binary_resolve_error', { message });
+ throw new Error(withStartupDiagnosticsPath(message, startupDiagnosticsPath));
}
- const goosePath = findGooseBinaryPath({ isPackaged, resourcesPath });
const port = await findAvailablePort();
- const statusUrl = `http://127.0.0.1:${port}/status`;
+ const httpBaseUrl = `http://127.0.0.1:${port}`;
+ const statusUrl = `${httpBaseUrl}/status`;
+ const healthUrl = `${httpBaseUrl}/health`;
const acpUrl = buildAcpUrl(port, secretKey);
+ const redactedAcpUrl = buildRedactedAcpUrl(port);
const errorLog: string[] = [];
logger.info(`Starting goose serve from: ${goosePath} on port ${port} in dir ${workingDir}`);
+ if (startupTrace) {
+ startupTrace.diagnostics.binaryPath = goosePath;
+ startupTrace.diagnostics.httpBaseUrl = httpBaseUrl;
+ startupTrace.diagnostics.readinessUrl = statusUrl;
+ startupTrace.diagnostics.statusUrl = statusUrl;
+ startupTrace.diagnostics.healthUrl = healthUrl;
+ startupTrace.diagnostics.acpUrl = redactedAcpUrl;
+ startupTrace.record('spawn_start', {
+ binaryPath: goosePath,
+ port,
+ workingDir,
+ args: ['serve', '--host', '127.0.0.1', '--port', String(port)],
+ });
+ }
const gooseProcess = spawn(goosePath, ['serve', '--host', '127.0.0.1', '--port', String(port)], {
env: buildGooseServeEnv(secretKey, goosePath, additionalEnv),
@@ -221,6 +322,10 @@ export const startGooseServe = async ({
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
+ if (startupTrace) {
+ startupTrace.diagnostics.pid = gooseProcess.pid ?? null;
+ startupTrace.record('spawn_success', { pid: gooseProcess.pid ?? null });
+ }
let exited = false;
let spawnFailed = false;
@@ -231,7 +336,10 @@ export const startGooseServe = async ({
const onStderrData = (data: Buffer) => {
const lines = data.toString().split('\n');
- appendTail(errorLog, lines);
+ appendErrorTail(errorLog, lines);
+ if (startupTrace) {
+ appendStartupTail(startupTrace.diagnostics.stderrTail, lines);
+ }
for (const line of lines) {
if (line.trim() && isFatalError(line)) {
logger.error(`goose serve stderr for port ${port} and dir ${workingDir}: ${line}`);
@@ -248,12 +356,18 @@ export const startGooseServe = async ({
logger.info(
`goose serve process exited with code ${code} and signal ${signal} for port ${port} and dir ${workingDir}`
);
+ if (startupTrace) {
+ startupTrace.diagnostics.childExitCode = code;
+ startupTrace.diagnostics.childExitSignal = signal;
+ startupTrace.record('child_exit', { code, signal });
+ }
});
gooseProcess.on('error', (error) => {
spawnFailed = true;
errorLog.push(error.message);
logger.error(`Failed to start goose serve on port ${port} and dir ${workingDir}`, error);
+ startupTrace?.record('spawn_error', { message: error.message, name: error.name });
});
const cleanup = async (): Promise => {
@@ -295,7 +409,10 @@ export const startGooseServe = async ({
});
};
- const ready = await waitForGooseServeReady(statusUrl, errorLog, () => exited || spawnFailed);
+ const ready = await waitForGooseServeReady(statusUrl, errorLog, () => exited || spawnFailed, {
+ healthUrl,
+ onEvent: startupTrace?.record,
+ });
gooseProcess.stderr?.off('data', onStderrData);
gooseProcess.stderr?.resume();
@@ -306,7 +423,10 @@ export const startGooseServe = async ({
: '';
const stderrDetails = errorLog.length ? ` Stderr: ${errorLog.join('\n')}` : '';
throw new Error(
- `goose serve did not become ready on ${statusUrl}.${exitDetails}${stderrDetails}`
+ withStartupDiagnosticsPath(
+ `goose serve did not become ready on ${statusUrl}.${exitDetails}${stderrDetails}`,
+ startupDiagnosticsPath
+ )
);
}
@@ -316,5 +436,8 @@ export const startGooseServe = async ({
process: gooseProcess,
errorLog,
cleanup,
+ startupDiagnosticsPath,
+ getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
+ recordStartupEvent: (name, details) => startupTrace?.record(name, details),
};
};
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index d3b12453f9f0..26667f817958 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -960,16 +960,35 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
trustedExternalHostname = null;
pinnedCertFingerprint = null;
- const gooseServeResult = await startGooseServe({
- serverSecret,
- dir: workingDir,
- env: {
- GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
- },
- isPackaged: app.isPackaged,
- resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
- logger: log,
- });
+ let gooseServeResult: Awaited>;
+ try {
+ gooseServeResult = await startGooseServe({
+ serverSecret,
+ dir: workingDir,
+ env: {
+ GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
+ },
+ isPackaged: app.isPackaged,
+ resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
+ logger: log,
+ diagnosticsDir: STARTUP_LOGS_DIR,
+ });
+ } catch (error) {
+ log.error('goose serve failed to start', error);
+ dialog.showMessageBoxSync({
+ type: 'error',
+ title: 'Goose Failed to Start',
+ message: 'The backend server failed to start.',
+ detail: [
+ 'Backend: goose serve',
+ 'Readiness check: plain GET /status',
+ `Startup error:\n${errorMessage(error)}`,
+ ].join('\n\n'),
+ buttons: ['OK'],
+ });
+ app.quit();
+ return;
+ }
workingDir = gooseServeResult.workingDir;
gooseServeLease = {
diff --git a/ui/desktop/src/startupDiagnostics.test.ts b/ui/desktop/src/startupDiagnostics.test.ts
new file mode 100644
index 000000000000..87badc8d8df9
--- /dev/null
+++ b/ui/desktop/src/startupDiagnostics.test.ts
@@ -0,0 +1,91 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { createGooseServeStartupDiagnostics, createStartupDiagnostics } from './startupDiagnostics';
+
+const tempDirs: string[] = [];
+
+function makeTempDir(): string {
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'startup-diagnostics-test-'));
+ tempDirs.push(tempDir);
+ return tempDir;
+}
+
+describe('startup diagnostics', () => {
+ afterEach(() => {
+ while (tempDirs.length > 0) {
+ const tempDir = tempDirs.pop();
+ if (tempDir) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ }
+ });
+
+ it('keeps goosed startup diagnostics shape and file prefix', () => {
+ const diagnosticsDir = makeTempDir();
+ const trace = createStartupDiagnostics(diagnosticsDir, '/tmp/project');
+ const expectedKeys = [
+ 'attemptId',
+ 'startedAt',
+ 'goosedPath',
+ 'workingDir',
+ 'baseUrl',
+ 'pid',
+ 'certFingerprintSeen',
+ 'healthCheckSucceeded',
+ 'childExitCode',
+ 'childExitSignal',
+ 'stderrTail',
+ 'events',
+ ];
+
+ expect(trace).not.toBeNull();
+ expect(path.basename(trace!.diagnosticsPath)).toMatch(/^goosed-startup-.*\.json$/);
+ expect(Object.keys(trace!.diagnostics)).toEqual(expectedKeys);
+ expect(trace!.diagnostics).toMatchObject({
+ goosedPath: null,
+ baseUrl: null,
+ certFingerprintSeen: false,
+ });
+ const saved = JSON.parse(fs.readFileSync(trace!.diagnosticsPath, 'utf8'));
+ expect(Object.keys(saved)).toEqual(expectedKeys);
+ });
+
+ it('writes serve startup diagnostics with serve-specific fields', () => {
+ const diagnosticsDir = makeTempDir();
+ const trace = createGooseServeStartupDiagnostics(diagnosticsDir, '/tmp/project');
+
+ expect(trace).not.toBeNull();
+ trace!.diagnostics.binaryPath = '/bin/goose';
+ trace!.diagnostics.httpBaseUrl = 'http://127.0.0.1:3000';
+ trace!.diagnostics.readinessUrl = 'http://127.0.0.1:3000/status';
+ trace!.diagnostics.statusUrl = 'http://127.0.0.1:3000/status';
+ trace!.diagnostics.healthUrl = 'http://127.0.0.1:3000/health';
+ trace!.diagnostics.acpUrl = 'ws://127.0.0.1:3000/acp?token=REDACTED';
+ trace!.record('healthcheck_start', {
+ transport: 'plain-http',
+ method: 'GET',
+ path: '/status',
+ });
+ trace!.record('healthcheck_success', { attempt: 1 });
+
+ expect(path.basename(trace!.diagnosticsPath)).toMatch(/^goose-serve-startup-.*\.json$/);
+ const saved = JSON.parse(fs.readFileSync(trace!.diagnosticsPath, 'utf8'));
+ expect(saved).toMatchObject({
+ binaryPath: '/bin/goose',
+ httpBaseUrl: 'http://127.0.0.1:3000',
+ readinessUrl: 'http://127.0.0.1:3000/status',
+ statusUrl: 'http://127.0.0.1:3000/status',
+ healthUrl: 'http://127.0.0.1:3000/health',
+ acpUrl: 'ws://127.0.0.1:3000/acp?token=REDACTED',
+ healthCheckSucceeded: true,
+ });
+ expect(saved).not.toHaveProperty('goosedPath');
+ expect(saved).not.toHaveProperty('certFingerprintSeen');
+ expect(saved.events.map((event: { name: string }) => event.name)).toEqual([
+ 'healthcheck_start',
+ 'healthcheck_success',
+ ]);
+ });
+});
diff --git a/ui/desktop/src/startupDiagnostics.ts b/ui/desktop/src/startupDiagnostics.ts
index 3c797b2ae006..2e15b25b6c38 100644
--- a/ui/desktop/src/startupDiagnostics.ts
+++ b/ui/desktop/src/startupDiagnostics.ts
@@ -23,6 +23,24 @@ export interface StartupDiagnostics {
events: StartupTraceEvent[];
}
+export interface GooseServeStartupDiagnostics {
+ attemptId: string;
+ startedAt: string;
+ binaryPath: string | null;
+ workingDir: string;
+ httpBaseUrl: string | null;
+ readinessUrl: string | null;
+ statusUrl: string | null;
+ healthUrl: string | null;
+ acpUrl: string | null;
+ pid: number | null;
+ healthCheckSucceeded: boolean;
+ childExitCode: number | null;
+ childExitSignal: string | null;
+ stderrTail: string[];
+ events: StartupTraceEvent[];
+}
+
export interface StartupTrace {
diagnosticsPath: string;
diagnostics: StartupDiagnostics;
@@ -30,6 +48,13 @@ export interface StartupTrace {
flush: () => void;
}
+export interface GooseServeStartupTrace {
+ diagnosticsPath: string;
+ diagnostics: GooseServeStartupDiagnostics;
+ record: (name: string, details?: Record) => void;
+ flush: () => void;
+}
+
const STARTUP_TAIL_LIMIT = 80;
const STARTUP_LOGS_TO_KEEP = 20;
@@ -61,6 +86,29 @@ const cleanupStartupDiagnostics = (diagnosticsDir: string) => {
}
};
+const cleanupGooseServeStartupDiagnostics = (diagnosticsDir: string) => {
+ const startupLogs = fs
+ .readdirSync(diagnosticsDir, { withFileTypes: true })
+ .filter(
+ (entry) =>
+ entry.isFile() &&
+ entry.name.startsWith('goose-serve-startup-') &&
+ entry.name.endsWith('.json')
+ )
+ .map((entry) => {
+ const filePath = path.join(diagnosticsDir, entry.name);
+ return {
+ filePath,
+ modifiedMs: fs.statSync(filePath).mtimeMs,
+ };
+ })
+ .sort((a, b) => b.modifiedMs - a.modifiedMs);
+
+ for (const startupLog of startupLogs.slice(STARTUP_LOGS_TO_KEEP)) {
+ fs.unlinkSync(startupLog.filePath);
+ }
+};
+
export const createStartupDiagnostics = (
diagnosticsDir: string | undefined,
workingDir: string
@@ -117,3 +165,63 @@ export const createStartupDiagnostics = (
flush,
};
};
+
+export const createGooseServeStartupDiagnostics = (
+ diagnosticsDir: string | undefined,
+ workingDir: string
+): GooseServeStartupTrace | null => {
+ if (!diagnosticsDir) {
+ return null;
+ }
+
+ fs.mkdirSync(diagnosticsDir, { recursive: true });
+ cleanupGooseServeStartupDiagnostics(diagnosticsDir);
+ const startedAt = new Date();
+ const attemptId = `goose-serve-startup-${startedAt.toISOString().replace(/:/g, '-')}-${process.pid}.json`;
+ const diagnosticsPath = path.join(diagnosticsDir, attemptId);
+ const monotonicStart = Date.now();
+
+ const diagnostics: GooseServeStartupDiagnostics = {
+ attemptId,
+ startedAt: startedAt.toISOString(),
+ binaryPath: null,
+ workingDir,
+ httpBaseUrl: null,
+ readinessUrl: null,
+ statusUrl: null,
+ healthUrl: null,
+ acpUrl: null,
+ pid: null,
+ healthCheckSucceeded: false,
+ childExitCode: null,
+ childExitSignal: null,
+ stderrTail: [],
+ events: [],
+ };
+
+ const flush = () => {
+ fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}\n`);
+ };
+
+ const record = (name: string, details?: Record) => {
+ if (name === 'healthcheck_success') {
+ diagnostics.healthCheckSucceeded = true;
+ }
+ diagnostics.events.push({
+ name,
+ at: new Date().toISOString(),
+ elapsedMs: Date.now() - monotonicStart,
+ ...(details ? { details } : {}),
+ });
+ flush();
+ };
+
+ flush();
+
+ return {
+ diagnosticsPath,
+ diagnostics,
+ record,
+ flush,
+ };
+};
From fc4bd78d759fa64a57e8f3dedb01af52fb2cfab4 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Mon, 29 Jun 2026 16:54:32 +1000
Subject: [PATCH 04/34] added just run-ui-acp
---
Justfile | 10 ++++++++++
ui/desktop/package.json | 3 +++
ui/desktop/src/gooseServe.test.ts | 15 +++++++++++++++
ui/desktop/src/gooseServe.ts | 12 ++++++------
4 files changed, 34 insertions(+), 6 deletions(-)
diff --git a/Justfile b/Justfile
index 56f0fe330279..d732444a9286 100644
--- a/Justfile
+++ b/Justfile
@@ -115,6 +115,16 @@ run-ui-only:
@echo "Running UI..."
cd ui/desktop && pnpm install && pnpm run start-gui
+run-ui-acp:
+ @echo "Building goose CLI for direct ACP..."
+ cargo build -p goose-cli --bin goose
+ @echo "Running UI with direct ACP backend..."
+ cd ui/desktop && pnpm install && pnpm run start-gui:acp
+
+run-ui-acp-only:
+ @echo "Running UI with direct ACP backend..."
+ cd ui/desktop && pnpm install && pnpm run start-gui:acp
+
debug-ui:
@echo "🚀 Starting goose frontend in external backend mode"
cd ui/desktop && \
diff --git a/ui/desktop/package.json b/ui/desktop/package.json
index 307ab8b72e15..ee9709957d95 100644
--- a/ui/desktop/package.json
+++ b/ui/desktop/package.json
@@ -12,8 +12,11 @@
"postinstall": "pnpm --filter @aaif/goose-sdk run build",
"typecheck": "tsc --noEmit",
"generate-api": "openapi-ts",
+ "build-goose-sdk": "pnpm --filter @aaif/goose-sdk run build",
"start-gui": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start",
+ "start-gui:acp": "pnpm run build-goose-sdk && GOOSE_BACKEND_ACP_ONLY=true pnpm run start-gui",
"start-gui-debug": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start -- --inspect=9229",
+ "start-gui-debug:acp": "pnpm run build-goose-sdk && GOOSE_BACKEND_ACP_ONLY=true pnpm run start-gui-debug",
"start": "cd ../.. && just run-ui",
"start:test-error": "GOOSE_TEST_ERROR=true electron-forge start",
"package": "pnpm run i18n:compile && electron-forge package",
diff --git a/ui/desktop/src/gooseServe.test.ts b/ui/desktop/src/gooseServe.test.ts
index c9a6e267aa3a..ad1313dc9b45 100644
--- a/ui/desktop/src/gooseServe.test.ts
+++ b/ui/desktop/src/gooseServe.test.ts
@@ -6,6 +6,7 @@ import { findGooseBinaryPath } from './gooseServe';
const binaryName = process.platform === 'win32' ? 'goose.exe' : 'goose';
const tempDirs: string[] = [];
+const originalCwd = process.cwd();
function makeTempDir(): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'goose-serve-test-'));
@@ -23,6 +24,7 @@ function makeFile(filePath: string): string {
describe('findGooseBinaryPath', () => {
afterEach(() => {
vi.unstubAllEnvs();
+ process.chdir(originalCwd);
while (tempDirs.length > 0) {
const tempDir = tempDirs.pop();
@@ -52,6 +54,19 @@ describe('findGooseBinaryPath', () => {
);
});
+ it('prefers the debug target over the staged binary in development builds', () => {
+ const tempDir = makeTempDir();
+ const desktopDir = path.join(tempDir, 'ui', 'desktop');
+ const stagedPath = makeFile(path.join(desktopDir, 'src', 'bin', binaryName));
+ const debugPath = makeFile(path.join(tempDir, 'target', 'debug', binaryName));
+ makeFile(path.join(tempDir, 'target', 'release', binaryName));
+ process.chdir(desktopDir);
+
+ const resolvedPath = findGooseBinaryPath({ isPackaged: false });
+ expect(fs.realpathSync(resolvedPath)).toBe(fs.realpathSync(debugPath));
+ expect(fs.realpathSync(resolvedPath)).not.toBe(fs.realpathSync(stagedPath));
+ });
+
it('uses the bundled goose binary in packaged builds', () => {
const tempDir = makeTempDir();
const resourcesPath = path.join(tempDir, 'resources');
diff --git a/ui/desktop/src/gooseServe.ts b/ui/desktop/src/gooseServe.ts
index 8d4ab05ac182..b0f11118021c 100644
--- a/ui/desktop/src/gooseServe.ts
+++ b/ui/desktop/src/gooseServe.ts
@@ -72,14 +72,14 @@ export const findGooseBinaryPath = (options: FindGooseBinaryOptions = {}): strin
if (isPackaged && resourcesPath) {
possiblePaths.push(path.join(resourcesPath, 'bin', binaryName));
possiblePaths.push(path.join(resourcesPath, binaryName));
+ } else {
+ possiblePaths.push(
+ path.join(process.cwd(), '..', '..', 'target', 'debug', binaryName),
+ path.join(process.cwd(), '..', '..', 'target', 'release', binaryName),
+ path.join(process.cwd(), 'src', 'bin', binaryName)
+ );
}
- 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 candidate of possiblePaths) {
if (existingFile(candidate)) {
return candidate;
From e318b21b9b3fd3346d515dfcbb0e22c531a9152b Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Mon, 29 Jun 2026 18:50:37 +1000
Subject: [PATCH 05/34] added env to bundle ui with acp only
---
.github/workflows/bundle-desktop-intel.yml | 31 +++++++-
.github/workflows/bundle-desktop-linux.yml | 36 +++++++--
.github/workflows/bundle-desktop-manual.yml | 10 +++
.github/workflows/bundle-desktop-windows.yml | 74 +++++++++++++++----
.github/workflows/bundle-desktop.yml | 35 ++++++++-
ui/desktop/package.json | 4 +-
.../src/components/MCPUIResourceRenderer.tsx | 2 +-
.../src/components/McpApps/McpAppRenderer.tsx | 2 +-
ui/desktop/src/main.ts | 6 +-
ui/desktop/src/renderer.tsx | 2 +-
ui/desktop/vite.main.config.mts | 3 +
11 files changed, 169 insertions(+), 36 deletions(-)
diff --git a/.github/workflows/bundle-desktop-intel.yml b/.github/workflows/bundle-desktop-intel.yml
index 54d76434edc3..458046e695a1 100644
--- a/.github/workflows/bundle-desktop-intel.yml
+++ b/.github/workflows/bundle-desktop-intel.yml
@@ -30,6 +30,11 @@ on:
required: false
type: string
default: ''
+ backend:
+ description: 'Desktop backend to bundle: goosed or acp'
+ required: false
+ default: 'goosed'
+ type: string
name: Reusable workflow to bundle desktop app for Intel Mac
@@ -73,11 +78,17 @@ jobs:
key: intel-macos-deployment-target-12
- - name: Build goose-server for Intel macOS (x86_64)
+ - name: Build desktop backend for Intel macOS (x86_64)
+ env:
+ DESKTOP_BACKEND: ${{ inputs.backend }}
run: |
source ./bin/activate-hermit
rustup target add x86_64-apple-darwin
- cargo build --release -p goose-server --target x86_64-apple-darwin
+ if [ "$DESKTOP_BACKEND" = "acp" ]; then
+ cargo build --release -p goose-cli --bin goose --target x86_64-apple-darwin
+ else
+ cargo build --release -p goose-server --bin goosed --target x86_64-apple-darwin
+ fi
@@ -95,9 +106,20 @@ jobs:
# Check disk space after cleanup
df -h
- - name: Copy binaries into Electron folder
+ - name: Copy backend binary into Electron folder
+ env:
+ DESKTOP_BACKEND: ${{ inputs.backend }}
run: |
- cp target/x86_64-apple-darwin/release/goosed ui/desktop/src/bin/goosed
+ mkdir -p ui/desktop/src/bin
+ rm -f ui/desktop/src/bin/goosed ui/desktop/src/bin/goose
+ if [ "$DESKTOP_BACKEND" = "acp" ]; then
+ cp target/x86_64-apple-darwin/release/goose ui/desktop/src/bin/goose
+ chmod +x ui/desktop/src/bin/goose
+ else
+ cp target/x86_64-apple-darwin/release/goosed ui/desktop/src/bin/goosed
+ chmod +x ui/desktop/src/bin/goosed
+ fi
+ ls -la ui/desktop/src/bin/
- name: Cache pnpm dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
@@ -133,6 +155,7 @@ jobs:
- name: Build App
env:
+ GOOSE_DESKTOP_BACKEND: ${{ inputs.backend }}
APPLE_ID: ${{ inputs.signing && secrets.APPLE_ID || '' }}
APPLE_ID_PASSWORD: ${{ inputs.signing && secrets.APPLE_ID_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ inputs.signing && secrets.APPLE_TEAM_ID || '' }}
diff --git a/.github/workflows/bundle-desktop-linux.yml b/.github/workflows/bundle-desktop-linux.yml
index dd1a88fcc998..e9a47ff8954c 100644
--- a/.github/workflows/bundle-desktop-linux.yml
+++ b/.github/workflows/bundle-desktop-linux.yml
@@ -10,6 +10,11 @@ on:
description: 'Branch name to bundle app from'
required: true
type: string
+ backend:
+ description: 'Desktop backend to bundle: goosed or acp'
+ required: false
+ default: 'goosed'
+ type: string
workflow_call:
inputs:
version:
@@ -21,6 +26,11 @@ on:
type: string
required: false
default: ''
+ backend:
+ description: 'Desktop backend to bundle: goosed or acp'
+ required: false
+ default: 'goosed'
+ type: string
name: "Bundle Desktop (Linux)"
@@ -124,8 +134,9 @@ jobs:
with:
key: linux-${{ matrix.build-on }}-${{ matrix.variant }}
- - name: Build goosed binary
+ - name: Build desktop backend binary
env:
+ DESKTOP_BACKEND: ${{ inputs.backend }}
RUST_LOG: debug
RUST_BACKTRACE: 1
run: |
@@ -137,15 +148,27 @@ jobs:
FEATURE_ARGS=(--features vulkan)
fi
- cargo build --release --target ${TARGET} -p goose-server "${FEATURE_ARGS[@]}"
+ if [ "$DESKTOP_BACKEND" = "acp" ]; then
+ cargo build --release --target ${TARGET} -p goose-cli --bin goose "${FEATURE_ARGS[@]}"
+ else
+ cargo build --release --target ${TARGET} -p goose-server "${FEATURE_ARGS[@]}"
+ fi
- - name: Copy binaries into Electron folder
+ - name: Copy backend binary into Electron folder
+ env:
+ DESKTOP_BACKEND: ${{ inputs.backend }}
run: |
- echo "Copying binaries to ui/desktop/src/bin/"
+ echo "Copying backend binary to ui/desktop/src/bin/"
export TARGET="x86_64-unknown-linux-gnu"
mkdir -p ui/desktop/src/bin
- cp target/$TARGET/release/goosed ui/desktop/src/bin/
- chmod +x ui/desktop/src/bin/goosed
+ rm -f ui/desktop/src/bin/goosed ui/desktop/src/bin/goose
+ if [ "$DESKTOP_BACKEND" = "acp" ]; then
+ cp target/$TARGET/release/goose ui/desktop/src/bin/
+ chmod +x ui/desktop/src/bin/goose
+ else
+ cp target/$TARGET/release/goosed ui/desktop/src/bin/
+ chmod +x ui/desktop/src/bin/goosed
+ fi
ls -la ui/desktop/src/bin/
- name: Cache pnpm dependencies
@@ -168,6 +191,7 @@ jobs:
- name: Build Linux packages
env:
+ GOOSE_DESKTOP_BACKEND: ${{ inputs.backend }}
GOOSE_DESKTOP_LINUX_VARIANT: ${{ matrix.variant }}
run: |
source ./bin/activate-hermit
diff --git a/.github/workflows/bundle-desktop-manual.yml b/.github/workflows/bundle-desktop-manual.yml
index 84f6d962bae7..5c3d7d9bc682 100644
--- a/.github/workflows/bundle-desktop-manual.yml
+++ b/.github/workflows/bundle-desktop-manual.yml
@@ -7,6 +7,14 @@ on:
description: 'Branch name to bundle app from'
required: true
type: string
+ backend:
+ description: 'Desktop backend to bundle'
+ required: false
+ type: choice
+ default: goosed
+ options:
+ - goosed
+ - acp
jobs:
bundle-desktop-unsigned:
@@ -17,6 +25,7 @@ jobs:
with:
signing: false
ref: ${{ inputs.branch }}
+ backend: ${{ inputs.backend }}
bundle-desktop-intel-unsigned:
uses: ./.github/workflows/bundle-desktop-intel.yml
@@ -26,3 +35,4 @@ jobs:
with:
signing: false
ref: ${{ inputs.branch }}
+ backend: ${{ inputs.backend }}
diff --git a/.github/workflows/bundle-desktop-windows.yml b/.github/workflows/bundle-desktop-windows.yml
index f18af1025d4b..3661f3256841 100644
--- a/.github/workflows/bundle-desktop-windows.yml
+++ b/.github/workflows/bundle-desktop-windows.yml
@@ -13,6 +13,11 @@ on:
required: false
type: string
default: 'standard'
+ backend:
+ description: 'Desktop backend to bundle: goosed or acp'
+ required: false
+ type: string
+ default: 'goosed'
workflow_call:
inputs:
version:
@@ -34,6 +39,11 @@ on:
required: false
type: string
default: 'standard'
+ backend:
+ description: 'Desktop backend to bundle: goosed or acp'
+ required: false
+ type: string
+ default: 'goosed'
# Permissions required for OIDC authentication with Azure Trusted Signing
permissions:
@@ -113,28 +123,49 @@ jobs:
env:
CUDA_COMPUTE_CAP: ${{ inputs.windows_variant == 'cuda' && '80' || '' }}
run: |
- Write-Output "Building Windows executable..."
- if ("${{ inputs.windows_variant }}" -eq "cuda") {
- cargo build --release --target x86_64-pc-windows-msvc -p goose-server --features cuda
+ $backend = "${{ inputs.backend }}"
+ $isCuda = "${{ inputs.windows_variant }}" -eq "cuda"
+
+ Write-Output "Building Windows backend: $backend"
+ if ($backend -eq "acp") {
+ if ($isCuda) {
+ cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose --features cuda
+ } else {
+ cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose
+ }
+ $binaryPath = "./target/x86_64-pc-windows-msvc/release/goose.exe"
} else {
- cargo build --release --target x86_64-pc-windows-msvc -p goose-server
+ if ($isCuda) {
+ cargo build --release --target x86_64-pc-windows-msvc -p goose-server --features cuda
+ } else {
+ cargo build --release --target x86_64-pc-windows-msvc -p goose-server
+ }
+ $binaryPath = "./target/x86_64-pc-windows-msvc/release/goosed.exe"
}
# Verify build succeeded
- if (-not (Test-Path "./target/x86_64-pc-windows-msvc/release/goosed.exe")) {
- Write-Error "Windows binary not found."
+ if (-not (Test-Path $binaryPath)) {
+ Write-Error "Windows backend binary not found: $binaryPath"
Get-ChildItem ./target/x86_64-pc-windows-msvc/release/ -ErrorAction SilentlyContinue
exit 1
}
- Write-Output "Windows binary found."
- Get-Item ./target/x86_64-pc-windows-msvc/release/goosed.exe
+ Write-Output "Windows backend binary found."
+ Get-Item $binaryPath
- name: Prepare Windows binary
shell: bash
+ env:
+ DESKTOP_BACKEND: ${{ inputs.backend }}
run: |
- if [ ! -f "./target/x86_64-pc-windows-msvc/release/goosed.exe" ]; then
- echo "Windows binary not found."
+ if [ "$DESKTOP_BACKEND" = "acp" ]; then
+ BACKEND_BINARY="./target/x86_64-pc-windows-msvc/release/goose.exe"
+ else
+ BACKEND_BINARY="./target/x86_64-pc-windows-msvc/release/goosed.exe"
+ fi
+
+ if [ ! -f "$BACKEND_BINARY" ]; then
+ echo "Windows backend binary not found: $BACKEND_BINARY"
exit 1
fi
@@ -142,13 +173,14 @@ jobs:
rm -rf ./ui/desktop/src/bin
mkdir -p ./ui/desktop/src/bin
- echo "Copying Windows binary..."
- cp -f ./target/x86_64-pc-windows-msvc/release/goosed.exe ./ui/desktop/src/bin/
+ echo "Copying Windows backend binary..."
+ cp -f "$BACKEND_BINARY" ./ui/desktop/src/bin/
if [ -d "./ui/desktop/src/platform/windows/bin" ]; then
echo "Copying Windows platform files..."
for file in ./ui/desktop/src/platform/windows/bin/*.{exe,dll,cmd}; do
- if [ -f "$file" ] && [ "$(basename "$file")" != "goosed.exe" ]; then
+ filename="$(basename "$file")"
+ if [ -f "$file" ] && [ "$filename" != "goosed.exe" ] && [ "$filename" != "goose.exe" ]; then
cp -f "$file" ./ui/desktop/src/bin/
fi
done
@@ -171,6 +203,7 @@ jobs:
shell: bash
env:
ELECTRON_PLATFORM: win32
+ GOOSE_DESKTOP_BACKEND: ${{ inputs.backend }}
run: |
cd ui/desktop
@@ -222,6 +255,7 @@ jobs:
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign Windows executables with Azure Trusted Signing
+ if: ${{ inputs.backend != 'acp' }}
uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
with:
endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
@@ -231,12 +265,24 @@ jobs:
${{ github.workspace }}/dist-windows/Goose.exe
${{ github.workspace }}/dist-windows/resources/bin/goosed.exe
+ - name: Sign Windows executables with Azure Trusted Signing (ACP)
+ if: ${{ inputs.backend == 'acp' }}
+ uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
+ with:
+ endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
+ trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT_NAME }}
+ certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }}
+ files: |
+ ${{ github.workspace }}/dist-windows/Goose.exe
+ ${{ github.workspace }}/dist-windows/resources/bin/goose.exe
+
- name: Verify signed executables
shell: pwsh
run: |
+ $backendExe = if ("${{ inputs.backend }}" -eq "acp") { "goose.exe" } else { "goosed.exe" }
$files = @(
"dist-windows/Goose.exe",
- "dist-windows/resources/bin/goosed.exe"
+ "dist-windows/resources/bin/$backendExe"
)
foreach ($file in $files) {
Write-Output "Verifying signature: $file"
diff --git a/.github/workflows/bundle-desktop.yml b/.github/workflows/bundle-desktop.yml
index 4b0073198d8d..df3e088349e4 100644
--- a/.github/workflows/bundle-desktop.yml
+++ b/.github/workflows/bundle-desktop.yml
@@ -32,6 +32,11 @@ on:
required: false
type: string
default: ''
+ backend:
+ description: 'Desktop backend to bundle: goosed or acp'
+ required: false
+ default: 'goosed'
+ type: string
name: Reusable workflow to bundle desktop app
@@ -59,6 +64,7 @@ jobs:
INPUT_VERSION: ${{ inputs.version }}
INPUT_SIGNING: ${{ inputs.signing }}
INPUT_QUICK_TEST: ${{ inputs.quick_test }}
+ INPUT_BACKEND: ${{ inputs.backend }}
run: |
echo "=== Workflow Information ==="
echo "Workflow: ${WORKFLOW_NAME}"
@@ -71,6 +77,7 @@ jobs:
echo "Version: ${INPUT_VERSION:-not set}"
echo "Signing: ${INPUT_SIGNING:-false}"
echo "Quick test: ${INPUT_QUICK_TEST:-true}"
+ echo "Backend: ${INPUT_BACKEND:-goosed}"
# Check initial disk space
- name: Check initial disk space
@@ -118,8 +125,16 @@ jobs:
key: macos-deployment-target-12
# Build the project
- - name: Build goosed
- run: source ./bin/activate-hermit && cargo build --release -p goose-server
+ - name: Build desktop backend
+ env:
+ DESKTOP_BACKEND: ${{ inputs.backend }}
+ run: |
+ source ./bin/activate-hermit
+ if [ "$DESKTOP_BACKEND" = "acp" ]; then
+ cargo build --release -p goose-cli --bin goose
+ else
+ cargo build --release -p goose-server --bin goosed
+ fi
# Post-build cleanup to free space
- name: Post-build cleanup
@@ -134,9 +149,20 @@ jobs:
# Check disk space after cleanup
df -h
- - name: Copy binaries into Electron folder
+ - name: Copy backend binary into Electron folder
+ env:
+ DESKTOP_BACKEND: ${{ inputs.backend }}
run: |
- cp target/release/goosed ui/desktop/src/bin/goosed
+ mkdir -p ui/desktop/src/bin
+ rm -f ui/desktop/src/bin/goosed ui/desktop/src/bin/goose
+ if [ "$DESKTOP_BACKEND" = "acp" ]; then
+ cp target/release/goose ui/desktop/src/bin/goose
+ chmod +x ui/desktop/src/bin/goose
+ else
+ cp target/release/goosed ui/desktop/src/bin/goosed
+ chmod +x ui/desktop/src/bin/goosed
+ fi
+ ls -la ui/desktop/src/bin/
- name: Cache pnpm dependencies
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
@@ -165,6 +191,7 @@ jobs:
- name: Build App
env:
+ GOOSE_DESKTOP_BACKEND: ${{ inputs.backend }}
APPLE_ID: ${{ inputs.signing && secrets.APPLE_ID || '' }}
APPLE_ID_PASSWORD: ${{ inputs.signing && secrets.APPLE_ID_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ inputs.signing && secrets.APPLE_TEAM_ID || '' }}
diff --git a/ui/desktop/package.json b/ui/desktop/package.json
index ee9709957d95..d74850d93ac6 100644
--- a/ui/desktop/package.json
+++ b/ui/desktop/package.json
@@ -14,9 +14,9 @@
"generate-api": "openapi-ts",
"build-goose-sdk": "pnpm --filter @aaif/goose-sdk run build",
"start-gui": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start",
- "start-gui:acp": "pnpm run build-goose-sdk && GOOSE_BACKEND_ACP_ONLY=true pnpm run start-gui",
+ "start-gui:acp": "pnpm run build-goose-sdk && GOOSE_DESKTOP_BACKEND=acp pnpm run start-gui",
"start-gui-debug": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start -- --inspect=9229",
- "start-gui-debug:acp": "pnpm run build-goose-sdk && GOOSE_BACKEND_ACP_ONLY=true pnpm run start-gui-debug",
+ "start-gui-debug:acp": "pnpm run build-goose-sdk && GOOSE_DESKTOP_BACKEND=acp pnpm run start-gui-debug",
"start": "cd ../.. && just run-ui",
"start:test-error": "GOOSE_TEST_ERROR=true electron-forge start",
"package": "pnpm run i18n:compile && electron-forge package",
diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx
index 523905daa6df..bc05e79ddfad 100644
--- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx
+++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx
@@ -138,7 +138,7 @@ export default function MCPUIResourceRenderer({
const intl = useIntl();
const { resolvedTheme } = useTheme();
const [proxyUrl, setProxyUrl] = useState(undefined);
- const backendAcpOnly = window.appConfig.get('GOOSE_BACKEND_ACP_ONLY') === true;
+ const backendAcpOnly = window.appConfig.get('GOOSE_DESKTOP_BACKEND') === 'acp';
useEffect(() => {
if (backendAcpOnly) {
diff --git a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx
index 31399979d1ae..8609466cc536 100644
--- a/ui/desktop/src/components/McpApps/McpAppRenderer.tsx
+++ b/ui/desktop/src/components/McpApps/McpAppRenderer.tsx
@@ -417,7 +417,7 @@ export default function McpAppRenderer({
const effectiveInlineHeight = iframeHeight || DEFAULT_IFRAME_HEIGHT;
- const backendAcpOnly = window.appConfig.get('GOOSE_BACKEND_ACP_ONLY') === true;
+ const backendAcpOnly = window.appConfig.get('GOOSE_DESKTOP_BACKEND') === 'acp';
const [containerWidth, setContainerWidth] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);
const [restApiHost, setRestApiHost] = useState(null);
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index 26667f817958..b37391f5afa4 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -810,7 +810,7 @@ let appConfig = {
GOOSE_DEFAULT_MODEL: defaultModel,
GOOSE_PREDEFINED_MODELS: predefinedModels,
GOOSE_API_HOST: 'https://localhost',
- GOOSE_BACKEND_ACP_ONLY: process.env.GOOSE_BACKEND_ACP_ONLY === 'true',
+ GOOSE_DESKTOP_BACKEND: process.env.GOOSE_DESKTOP_BACKEND,
GOOSE_PATH_ROOT: resolveGoosePathRoot(),
GOOSE_WORKING_DIR: '',
// Start with the env-var override; the OS region locale is filled in after app.ready
@@ -949,7 +949,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
recipeParameters,
} = options;
const settings = getSettings();
- const backendAcpOnly = appConfig.GOOSE_BACKEND_ACP_ONLY === true;
+ const backendAcpOnly = appConfig.GOOSE_DESKTOP_BACKEND === 'acp';
const serverSecret = backendAcpOnly ? GENERATED_SECRET : getServerSecret(settings);
let baseUrl = '';
let workingDir = dir || os.homedir();
@@ -1886,7 +1886,7 @@ ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => {
});
ipcMain.handle('get-secret-key', () => {
- if (appConfig.GOOSE_BACKEND_ACP_ONLY === true) {
+ if (appConfig.GOOSE_DESKTOP_BACKEND === 'acp') {
return GENERATED_SECRET;
}
diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx
index 0d8a6808aa93..608cbd0692b5 100644
--- a/ui/desktop/src/renderer.tsx
+++ b/ui/desktop/src/renderer.tsx
@@ -36,7 +36,7 @@ function handleIntlError(err: { code: string; message?: string }) {
const isLauncher = window.location.hash === '#/launcher';
if (!isLauncher) {
- const backendAcpOnly = window.appConfig.get('GOOSE_BACKEND_ACP_ONLY') === true;
+ const backendAcpOnly = window.appConfig.get('GOOSE_DESKTOP_BACKEND') === 'acp';
if (!backendAcpOnly) {
const gooseApiHost = await window.electron.getGoosedHostPort();
if (gooseApiHost === null) {
diff --git a/ui/desktop/vite.main.config.mts b/ui/desktop/vite.main.config.mts
index 2d3f89e8610a..9afcaac6ae64 100644
--- a/ui/desktop/vite.main.config.mts
+++ b/ui/desktop/vite.main.config.mts
@@ -6,5 +6,8 @@ export default defineConfig({
'process.env.GITHUB_OWNER': JSON.stringify(process.env.GITHUB_OWNER || 'aaif-goose'),
'process.env.GITHUB_REPO': JSON.stringify(process.env.GITHUB_REPO || 'goose'),
'process.env.GOOSE_BUNDLE_NAME': JSON.stringify(process.env.GOOSE_BUNDLE_NAME || 'Goose'),
+ 'process.env.GOOSE_DESKTOP_BACKEND': JSON.stringify(
+ process.env.GOOSE_DESKTOP_BACKEND || 'goosed'
+ ),
},
});
From 6c5ccf46057cf98e9bbda2a44444d3a04cdb6bf0 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Mon, 29 Jun 2026 20:40:00 +1000
Subject: [PATCH 06/34] add goose serve arg --platform to pass desktop platform
option to acp
---
crates/goose-cli/src/cli.rs | 31 ++++++++++++++++++++++++++++---
ui/desktop/src/gooseServe.ts | 5 +++--
2 files changed, 31 insertions(+), 5 deletions(-)
diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs
index 267c0b352258..a1dcb2c42712 100644
--- a/crates/goose-cli/src/cli.rs
+++ b/crates/goose-cli/src/cli.rs
@@ -51,6 +51,22 @@ fn generate_serve_secret_key() -> String {
)
}
+#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
+enum ServePlatform {
+ #[default]
+ Cli,
+ Desktop,
+}
+
+impl From for GoosePlatform {
+ fn from(platform: ServePlatform) -> Self {
+ match platform {
+ ServePlatform::Cli => GoosePlatform::GooseCli,
+ ServePlatform::Desktop => GoosePlatform::GooseDesktop,
+ }
+ }
+}
+
#[derive(Parser)]
#[command(name = "goose", author, version, display_name = "", about, long_about = None)]
pub struct Cli {
@@ -831,6 +847,9 @@ enum Command {
#[arg(long, default_value = "3284")]
port: u16,
+ #[arg(long, value_enum, default_value_t = ServePlatform::Cli)]
+ platform: ServePlatform,
+
#[arg(
long = "with-builtin",
value_name = "NAME",
@@ -1324,7 +1343,12 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> {
Ok(())
}
-async fn handle_serve_command(host: String, port: u16, builtins: Vec) -> Result<()> {
+async fn handle_serve_command(
+ host: String,
+ port: u16,
+ builtins: Vec,
+ platform: ServePlatform,
+) -> Result<()> {
use goose::acp::server_factory::{AcpServer, AcpServerFactoryConfig};
use goose::acp::transport::create_router;
use goose::config::paths::Paths;
@@ -1354,7 +1378,7 @@ async fn handle_serve_command(host: String, port: u16, builtins: Vec) ->
builtins,
data_dir: Paths::data_dir(),
config_dir: Paths::config_dir(),
- goose_platform: GoosePlatform::GooseCli,
+ goose_platform: platform.into(),
additional_source_roots,
scheduler: None,
}));
@@ -2071,7 +2095,8 @@ pub async fn cli() -> anyhow::Result<()> {
host,
port,
builtins,
- }) => handle_serve_command(host, port, builtins).await,
+ platform,
+ }) => handle_serve_command(host, port, builtins, platform).await,
Some(Command::Session {
command: Some(cmd), ..
}) => handle_session_subcommand(cmd).await,
diff --git a/ui/desktop/src/gooseServe.ts b/ui/desktop/src/gooseServe.ts
index b0f11118021c..796f18ad7a5f 100644
--- a/ui/desktop/src/gooseServe.ts
+++ b/ui/desktop/src/gooseServe.ts
@@ -297,6 +297,7 @@ export const startGooseServe = async ({
const acpUrl = buildAcpUrl(port, secretKey);
const redactedAcpUrl = buildRedactedAcpUrl(port);
const errorLog: string[] = [];
+ const args = ['serve', '--platform', 'desktop', '--host', '127.0.0.1', '--port', String(port)];
logger.info(`Starting goose serve from: ${goosePath} on port ${port} in dir ${workingDir}`);
if (startupTrace) {
@@ -310,11 +311,11 @@ export const startGooseServe = async ({
binaryPath: goosePath,
port,
workingDir,
- args: ['serve', '--host', '127.0.0.1', '--port', String(port)],
+ args,
});
}
- const gooseProcess = spawn(goosePath, ['serve', '--host', '127.0.0.1', '--port', String(port)], {
+ const gooseProcess = spawn(goosePath, args, {
env: buildGooseServeEnv(secretKey, goosePath, additionalEnv),
cwd: workingDir,
windowsHide: true,
From 9032504c46ee1537c06dde07f6b575c0a6bf7be7 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Mon, 29 Jun 2026 23:55:28 +1000
Subject: [PATCH 07/34] throw error when acp server exits
---
ui/desktop/src/gooseServe.ts | 6 +-
.../src/gooseServeLeaseRegistry.test.ts | 89 +++++++++++
ui/desktop/src/gooseServeLeaseRegistry.ts | 139 ++++++++++++++++++
ui/desktop/src/main.ts | 80 ++--------
4 files changed, 249 insertions(+), 65 deletions(-)
create mode 100644 ui/desktop/src/gooseServeLeaseRegistry.test.ts
create mode 100644 ui/desktop/src/gooseServeLeaseRegistry.ts
diff --git a/ui/desktop/src/gooseServe.ts b/ui/desktop/src/gooseServe.ts
index 796f18ad7a5f..d4466b078594 100644
--- a/ui/desktop/src/gooseServe.ts
+++ b/ui/desktop/src/gooseServe.ts
@@ -38,6 +38,8 @@ export interface GooseServeResult {
process: ChildProcess;
errorLog: string[];
cleanup: () => Promise;
+ hasExited: () => boolean;
+ getExitDetails: () => { code: number | null; signal: NodeJS.Signals | null };
startupDiagnosticsPath: string | null;
getStartupDiagnostics: () => GooseServeStartupDiagnostics | null;
recordStartupEvent: (name: string, details?: Record) => void;
@@ -331,7 +333,7 @@ export const startGooseServe = async ({
let exited = false;
let spawnFailed = false;
let exitCode: number | null = null;
- let exitSignal: string | null = null;
+ let exitSignal: NodeJS.Signals | null = null;
gooseProcess.stdout?.resume();
@@ -437,6 +439,8 @@ export const startGooseServe = async ({
process: gooseProcess,
errorLog,
cleanup,
+ hasExited: () => exited,
+ getExitDetails: () => ({ code: exitCode, signal: exitSignal }),
startupDiagnosticsPath,
getStartupDiagnostics: () => startupTrace?.diagnostics ?? null,
recordStartupEvent: (name, details) => startupTrace?.record(name, details),
diff --git a/ui/desktop/src/gooseServeLeaseRegistry.test.ts b/ui/desktop/src/gooseServeLeaseRegistry.test.ts
new file mode 100644
index 000000000000..a818065080cf
--- /dev/null
+++ b/ui/desktop/src/gooseServeLeaseRegistry.test.ts
@@ -0,0 +1,89 @@
+import { EventEmitter } from 'node:events';
+import { describe, expect, it, vi } from 'vitest';
+import type { GooseServeResult, Logger } from './gooseServe';
+import {
+ GOOSE_SERVE_EXITED_USER_MESSAGE,
+ GooseServeLeaseRegistry,
+} from './gooseServeLeaseRegistry';
+
+function createLogger(): Logger {
+ return {
+ info: vi.fn(),
+ error: vi.fn(),
+ };
+}
+
+function createGooseServeResult(
+ overrides: Partial> = {}
+): GooseServeResult {
+ return {
+ acpUrl: 'ws://127.0.0.1:1234/acp?token=test',
+ workingDir: '/tmp',
+ process: new EventEmitter() as GooseServeResult['process'],
+ errorLog: [],
+ cleanup: vi.fn(async () => undefined),
+ hasExited: () => false,
+ getExitDetails: () => ({ code: null, signal: null }),
+ startupDiagnosticsPath: null,
+ getStartupDiagnostics: () => null,
+ recordStartupEvent: () => undefined,
+ ...overrides,
+ };
+}
+
+describe('GooseServeLeaseRegistry', () => {
+ it('returns the ACP URL for an attached live lease', () => {
+ const store = new GooseServeLeaseRegistry(createLogger());
+ const lease = store.create(createGooseServeResult());
+
+ store.attachWindow(1, lease);
+
+ expect(store.getAcpUrl(1)).toBe('ws://127.0.0.1:1234/acp?token=test');
+ });
+
+ it('throws a recovery message after the process exits', () => {
+ const logger = createLogger();
+ const store = new GooseServeLeaseRegistry(logger);
+ const result = createGooseServeResult();
+ const lease = store.create(result);
+ store.attachWindow(1, lease);
+
+ result.process.emit('exit', 1, null);
+
+ expect(() => store.getAcpUrl(1)).toThrow(GOOSE_SERVE_EXITED_USER_MESSAGE);
+ expect(logger.error).toHaveBeenCalledWith(
+ 'Goose ACP server exited unexpectedly',
+ expect.objectContaining({ code: 1, signal: null, windowIds: [1] })
+ );
+ });
+
+ it('uses the current child exit state when creating the lease', () => {
+ const store = new GooseServeLeaseRegistry(createLogger());
+ const lease = store.create(
+ createGooseServeResult({
+ hasExited: () => true,
+ getExitDetails: () => ({ code: null, signal: 'SIGTERM' }),
+ })
+ );
+
+ store.attachWindow(1, lease);
+
+ expect(() => store.getAcpUrl(1)).toThrow(GOOSE_SERVE_EXITED_USER_MESSAGE);
+ });
+
+ it('cleans up once after the last attached window is released', async () => {
+ const cleanup = vi.fn(async () => undefined);
+ const store = new GooseServeLeaseRegistry(createLogger());
+ const lease = store.create(createGooseServeResult({ cleanup }));
+ store.attachWindow(1, lease);
+ store.attachWindow(2, lease);
+
+ await store.releaseWindow(1);
+ expect(cleanup).not.toHaveBeenCalled();
+ expect(store.getAcpUrl(2)).toBe('ws://127.0.0.1:1234/acp?token=test');
+
+ await store.releaseWindow(2);
+ expect(cleanup).toHaveBeenCalledTimes(1);
+ expect(store.getAcpUrl(2)).toBeNull();
+ });
+});
diff --git a/ui/desktop/src/gooseServeLeaseRegistry.ts b/ui/desktop/src/gooseServeLeaseRegistry.ts
new file mode 100644
index 000000000000..5b87e2f9ee73
--- /dev/null
+++ b/ui/desktop/src/gooseServeLeaseRegistry.ts
@@ -0,0 +1,139 @@
+import type { GooseServeResult, Logger } from './gooseServe';
+
+export const GOOSE_SERVE_EXITED_USER_MESSAGE =
+ "This window's Goose backend stopped. Close this window and open a new chat to start a new backend. If this keeps happening, restart Goose Desktop.";
+
+export interface GooseServeLease {
+ acpUrl: string;
+ cleanup: () => Promise;
+ windowIds: Set;
+ cleanedUp: boolean;
+ exited: boolean;
+ exitCode: number | null;
+ exitSignal: NodeJS.Signals | null;
+ exitError?: string;
+}
+
+export class GooseServeLeaseRegistry {
+ private leasesByWindowId = new Map();
+
+ constructor(private readonly logger: Logger) {}
+
+ create(result: GooseServeResult): GooseServeLease {
+ const exitDetails = result.getExitDetails();
+ const lease: GooseServeLease = {
+ acpUrl: result.acpUrl,
+ cleanup: result.cleanup,
+ windowIds: new Set(),
+ cleanedUp: false,
+ exited: result.hasExited(),
+ exitCode: exitDetails.code,
+ exitSignal: exitDetails.signal,
+ };
+
+ const markExited = ({
+ code,
+ signal,
+ error,
+ }: {
+ code?: number | null;
+ signal?: NodeJS.Signals | null;
+ error?: Error;
+ }) => {
+ const firstExit = !lease.exited;
+ lease.exited = true;
+ if (code !== undefined) {
+ lease.exitCode = code;
+ }
+ if (signal !== undefined) {
+ lease.exitSignal = signal;
+ }
+ if (error) {
+ lease.exitError = error.message;
+ }
+
+ if (firstExit && !lease.cleanedUp) {
+ this.logger.error('Goose ACP server exited unexpectedly', {
+ code: lease.exitCode,
+ signal: lease.exitSignal,
+ error: lease.exitError,
+ windowIds: [...lease.windowIds],
+ });
+ }
+ };
+
+ result.process.once('exit', (code, signal) => {
+ markExited({ code, signal });
+ });
+
+ result.process.once('error', (error) => {
+ markExited({ error });
+ });
+
+ return lease;
+ }
+
+ get(windowId: number): GooseServeLease | null {
+ return this.leasesByWindowId.get(windowId) ?? null;
+ }
+
+ getAcpUrl(windowId: number): string | null {
+ const lease = this.get(windowId);
+ if (!lease) {
+ return null;
+ }
+ if (lease.exited) {
+ throw new Error(GOOSE_SERVE_EXITED_USER_MESSAGE);
+ }
+ return lease.acpUrl;
+ }
+
+ attachWindow(windowId: number, lease: GooseServeLease) {
+ lease.windowIds.add(windowId);
+ this.leasesByWindowId.set(windowId, lease);
+ }
+
+ async releaseWindow(windowId: number) {
+ const lease = this.leasesByWindowId.get(windowId);
+ this.leasesByWindowId.delete(windowId);
+
+ if (!lease) {
+ return;
+ }
+
+ lease.windowIds.delete(windowId);
+ if (lease.windowIds.size === 0) {
+ await this.cleanupLease(lease);
+ }
+ }
+
+ async cleanupLease(lease: GooseServeLease) {
+ if (lease.cleanedUp) {
+ return;
+ }
+
+ lease.cleanedUp = true;
+ for (const windowId of lease.windowIds) {
+ this.leasesByWindowId.delete(windowId);
+ }
+ lease.windowIds.clear();
+
+ try {
+ await lease.cleanup();
+ } catch (error) {
+ this.logger.error('Failed to cleanup goose serve backend:', error);
+ }
+ }
+
+ activeLeaseCount(): number {
+ return this.uniqueLeases().length;
+ }
+
+ async cleanupAll() {
+ await Promise.all(this.uniqueLeases().map((lease) => this.cleanupLease(lease)));
+ }
+
+ private uniqueLeases(): GooseServeLease[] {
+ return [...new Set(this.leasesByWindowId.values())];
+ }
+}
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index f0e189d9ca9e..a6f2e826f88a 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -28,6 +28,7 @@ import 'dotenv/config';
import { checkServerStatus } from './goosed';
import { startGoosed } from './goosed';
import { startGooseServe } from './gooseServe';
+import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLease';
import { createClient, createConfig } from './api/client';
import { expandTilde } from './utils/pathUtils';
import log from './utils/logger';
@@ -833,13 +834,6 @@ const windowMap = new Map();
const goosedClients = new Map();
const appWindows = new Map();
-interface GooseServeLease {
- acpUrl: string;
- cleanup: () => Promise;
- windowIds: Set;
- cleanedUp: boolean;
-}
-
interface GoosedLease {
client: Client;
cleanup: () => Promise;
@@ -848,7 +842,7 @@ interface GoosedLease {
}
const goosedLeasesByWindowId = new Map();
-const gooseServeLeasesByWindowId = new Map();
+const gooseServeLeases = new GooseServeLeaseRegistry(log);
const cleanupGoosedLease = async (lease: GoosedLease) => {
if (lease.cleanedUp) {
@@ -890,43 +884,6 @@ const releaseWindowGoosedLease = async (windowId: number) => {
}
};
-const cleanupGooseServeLease = async (lease: GooseServeLease) => {
- if (lease.cleanedUp) {
- return;
- }
-
- lease.cleanedUp = true;
- for (const windowId of lease.windowIds) {
- gooseServeLeasesByWindowId.delete(windowId);
- }
- lease.windowIds.clear();
-
- try {
- await lease.cleanup();
- } catch (error) {
- log.error('Failed to cleanup goose serve backend:', error);
- }
-};
-
-const attachWindowToGooseServeLease = (windowId: number, lease: GooseServeLease) => {
- lease.windowIds.add(windowId);
- gooseServeLeasesByWindowId.set(windowId, lease);
-};
-
-const releaseWindowGooseServeLease = async (windowId: number) => {
- const lease = gooseServeLeasesByWindowId.get(windowId);
- gooseServeLeasesByWindowId.delete(windowId);
-
- if (!lease) {
- return;
- }
-
- lease.windowIds.delete(windowId);
- if (lease.windowIds.size === 0) {
- await cleanupGooseServeLease(lease);
- }
-};
-
const windowPowerSaveBlockers = new Map(); // windowId -> blockerId
// Track pending initial messages per window
const pendingInitialMessages = new Map(); // windowId -> initialMessage
@@ -999,12 +956,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
}
workingDir = gooseServeResult.workingDir;
- gooseServeLease = {
- acpUrl: gooseServeResult.acpUrl,
- cleanup: gooseServeResult.cleanup,
- windowIds: new Set(),
- cleanedUp: false,
- };
+ gooseServeLease = gooseServeLeases.create(gooseServeResult);
} else {
// Update the cached trusted-external-hostname so the TLS handlers allow
// connections to the configured remote backend.
@@ -1057,7 +1009,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
const lease = gooseServeLease;
gooseServeLease = null;
- await cleanupGooseServeLease(lease);
+ await gooseServeLeases.cleanupLease(lease);
};
let mainWindowState: ReturnType;
@@ -1120,9 +1072,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
if (gooseServeLease) {
const lease = gooseServeLease;
mainWindow.once('closed', () => {
- void releaseWindowGooseServeLease(mainWindow.id);
+ void gooseServeLeases.releaseWindow(mainWindow.id);
});
- attachWindowToGooseServeLease(mainWindow.id, lease);
+ gooseServeLeases.attachWindow(mainWindow.id, lease);
gooseServeLease = null;
}
@@ -1919,9 +1871,9 @@ ipcMain.handle('get-acp-url', async (event) => {
if (!windowId) {
return null;
}
- const serveLease = gooseServeLeasesByWindowId.get(windowId);
- if (serveLease) {
- return serveLease.acpUrl;
+ const gooseServeAcpUrl = gooseServeLeases.getAcpUrl(windowId);
+ if (gooseServeAcpUrl) {
+ return gooseServeAcpUrl;
}
const client = goosedClients.get(windowId);
@@ -2924,7 +2876,7 @@ async function appMain() {
const launchingWindowId = launchingWindow.id;
const launchingGoosedLease = goosedLeasesByWindowId.get(launchingWindowId);
- const launchingGooseServeLease = gooseServeLeasesByWindowId.get(launchingWindowId);
+ const launchingGooseServeLease = gooseServeLeases.get(launchingWindowId);
if (!launchingGoosedLease && !launchingGooseServeLease) {
throw new Error('No backend lease found for launching window');
}
@@ -2961,8 +2913,8 @@ async function appMain() {
attachWindowToGoosedLease(appWindow.id, launchingGoosedLease);
releaseAppWindowBackend = () => releaseWindowGoosedLease(appWindow.id);
} else if (launchingGooseServeLease) {
- attachWindowToGooseServeLease(appWindow.id, launchingGooseServeLease);
- releaseAppWindowBackend = () => releaseWindowGooseServeLease(appWindow.id);
+ gooseServeLeases.attachWindow(appWindow.id, launchingGooseServeLease);
+ releaseAppWindowBackend = () => gooseServeLeases.releaseWindow(appWindow.id);
} else {
throw new Error('No backend lease found for launching window');
}
@@ -3078,10 +3030,10 @@ app.on('will-quit', async () => {
await Promise.all([...goosedLeases].map(cleanupGoosedLease));
}
- const gooseServeLeases = new Set(gooseServeLeasesByWindowId.values());
- if (gooseServeLeases.size > 0) {
- log.info(`App quitting, terminating ${gooseServeLeases.size} goose serve process(es)`);
- await Promise.all([...gooseServeLeases].map(cleanupGooseServeLease));
+ const gooseServeLeaseCount = gooseServeLeases.activeLeaseCount();
+ if (gooseServeLeaseCount > 0) {
+ log.info(`App quitting, terminating ${gooseServeLeaseCount} goose serve process(es)`);
+ await gooseServeLeases.cleanupAll();
}
for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) {
From b1c261c7cc93ebbaf57274f225e572c1a04e129f Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Tue, 30 Jun 2026 00:03:47 +1000
Subject: [PATCH 08/34] handle error and exit
---
ui/desktop/src/gooseServeLeaseRegistry.ts | 27 +++++++++--------------
ui/desktop/src/main.ts | 2 +-
2 files changed, 12 insertions(+), 17 deletions(-)
diff --git a/ui/desktop/src/gooseServeLeaseRegistry.ts b/ui/desktop/src/gooseServeLeaseRegistry.ts
index 5b87e2f9ee73..5072f09acd51 100644
--- a/ui/desktop/src/gooseServeLeaseRegistry.ts
+++ b/ui/desktop/src/gooseServeLeaseRegistry.ts
@@ -11,7 +11,6 @@ export interface GooseServeLease {
exited: boolean;
exitCode: number | null;
exitSignal: NodeJS.Signals | null;
- exitError?: string;
}
export class GooseServeLeaseRegistry {
@@ -20,25 +19,24 @@ export class GooseServeLeaseRegistry {
constructor(private readonly logger: Logger) {}
create(result: GooseServeResult): GooseServeLease {
- const exitDetails = result.getExitDetails();
const lease: GooseServeLease = {
acpUrl: result.acpUrl,
cleanup: result.cleanup,
windowIds: new Set(),
cleanedUp: false,
- exited: result.hasExited(),
- exitCode: exitDetails.code,
- exitSignal: exitDetails.signal,
+ exited: false,
+ exitCode: null,
+ exitSignal: null,
};
const markExited = ({
code,
signal,
- error,
+ logUnexpected,
}: {
code?: number | null;
signal?: NodeJS.Signals | null;
- error?: Error;
+ logUnexpected: boolean;
}) => {
const firstExit = !lease.exited;
lease.exited = true;
@@ -48,27 +46,24 @@ export class GooseServeLeaseRegistry {
if (signal !== undefined) {
lease.exitSignal = signal;
}
- if (error) {
- lease.exitError = error.message;
- }
- if (firstExit && !lease.cleanedUp) {
+ if (logUnexpected && firstExit && !lease.cleanedUp) {
this.logger.error('Goose ACP server exited unexpectedly', {
code: lease.exitCode,
signal: lease.exitSignal,
- error: lease.exitError,
windowIds: [...lease.windowIds],
});
}
};
result.process.once('exit', (code, signal) => {
- markExited({ code, signal });
+ markExited({ code, signal, logUnexpected: true });
});
- result.process.once('error', (error) => {
- markExited({ error });
- });
+ if (result.hasExited()) {
+ const exitDetails = result.getExitDetails();
+ markExited({ code: exitDetails.code, signal: exitDetails.signal, logUnexpected: false });
+ }
return lease;
}
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index a6f2e826f88a..4df9ff8bc9f9 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -28,7 +28,7 @@ import 'dotenv/config';
import { checkServerStatus } from './goosed';
import { startGoosed } from './goosed';
import { startGooseServe } from './gooseServe';
-import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLease';
+import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLeaseRegistry';
import { createClient, createConfig } from './api/client';
import { expandTilde } from './utils/pathUtils';
import log from './utils/logger';
From 30aa3dbd5b53d9304b8311e8f44f1680986b0d66 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Tue, 30 Jun 2026 00:14:33 +1000
Subject: [PATCH 09/34] removed acpconfig read before react is rendered
---
ui/desktop/src/renderer.tsx | 12 ------------
1 file changed, 12 deletions(-)
diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx
index 608cbd0692b5..b3bd4146f22b 100644
--- a/ui/desktop/src/renderer.tsx
+++ b/ui/desktop/src/renderer.tsx
@@ -5,8 +5,6 @@ import { ConfigProvider } from './components/ConfigContext';
import { ErrorBoundary } from './components/ErrorBoundary';
import SuspenseLoader from './suspense-loader';
import { client } from './api/client.gen';
-import { setTelemetryEnabled } from './utils/analytics';
-import { acpReadConfig } from './acp/config';
import { applyThemeTokens } from './theme/theme-tokens';
import { currentLocale, currentMessageLocale, loadMessages } from './i18n';
@@ -15,8 +13,6 @@ applyThemeTokens();
const App = lazy(() => import('./App'));
-const TELEMETRY_CONFIG_KEY = 'GOOSE_TELEMETRY_ENABLED';
-
let warnedFallbackLocale = false;
function handleIntlError(err: { code: string; message?: string }) {
if (err.code === 'MISSING_TRANSLATION' && currentLocale !== currentMessageLocale) {
@@ -51,14 +47,6 @@ function handleIntlError(err: { code: string; message?: string }) {
},
});
}
-
- try {
- const telemetryValue = await acpReadConfig(TELEMETRY_CONFIG_KEY, false);
- const isTelemetryEnabled = telemetryValue !== false;
- setTelemetryEnabled(isTelemetryEnabled);
- } catch (error) {
- console.warn('[Analytics] Failed to initialize analytics:', error);
- }
}
const messages = await loadMessages(currentMessageLocale);
From 2a271ec911a09faf3c8319ad5d9ad7320fe76946 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Tue, 30 Jun 2026 00:50:56 +1000
Subject: [PATCH 10/34] close stream when initialize failed
---
ui/desktop/src/acp/acpConnection.ts | 39 ++++++++++++---------
ui/desktop/src/acp/createWebSocketStream.ts | 12 +++++--
2 files changed, 32 insertions(+), 19 deletions(-)
diff --git a/ui/desktop/src/acp/acpConnection.ts b/ui/desktop/src/acp/acpConnection.ts
index b7b6d5561e81..15dff98b242a 100644
--- a/ui/desktop/src/acp/acpConnection.ts
+++ b/ui/desktop/src/acp/acpConnection.ts
@@ -53,26 +53,31 @@ async function initializeConnection(): Promise {
const stream = createWebSocketStream(wsUrl);
const client = new GooseClient(createClientCallbacks(), stream);
- const initializeResponse = await client.initialize({
- protocolVersion: PROTOCOL_VERSION,
- clientCapabilities: {
- elicitation: { form: {} },
- _meta: {
- goose: {
- mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
- customNotifications: true,
- recipeParameterRequests: true,
+ try {
+ const initializeResponse = await client.initialize({
+ protocolVersion: PROTOCOL_VERSION,
+ clientCapabilities: {
+ elicitation: { form: {} },
+ _meta: {
+ goose: {
+ mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
+ customNotifications: true,
+ recipeParameterRequests: true,
+ },
},
},
- },
- clientInfo: {
- name: packageJson.name,
- version: packageJson.version,
- },
- });
+ clientInfo: {
+ name: packageJson.name,
+ version: packageJson.version,
+ },
+ });
- monitorConnection(client);
- return { client, initializeResponse };
+ monitorConnection(client);
+ return { client, initializeResponse };
+ } catch (error) {
+ stream.close();
+ throw error;
+ }
}
export async function getAcpClient(): Promise {
diff --git a/ui/desktop/src/acp/createWebSocketStream.ts b/ui/desktop/src/acp/createWebSocketStream.ts
index 61b21c558f94..73d74481613a 100644
--- a/ui/desktop/src/acp/createWebSocketStream.ts
+++ b/ui/desktop/src/acp/createWebSocketStream.ts
@@ -1,6 +1,10 @@
import type { Stream } from '@aaif/goose-sdk';
-export function createWebSocketStream(wsUrl: string): Stream {
+export type ClosableAcpStream = Stream & {
+ close: () => void;
+};
+
+export function createWebSocketStream(wsUrl: string): ClosableAcpStream {
const ws = new window.WebSocket(wsUrl);
const incoming: unknown[] = [];
@@ -73,5 +77,9 @@ export function createWebSocketStream(wsUrl: string): Stream {
},
});
- return { readable, writable } as Stream;
+ return {
+ readable,
+ writable,
+ close: () => ws.close(),
+ } as ClosableAcpStream;
}
From 74d87f5834b9b70e684c41b8de8cb6d042c8b713 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Tue, 30 Jun 2026 00:57:11 +1000
Subject: [PATCH 11/34] set up initialization timeout
---
ui/desktop/src/acp/acpConnection.ts | 51 ++++++++++++++++++++---------
1 file changed, 36 insertions(+), 15 deletions(-)
diff --git a/ui/desktop/src/acp/acpConnection.ts b/ui/desktop/src/acp/acpConnection.ts
index 15dff98b242a..fd40944ea4cf 100644
--- a/ui/desktop/src/acp/acpConnection.ts
+++ b/ui/desktop/src/acp/acpConnection.ts
@@ -19,6 +19,8 @@ type InitializedAcpClient = {
initializeResponse: InitializeResponse;
};
+const ACP_INITIALIZE_TIMEOUT_MS = 10_000;
+
let clientPromise: Promise | null = null;
let resolvedClient: InitializedAcpClient | null = null;
@@ -44,6 +46,21 @@ function monitorConnection(client: GooseClient): void {
});
}
+async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise {
+ let timeoutId: ReturnType | null = null;
+ const timeout = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
+ });
+
+ try {
+ return await Promise.race([promise, timeout]);
+ } finally {
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId);
+ }
+ }
+}
+
async function initializeConnection(): Promise {
const wsUrl = await window.electron.getAcpUrl();
if (!wsUrl) {
@@ -54,23 +71,27 @@ async function initializeConnection(): Promise {
const client = new GooseClient(createClientCallbacks(), stream);
try {
- const initializeResponse = await client.initialize({
- protocolVersion: PROTOCOL_VERSION,
- clientCapabilities: {
- elicitation: { form: {} },
- _meta: {
- goose: {
- mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
- customNotifications: true,
- recipeParameterRequests: true,
+ const initializeResponse = await withTimeout(
+ client.initialize({
+ protocolVersion: PROTOCOL_VERSION,
+ clientCapabilities: {
+ elicitation: { form: {} },
+ _meta: {
+ goose: {
+ mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
+ customNotifications: true,
+ recipeParameterRequests: true,
+ },
},
},
- },
- clientInfo: {
- name: packageJson.name,
- version: packageJson.version,
- },
- });
+ clientInfo: {
+ name: packageJson.name,
+ version: packageJson.version,
+ },
+ }),
+ ACP_INITIALIZE_TIMEOUT_MS,
+ `ACP initialize timed out after ${ACP_INITIALIZE_TIMEOUT_MS}ms`
+ );
monitorConnection(client);
return { client, initializeResponse };
From 103187044231b86f11793347adc652cfa1b528a6 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Wed, 1 Jul 2026 16:25:27 +1000
Subject: [PATCH 12/34] implement starting acp sever including local and
external
---
ui/desktop/src/acp/__tests__/url.test.ts | 53 ++++-
ui/desktop/src/acp/url.ts | 32 +++
ui/desktop/src/config.ts | 5 -
.../src/gooseServeLeaseRegistry.test.ts | 12 ++
ui/desktop/src/gooseServeLeaseRegistry.ts | 12 ++
ui/desktop/src/main.ts | 193 +++++++++++-------
ui/desktop/src/preload.ts | 2 -
ui/desktop/src/renderer.tsx | 22 --
8 files changed, 232 insertions(+), 99 deletions(-)
delete mode 100644 ui/desktop/src/config.ts
diff --git a/ui/desktop/src/acp/__tests__/url.test.ts b/ui/desktop/src/acp/__tests__/url.test.ts
index 308dc84c0ccb..696e165efdeb 100644
--- a/ui/desktop/src/acp/__tests__/url.test.ts
+++ b/ui/desktop/src/acp/__tests__/url.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { httpBaseFromAcpWebSocketUrl } from '../url';
+import {
+ acpWebSocketUrlFromHttpBase,
+ httpBaseFromAcpWebSocketUrl,
+ normalizeAcpHttpBaseUrl,
+} from '../url';
describe('httpBaseFromAcpWebSocketUrl', () => {
it('converts ws ACP URLs to HTTP bases', () => {
@@ -26,3 +30,50 @@ describe('httpBaseFromAcpWebSocketUrl', () => {
);
});
});
+
+describe('normalizeAcpHttpBaseUrl', () => {
+ it('normalizes root HTTPS base URLs', () => {
+ expect(normalizeAcpHttpBaseUrl('https://example.com/')).toBe('https://example.com');
+ });
+
+ it('normalizes prefixed HTTPS base URLs', () => {
+ expect(normalizeAcpHttpBaseUrl('https://example.com/goose/')).toBe(
+ 'https://example.com/goose'
+ );
+ });
+
+ it('rejects WebSocket URLs', () => {
+ expect(() => normalizeAcpHttpBaseUrl('wss://example.com/acp')).toThrow(
+ 'External ACP backend URL must use http: or https:'
+ );
+ });
+
+ it('rejects direct ACP endpoint URLs', () => {
+ expect(() => normalizeAcpHttpBaseUrl('https://example.com/acp')).toThrow(
+ 'External ACP backend URL must be the base URL before /acp'
+ );
+ });
+
+ it('rejects query parameters and fragments', () => {
+ expect(() => normalizeAcpHttpBaseUrl('https://example.com?token=secret')).toThrow(
+ 'External ACP backend URL must not include query parameters or fragments'
+ );
+ expect(() => normalizeAcpHttpBaseUrl('https://example.com#section')).toThrow(
+ 'External ACP backend URL must not include query parameters or fragments'
+ );
+ });
+});
+
+describe('acpWebSocketUrlFromHttpBase', () => {
+ it('derives WSS ACP URLs from HTTPS base URLs', () => {
+ expect(acpWebSocketUrlFromHttpBase('https://example.com/goose', 'secret')).toBe(
+ 'wss://example.com/goose/acp?token=secret'
+ );
+ });
+
+ it('derives WS ACP URLs from HTTP base URLs', () => {
+ expect(acpWebSocketUrlFromHttpBase('http://127.0.0.1:1234', 'secret')).toBe(
+ 'ws://127.0.0.1:1234/acp?token=secret'
+ );
+ });
+});
diff --git a/ui/desktop/src/acp/url.ts b/ui/desktop/src/acp/url.ts
index 4ab215afa328..ca055f2ffd3a 100644
--- a/ui/desktop/src/acp/url.ts
+++ b/ui/desktop/src/acp/url.ts
@@ -14,3 +14,35 @@ export function httpBaseFromAcpWebSocketUrl(acpUrl: string): string {
return `${url.origin}${pathPrefix}`;
}
+
+export function normalizeAcpHttpBaseUrl(rawBaseUrl: string): string {
+ const trimmed = rawBaseUrl.trim();
+ if (!trimmed) {
+ throw new Error('External ACP backend URL is required');
+ }
+
+ const url = new URL(trimmed);
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
+ throw new Error(`External ACP backend URL must use http: or https:, got ${url.protocol}`);
+ }
+
+ if (url.search || url.hash) {
+ throw new Error('External ACP backend URL must not include query parameters or fragments');
+ }
+
+ const pathname = url.pathname.replace(/\/+$/, '');
+ if (pathname.endsWith('/acp')) {
+ throw new Error('External ACP backend URL must be the base URL before /acp');
+ }
+
+ return `${url.origin}${pathname}`;
+}
+
+export function acpWebSocketUrlFromHttpBase(rawBaseUrl: string, token: string): string {
+ const baseUrl = normalizeAcpHttpBaseUrl(rawBaseUrl);
+ const url = new URL(baseUrl);
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/acp`;
+ url.searchParams.set('token', token);
+ return url.toString();
+}
diff --git a/ui/desktop/src/config.ts b/ui/desktop/src/config.ts
deleted file mode 100644
index f5f96eb85fde..000000000000
--- a/ui/desktop/src/config.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export const getApiUrl = (endpoint: string): string => {
- const gooseApiHost = String(window.appConfig.get('GOOSE_API_HOST') || '');
- const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
- return `${gooseApiHost}${cleanEndpoint}`;
-};
diff --git a/ui/desktop/src/gooseServeLeaseRegistry.test.ts b/ui/desktop/src/gooseServeLeaseRegistry.test.ts
index a818065080cf..0dc86fa93237 100644
--- a/ui/desktop/src/gooseServeLeaseRegistry.test.ts
+++ b/ui/desktop/src/gooseServeLeaseRegistry.test.ts
@@ -86,4 +86,16 @@ describe('GooseServeLeaseRegistry', () => {
expect(cleanup).toHaveBeenCalledTimes(1);
expect(store.getAcpUrl(2)).toBeNull();
});
+
+ it('creates an external ACP lease without process cleanup', async () => {
+ const store = new GooseServeLeaseRegistry(createLogger());
+ const lease = store.createExternal('wss://example.com/goose/acp?token=test');
+
+ store.attachWindow(1, lease);
+
+ expect(store.getAcpUrl(1)).toBe('wss://example.com/goose/acp?token=test');
+
+ await store.releaseWindow(1);
+ expect(store.getAcpUrl(1)).toBeNull();
+ });
});
diff --git a/ui/desktop/src/gooseServeLeaseRegistry.ts b/ui/desktop/src/gooseServeLeaseRegistry.ts
index 5072f09acd51..028cafeecc77 100644
--- a/ui/desktop/src/gooseServeLeaseRegistry.ts
+++ b/ui/desktop/src/gooseServeLeaseRegistry.ts
@@ -68,6 +68,18 @@ export class GooseServeLeaseRegistry {
return lease;
}
+ createExternal(acpUrl: string): GooseServeLease {
+ return {
+ acpUrl,
+ cleanup: async () => undefined,
+ windowIds: new Set(),
+ cleanedUp: false,
+ exited: false,
+ exitCode: null,
+ exitSignal: null,
+ };
+ }
+
get(windowId: number): GooseServeLease | null {
return this.leasesByWindowId.get(windowId) ?? null;
}
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index 4a230c4d52d0..22de7fd69eff 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -30,6 +30,7 @@ import { startGoosed } from './goosed';
import { startGooseServe } from './gooseServe';
import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLeaseRegistry';
import { createClient, createConfig } from './api/client';
+import { acpWebSocketUrlFromHttpBase, normalizeAcpHttpBaseUrl } from './acp/url';
import { expandTilde } from './utils/pathUtils';
import log from './utils/logger';
import { ensureWinShims } from './utils/winShims';
@@ -866,12 +867,22 @@ const buildAcpWebSocketUrl = (baseUrl: string, token: string): string => {
return url.toString();
};
+const createMainProcessBackendClient = (baseUrl: string, serverSecret: string): Client =>
+ createClient(
+ createConfig({
+ baseUrl,
+ fetch: net.fetch as unknown as typeof globalThis.fetch,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Secret-Key': serverSecret,
+ },
+ })
+ );
+
let appConfig = {
GOOSE_DEFAULT_PROVIDER: defaultProvider,
GOOSE_DEFAULT_MODEL: defaultModel,
GOOSE_PREDEFINED_MODELS: predefinedModels,
- GOOSE_API_HOST: 'https://localhost',
- GOOSE_DESKTOP_BACKEND: process.env.GOOSE_DESKTOP_BACKEND,
GOOSE_PATH_ROOT: resolveGoosePathRoot(),
GOOSE_WORKING_DIR: '',
// Start with the env-var override; the OS region locale is filled in after app.ready
@@ -970,6 +981,8 @@ const createChat = async (
} = options;
const settings = getSettings();
+ // `externalGoosed` is a legacy name kept for on-disk settings compatibility;
+ // the remote backend it points at is now an ACP server, not goosed.
if (settings.externalGoosed?.enabled && settings.externalGoosed.certFingerprint) {
const url = settings.externalGoosed.url;
const usesHttps = (() => {
@@ -1005,49 +1018,117 @@ const createChat = async (
}
}
- const backendAcpOnly = appConfig.GOOSE_DESKTOP_BACKEND === 'acp';
- const serverSecret = backendAcpOnly ? GENERATED_SECRET : getServerSecret(settings);
+ const useAcpBackend: boolean = true;
+ const externalBackend = settings.externalGoosed;
+ const externalBackendEnabled = Boolean(externalBackend?.enabled && externalBackend.url);
+ const serverSecret =
+ useAcpBackend && !externalBackendEnabled ? GENERATED_SECRET : getServerSecret(settings);
let baseUrl = '';
let workingDir = dir || os.homedir();
let goosedResult: Awaited> | null = null;
let gooseServeLease: GooseServeLease | null = null;
- if (backendAcpOnly) {
- trustedExternalHostname = null;
- pinnedCertFingerprint = null;
+ if (useAcpBackend) {
+ if (externalBackendEnabled && externalBackend?.url) {
+ try {
+ const externalBaseUrl = normalizeAcpHttpBaseUrl(externalBackend.url);
+ trustedExternalHostname = new URL(externalBaseUrl).hostname;
+ pinnedCertFingerprint = externalBackend.certFingerprint
+ ? normalizeFingerprint(externalBackend.certFingerprint)
+ : null;
+
+ const externalBackendReady = await checkServerStatus(
+ createMainProcessBackendClient(externalBaseUrl, serverSecret),
+ []
+ );
+ if (!externalBackendReady) {
+ const response = dialog.showMessageBoxSync({
+ type: 'error',
+ title: 'External Backend Unreachable',
+ message: `Could not connect to external backend at ${externalBaseUrl}`,
+ detail: 'The external backend must be running and expose /status at the configured URL.',
+ buttons: ['Disable External Backend & Retry', 'Quit'],
+ defaultId: 0,
+ cancelId: 1,
+ });
- let gooseServeResult: Awaited>;
- try {
- gooseServeResult = await startGooseServe({
- serverSecret,
- dir: workingDir,
- env: {
- GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
- },
- isPackaged: app.isPackaged,
- resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
- logger: log,
- diagnosticsDir: STARTUP_LOGS_DIR,
- });
- } catch (error) {
- log.error('goose serve failed to start', error);
- dialog.showMessageBoxSync({
- type: 'error',
- title: 'Goose Failed to Start',
- message: 'The backend server failed to start.',
- detail: [
- 'Backend: goose serve',
- 'Readiness check: plain GET /status',
- `Startup error:\n${errorMessage(error)}`,
- ].join('\n\n'),
- buttons: ['OK'],
- });
- app.quit();
- return;
- }
+ if (response === 0) {
+ updateSettings((s) => {
+ if (s.externalGoosed) {
+ s.externalGoosed.enabled = false;
+ }
+ });
+ return createChat(app, options);
+ }
+
+ app.quit();
+ return;
+ }
+
+ gooseServeLease = gooseServeLeases.createExternal(
+ acpWebSocketUrlFromHttpBase(externalBaseUrl, serverSecret)
+ );
+ } catch (error) {
+ log.error('External ACP backend is misconfigured', error);
+ const response = dialog.showMessageBoxSync({
+ type: 'error',
+ title: 'External Backend Misconfigured',
+ message: 'The external backend URL is invalid.',
+ detail: errorMessage(error),
+ buttons: ['Disable External Backend & Retry', 'Quit'],
+ defaultId: 0,
+ cancelId: 1,
+ });
+
+ if (response === 0) {
+ updateSettings((s) => {
+ if (s.externalGoosed) {
+ s.externalGoosed.enabled = false;
+ }
+ });
+ return createChat(app, options);
+ }
+
+ app.quit();
+ return;
+ }
+ } else {
+ trustedExternalHostname = null;
+ pinnedCertFingerprint = null;
- workingDir = gooseServeResult.workingDir;
- gooseServeLease = gooseServeLeases.create(gooseServeResult);
+ let gooseServeResult: Awaited>;
+ try {
+ gooseServeResult = await startGooseServe({
+ serverSecret,
+ dir: workingDir,
+ env: {
+ GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
+ },
+ isPackaged: app.isPackaged,
+ resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
+ logger: log,
+ diagnosticsDir: STARTUP_LOGS_DIR,
+ });
+ } catch (error) {
+ log.error('goose serve failed to start', error);
+ dialog.showMessageBoxSync({
+ type: 'error',
+ title: 'Goose Failed to Start',
+ message: 'The backend server failed to start.',
+ detail: [
+ 'Backend: goose serve',
+ 'Readiness check: plain GET /status',
+ `Startup error:\n${errorMessage(error)}`,
+ ].join('\n\n'),
+ buttons: ['OK'],
+ });
+ app.quit();
+ return;
+ }
+
+ workingDir = gooseServeResult.workingDir;
+ gooseServeLease = gooseServeLeases.create(gooseServeResult);
+ }
} else {
// Update the cached trusted-external-hostname so the TLS handlers allow
// connections to the configured remote backend.
@@ -1138,7 +1219,6 @@ const createChat = async (
JSON.stringify({
...appConfig,
GOOSE_LOCALE: getConfiguredGooseLocale(),
- GOOSE_API_HOST: baseUrl,
GOOSE_WORKING_DIR: workingDir,
REQUEST_DIR: dir,
GOOSE_VERSION: version,
@@ -1189,16 +1269,7 @@ const createChat = async (
// Re-create the client with Electron's net.fetch so requests to the local
// self-signed HTTPS server go through the session's certificate handling.
- const goosedClient = createClient(
- createConfig({
- baseUrl,
- fetch: net.fetch as unknown as typeof globalThis.fetch,
- headers: {
- 'Content-Type': 'application/json',
- 'X-Secret-Key': serverSecret,
- },
- })
- );
+ const goosedClient = createMainProcessBackendClient(baseUrl, serverSecret);
const goosedLease: GoosedLease = {
client: goosedClient,
cleanup: goosedResult.cleanup,
@@ -1269,7 +1340,7 @@ const createChat = async (
// Stop collecting stderr to avoid unbounded memory growth over long sessions.
stopErrorLogCollection();
errorLog.length = 0;
- } else if (!backendAcpOnly) {
+ } else if (!useAcpBackend) {
throw new Error('No desktop backend was started');
}
@@ -1943,24 +2014,11 @@ ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => {
});
ipcMain.handle('get-secret-key', () => {
- if (appConfig.GOOSE_DESKTOP_BACKEND === 'acp') {
- return GENERATED_SECRET;
- }
-
const settings = getSettings();
- return getServerSecret(settings);
-});
-
-ipcMain.handle('get-goosed-host-port', async (event) => {
- const windowId = BrowserWindow.fromWebContents(event.sender)?.id;
- if (!windowId) {
- return null;
+ if (settings.externalGoosed?.enabled && settings.externalGoosed.url) {
+ return getServerSecret(settings);
}
- const client = goosedClients.get(windowId);
- if (!client) {
- return null;
- }
- return client.getConfig().baseUrl || null;
+ return GENERATED_SECRET;
});
ipcMain.handle('get-acp-url', async (event) => {
@@ -2983,8 +3041,6 @@ async function appMain() {
}
const workingDir = app.getPath('home');
- const restApiHost = launchingGoosedLease?.client.getConfig().baseUrl ?? '';
-
const appWindow = new BrowserWindow({
title: formatAppName(gooseApp.name),
width: gooseApp.width ?? 800,
@@ -3000,7 +3056,6 @@ async function appMain() {
JSON.stringify({
...appConfig,
GOOSE_LOCALE: getConfiguredGooseLocale(),
- GOOSE_API_HOST: restApiHost,
GOOSE_WORKING_DIR: workingDir,
GOOSE_VERSION: version,
}),
diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts
index 231995f4ac8a..d79a151604b5 100644
--- a/ui/desktop/src/preload.ts
+++ b/ui/desktop/src/preload.ts
@@ -131,7 +131,6 @@ type ElectronAPI = {
getSetting: (key: K) => Promise;
setSetting: (key: K, value: Settings[K]) => Promise;
getSecretKey: () => Promise;
- getGoosedHostPort: () => Promise;
getAcpUrl: () => Promise;
setWakelock: (enable: boolean) => Promise;
getWakelockState: () => Promise;
@@ -254,7 +253,6 @@ const electronAPI: ElectronAPI = {
return ipcRenderer.invoke('set-setting', key, value);
},
getSecretKey: () => ipcRenderer.invoke('get-secret-key'),
- getGoosedHostPort: () => ipcRenderer.invoke('get-goosed-host-port'),
getAcpUrl: () => ipcRenderer.invoke('get-acp-url'),
setWakelock: (enable: boolean) => ipcRenderer.invoke('set-wakelock', enable),
getWakelockState: () => ipcRenderer.invoke('get-wakelock-state'),
diff --git a/ui/desktop/src/renderer.tsx b/ui/desktop/src/renderer.tsx
index b3bd4146f22b..4986e95211ad 100644
--- a/ui/desktop/src/renderer.tsx
+++ b/ui/desktop/src/renderer.tsx
@@ -4,7 +4,6 @@ import { IntlProvider } from 'react-intl';
import { ConfigProvider } from './components/ConfigContext';
import { ErrorBoundary } from './components/ErrorBoundary';
import SuspenseLoader from './suspense-loader';
-import { client } from './api/client.gen';
import { applyThemeTokens } from './theme/theme-tokens';
import { currentLocale, currentMessageLocale, loadMessages } from './i18n';
@@ -28,27 +27,6 @@ function handleIntlError(err: { code: string; message?: string }) {
}
(async () => {
- // Check if we're in the launcher view (doesn't need goosed connection)
- const isLauncher = window.location.hash === '#/launcher';
-
- if (!isLauncher) {
- const backendAcpOnly = window.appConfig.get('GOOSE_DESKTOP_BACKEND') === 'acp';
- if (!backendAcpOnly) {
- const gooseApiHost = await window.electron.getGoosedHostPort();
- if (gooseApiHost === null) {
- window.alert('failed to start goose backend process');
- return;
- }
- client.setConfig({
- baseUrl: gooseApiHost,
- headers: {
- 'Content-Type': 'application/json',
- 'X-Secret-Key': await window.electron.getSecretKey(),
- },
- });
- }
- }
-
const messages = await loadMessages(currentMessageLocale);
ReactDOM.createRoot(document.getElementById('root')!).render(
From cd9bc27c1a821f1f1ec7a6337fbf13f61e5fc9bc Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Wed, 1 Jul 2026 16:32:48 +1000
Subject: [PATCH 13/34] clean up goosed
---
ui/desktop/src/main.ts | 371 +++++++++--------------------------------
1 file changed, 76 insertions(+), 295 deletions(-)
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index 22de7fd69eff..4e3a6753ad74 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -26,7 +26,6 @@ import os from 'node:os';
import { execFileSync, spawn, execFile } from 'child_process';
import 'dotenv/config';
import { checkServerStatus } from './goosed';
-import { startGoosed } from './goosed';
import { startGooseServe } from './gooseServe';
import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLeaseRegistry';
import { createClient, createConfig } from './api/client';
@@ -859,14 +858,6 @@ const getServerSecret = (settings: Settings): string => {
return GENERATED_SECRET;
};
-const buildAcpWebSocketUrl = (baseUrl: string, token: string): string => {
- const url = new URL(baseUrl);
- url.pathname = `${url.pathname.replace(/\/+$/, '')}/acp`;
- url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
- url.searchParams.set('token', token);
- return url.toString();
-};
-
const createMainProcessBackendClient = (baseUrl: string, serverSecret: string): Client =>
createClient(
createConfig({
@@ -894,59 +885,10 @@ let appConfig = {
};
const windowMap = new Map();
-const goosedClients = new Map();
const appWindows = new Map();
-interface GoosedLease {
- client: Client;
- cleanup: () => Promise;
- windowIds: Set;
- cleanedUp: boolean;
-}
-
-const goosedLeasesByWindowId = new Map();
const gooseServeLeases = new GooseServeLeaseRegistry(log);
-const cleanupGoosedLease = async (lease: GoosedLease) => {
- if (lease.cleanedUp) {
- return;
- }
-
- lease.cleanedUp = true;
- for (const windowId of lease.windowIds) {
- goosedLeasesByWindowId.delete(windowId);
- goosedClients.delete(windowId);
- }
- lease.windowIds.clear();
-
- try {
- await lease.cleanup();
- } catch (error) {
- log.error('Failed to cleanup goosed server:', error);
- }
-};
-
-const attachWindowToGoosedLease = (windowId: number, lease: GoosedLease) => {
- lease.windowIds.add(windowId);
- goosedLeasesByWindowId.set(windowId, lease);
- goosedClients.set(windowId, lease.client);
-};
-
-const releaseWindowGoosedLease = async (windowId: number) => {
- const lease = goosedLeasesByWindowId.get(windowId);
- goosedLeasesByWindowId.delete(windowId);
- goosedClients.delete(windowId);
-
- if (!lease) {
- return;
- }
-
- lease.windowIds.delete(windowId);
- if (lease.windowIds.size === 0) {
- await cleanupGoosedLease(lease);
- }
-};
-
const windowPowerSaveBlockers = new Map(); // windowId -> blockerId
// Track pending initial messages per window
const pendingInitialMessages = new Map(); // windowId -> initialMessage
@@ -1018,63 +960,30 @@ const createChat = async (
}
}
- const useAcpBackend: boolean = true;
const externalBackend = settings.externalGoosed;
const externalBackendEnabled = Boolean(externalBackend?.enabled && externalBackend.url);
- const serverSecret =
- useAcpBackend && !externalBackendEnabled ? GENERATED_SECRET : getServerSecret(settings);
- let baseUrl = '';
+ const serverSecret = externalBackendEnabled ? getServerSecret(settings) : GENERATED_SECRET;
let workingDir = dir || os.homedir();
- let goosedResult: Awaited> | null = null;
let gooseServeLease: GooseServeLease | null = null;
- if (useAcpBackend) {
- if (externalBackendEnabled && externalBackend?.url) {
- try {
- const externalBaseUrl = normalizeAcpHttpBaseUrl(externalBackend.url);
- trustedExternalHostname = new URL(externalBaseUrl).hostname;
- pinnedCertFingerprint = externalBackend.certFingerprint
- ? normalizeFingerprint(externalBackend.certFingerprint)
- : null;
-
- const externalBackendReady = await checkServerStatus(
- createMainProcessBackendClient(externalBaseUrl, serverSecret),
- []
- );
- if (!externalBackendReady) {
- const response = dialog.showMessageBoxSync({
- type: 'error',
- title: 'External Backend Unreachable',
- message: `Could not connect to external backend at ${externalBaseUrl}`,
- detail: 'The external backend must be running and expose /status at the configured URL.',
- buttons: ['Disable External Backend & Retry', 'Quit'],
- defaultId: 0,
- cancelId: 1,
- });
-
- if (response === 0) {
- updateSettings((s) => {
- if (s.externalGoosed) {
- s.externalGoosed.enabled = false;
- }
- });
- return createChat(app, options);
- }
-
- app.quit();
- return;
- }
-
- gooseServeLease = gooseServeLeases.createExternal(
- acpWebSocketUrlFromHttpBase(externalBaseUrl, serverSecret)
- );
- } catch (error) {
- log.error('External ACP backend is misconfigured', error);
+ if (externalBackendEnabled && externalBackend?.url) {
+ try {
+ const externalBaseUrl = normalizeAcpHttpBaseUrl(externalBackend.url);
+ trustedExternalHostname = new URL(externalBaseUrl).hostname;
+ pinnedCertFingerprint = externalBackend.certFingerprint
+ ? normalizeFingerprint(externalBackend.certFingerprint)
+ : null;
+
+ const externalBackendReady = await checkServerStatus(
+ createMainProcessBackendClient(externalBaseUrl, serverSecret),
+ []
+ );
+ if (!externalBackendReady) {
const response = dialog.showMessageBoxSync({
type: 'error',
- title: 'External Backend Misconfigured',
- message: 'The external backend URL is invalid.',
- detail: errorMessage(error),
+ title: 'External Backend Unreachable',
+ message: `Could not connect to external backend at ${externalBaseUrl}`,
+ detail: 'The external backend must be running and expose /status at the configured URL.',
buttons: ['Disable External Backend & Retry', 'Quit'],
defaultId: 0,
cancelId: 1,
@@ -1092,86 +1001,70 @@ const createChat = async (
app.quit();
return;
}
- } else {
- trustedExternalHostname = null;
- pinnedCertFingerprint = null;
- let gooseServeResult: Awaited>;
- try {
- gooseServeResult = await startGooseServe({
- serverSecret,
- dir: workingDir,
- env: {
- GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
- },
- isPackaged: app.isPackaged,
- resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
- logger: log,
- diagnosticsDir: STARTUP_LOGS_DIR,
- });
- } catch (error) {
- log.error('goose serve failed to start', error);
- dialog.showMessageBoxSync({
- type: 'error',
- title: 'Goose Failed to Start',
- message: 'The backend server failed to start.',
- detail: [
- 'Backend: goose serve',
- 'Readiness check: plain GET /status',
- `Startup error:\n${errorMessage(error)}`,
- ].join('\n\n'),
- buttons: ['OK'],
+ gooseServeLease = gooseServeLeases.createExternal(
+ acpWebSocketUrlFromHttpBase(externalBaseUrl, serverSecret)
+ );
+ } catch (error) {
+ log.error('External ACP backend is misconfigured', error);
+ const response = dialog.showMessageBoxSync({
+ type: 'error',
+ title: 'External Backend Misconfigured',
+ message: 'The external backend URL is invalid.',
+ detail: errorMessage(error),
+ buttons: ['Disable External Backend & Retry', 'Quit'],
+ defaultId: 0,
+ cancelId: 1,
+ });
+
+ if (response === 0) {
+ updateSettings((s) => {
+ if (s.externalGoosed) {
+ s.externalGoosed.enabled = false;
+ }
});
- app.quit();
- return;
+ return createChat(app, options);
}
- workingDir = gooseServeResult.workingDir;
- gooseServeLease = gooseServeLeases.create(gooseServeResult);
+ app.quit();
+ return;
}
} else {
- // Update the cached trusted-external-hostname so the TLS handlers allow
- // connections to the configured remote backend.
- if (settings.externalGoosed?.enabled && settings.externalGoosed.url) {
- try {
- trustedExternalHostname = new URL(settings.externalGoosed.url).hostname;
- } catch {
- trustedExternalHostname = null;
- }
- } else {
- trustedExternalHostname = null;
- }
+ trustedExternalHostname = null;
+ pinnedCertFingerprint = null;
- // If the user provided a cert fingerprint for the external backend, pin it
- // directly (skips TOFU). Otherwise reset so the first handshake pins via TOFU.
- if (settings.externalGoosed?.enabled && settings.externalGoosed.certFingerprint) {
- pinnedCertFingerprint = normalizeFingerprint(settings.externalGoosed.certFingerprint);
- } else {
- pinnedCertFingerprint = null;
- }
-
- goosedResult = await startGoosed({
- serverSecret,
- dir: workingDir,
- env: {
- GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
- },
- externalGoosed: settings.externalGoosed,
- isPackaged: app.isPackaged,
- resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
- logger: log,
- diagnosticsDir: STARTUP_LOGS_DIR,
- });
-
- // For locally-spawned goosed, pin using the fingerprint from stdout.
- // For external backends the TOFU path in the cert handlers will pin
- // the fingerprint on the first successful TLS handshake.
- if (goosedResult.certFingerprint) {
- pinnedCertFingerprint = goosedResult.certFingerprint;
+ let gooseServeResult: Awaited>;
+ try {
+ gooseServeResult = await startGooseServe({
+ serverSecret,
+ dir: workingDir,
+ env: {
+ GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
+ },
+ isPackaged: app.isPackaged,
+ resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
+ logger: log,
+ diagnosticsDir: STARTUP_LOGS_DIR,
+ });
+ } catch (error) {
+ log.error('goose serve failed to start', error);
+ dialog.showMessageBoxSync({
+ type: 'error',
+ title: 'Goose Failed to Start',
+ message: 'The backend server failed to start.',
+ detail: [
+ 'Backend: goose serve',
+ 'Readiness check: plain GET /status',
+ `Startup error:\n${errorMessage(error)}`,
+ ].join('\n\n'),
+ buttons: ['OK'],
+ });
+ app.quit();
+ return;
}
- baseUrl = goosedResult.baseUrl;
- workingDir = goosedResult.workingDir;
+ workingDir = gooseServeResult.workingDir;
+ gooseServeLease = gooseServeLeases.create(gooseServeResult);
}
const cleanupUnregisteredGooseServeLease = async () => {
@@ -1258,92 +1151,6 @@ const createChat = async (
.catch((err) => log.info('failed to install react dev tools:', err));
}
- if (goosedResult) {
- const {
- errorLog,
- stopErrorLogCollection,
- startupDiagnosticsPath,
- getStartupDiagnostics,
- recordStartupEvent,
- } = goosedResult;
-
- // Re-create the client with Electron's net.fetch so requests to the local
- // self-signed HTTPS server go through the session's certificate handling.
- const goosedClient = createMainProcessBackendClient(baseUrl, serverSecret);
- const goosedLease: GoosedLease = {
- client: goosedClient,
- cleanup: goosedResult.cleanup,
- windowIds: new Set(),
- cleanedUp: false,
- };
- attachWindowToGoosedLease(mainWindow.id, goosedLease);
- mainWindow.once('closed', () => {
- void releaseWindowGoosedLease(mainWindow.id);
- });
-
- const serverReady = await checkServerStatus(goosedClient, errorLog, {
- onEvent: recordStartupEvent,
- });
- if (!serverReady) {
- const isUsingExternalBackend = settings.externalGoosed?.enabled;
- const diagnostics = getStartupDiagnostics();
- const stderrTail = diagnostics?.stderrTail ?? [];
- const failureDetailParts = [
- diagnostics?.childExitCode !== null || diagnostics?.childExitSignal
- ? `Child exit: code=${diagnostics?.childExitCode ?? 'null'} signal=${diagnostics?.childExitSignal ?? 'null'}`
- : 'Child exit: unavailable',
- diagnostics?.certFingerprintSeen
- ? 'TLS fingerprint observed: yes'
- : 'TLS fingerprint observed: no',
- diagnostics?.healthCheckSucceeded
- ? 'Health check observed: yes'
- : 'Health check observed: no',
- startupDiagnosticsPath ? `Startup diagnostics: ${startupDiagnosticsPath}` : '',
- errorLog.length > 0 ? `Startup errors:\n${errorLog.join('\n')}` : '',
- stderrTail.length > 0 ? `Captured startup stderr:\n${stderrTail.join('\n')}` : '',
- ].filter(Boolean);
-
- if (isUsingExternalBackend) {
- const response = dialog.showMessageBoxSync({
- type: 'error',
- title: 'External Backend Unreachable',
- message: `Could not connect to external backend at ${settings.externalGoosed?.url}`,
- detail: 'The external goosed server may not be running.',
- buttons: ['Disable External Backend & Retry', 'Quit'],
- defaultId: 0,
- cancelId: 1,
- });
-
- if (response === 0) {
- updateSettings((s) => {
- if (s.externalGoosed) {
- s.externalGoosed.enabled = false;
- }
- });
- mainWindow.destroy();
- return createChat(app, { initialMessage, dir });
- }
- } else {
- dialog.showMessageBoxSync({
- type: 'error',
- title: 'Goose Failed to Start',
- message: 'The backend server failed to start.',
- detail: failureDetailParts.join('\n\n'),
- buttons: ['OK'],
- });
- }
- app.quit();
- return;
- }
-
- // errorLog is only needed during startup to detect fatal errors.
- // Stop collecting stderr to avoid unbounded memory growth over long sessions.
- stopErrorLogCollection();
- errorLog.length = 0;
- } else if (!useAcpBackend) {
- throw new Error('No desktop backend was started');
- }
-
// Let windowStateKeeper manage the window
mainWindowState.manage(mainWindow);
@@ -2026,17 +1833,7 @@ ipcMain.handle('get-acp-url', async (event) => {
if (!windowId) {
return null;
}
- const gooseServeAcpUrl = gooseServeLeases.getAcpUrl(windowId);
- if (gooseServeAcpUrl) {
- return gooseServeAcpUrl;
- }
-
- const client = goosedClients.get(windowId);
- const baseUrl = client?.getConfig().baseUrl;
- if (!baseUrl) {
- return null;
- }
- return buildAcpWebSocketUrl(baseUrl, getServerSecret(getSettings()));
+ return gooseServeLeases.getAcpUrl(windowId) ?? null;
});
// Handle menu bar icon visibility
@@ -3034,9 +2831,8 @@ async function appMain() {
}
const launchingWindowId = launchingWindow.id;
- const launchingGoosedLease = goosedLeasesByWindowId.get(launchingWindowId);
const launchingGooseServeLease = gooseServeLeases.get(launchingWindowId);
- if (!launchingGoosedLease && !launchingGooseServeLease) {
+ if (!launchingGooseServeLease) {
throw new Error('No backend lease found for launching window');
}
@@ -3064,21 +2860,12 @@ async function appMain() {
},
});
- let releaseAppWindowBackend: () => Promise;
- if (launchingGoosedLease) {
- attachWindowToGoosedLease(appWindow.id, launchingGoosedLease);
- releaseAppWindowBackend = () => releaseWindowGoosedLease(appWindow.id);
- } else if (launchingGooseServeLease) {
- gooseServeLeases.attachWindow(appWindow.id, launchingGooseServeLease);
- releaseAppWindowBackend = () => gooseServeLeases.releaseWindow(appWindow.id);
- } else {
- throw new Error('No backend lease found for launching window');
- }
+ gooseServeLeases.attachWindow(appWindow.id, launchingGooseServeLease);
appWindows.set(gooseApp.name, appWindow);
appWindow.on('closed', () => {
- void releaseAppWindowBackend();
+ void gooseServeLeases.releaseWindow(appWindow.id);
appWindows.delete(gooseApp.name);
});
@@ -3180,12 +2967,6 @@ async function getAllowList(): Promise {
}
app.on('will-quit', async () => {
- const goosedLeases = new Set(goosedLeasesByWindowId.values());
- if (goosedLeases.size > 0) {
- log.info(`App quitting, terminating ${goosedLeases.size} goosed server(s)`);
- await Promise.all([...goosedLeases].map(cleanupGoosedLease));
- }
-
const gooseServeLeaseCount = gooseServeLeases.activeLeaseCount();
if (gooseServeLeaseCount > 0) {
log.info(`App quitting, terminating ${gooseServeLeaseCount} goose serve process(es)`);
From 222d3b1dade7a21cd1cc6d84e11be700d440b715 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Wed, 1 Jul 2026 17:55:03 +1000
Subject: [PATCH 14/34] removed goosed invocation in ui
---
CONTRIBUTING.md | 7 +-
Justfile | 42 ++---
ui/desktop/package.json | 6 +-
.../scripts/prepare-platform-binaries.js | 18 +-
ui/desktop/src/backendStatus.ts | 40 +++++
.../settings/app/ExternalBackendSection.tsx | 49 ++++--
ui/desktop/src/gooseServe.ts | 22 ++-
ui/desktop/src/goosed.ts | 41 +----
ui/desktop/src/i18n/messages/en.json | 20 ++-
ui/desktop/src/main.ts | 159 +++++++++++++-----
ui/desktop/vite.main.config.mts | 3 -
11 files changed, 244 insertions(+), 163 deletions(-)
create mode 100644 ui/desktop/src/backendStatus.ts
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 49e3dcd56ea9..e76a03d71cb3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -205,11 +205,12 @@ To debug the Goose server, run it from an IDE. The configuration will depend on
```
export GOOSE_SERVER__SECRET_KEY=test
-cargo run --package goose-server --bin goosed -- agent # or: `just run-server`
+cargo run --package goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000
```
-The server listens on port `3000` by default; this can be changed by setting the
-`GOOSE_PORT` environment variable.
+The `debug-ui` recipe connects to `http://127.0.0.1:3000` by default. If the
+server uses another port, set `GOOSE_PORT` when starting the UI, or set
+`GOOSE_EXTERNAL_BACKEND_URL` to the server's HTTP base URL.
Once the server is running, start a UI and connect it to the server by running:
diff --git a/Justfile b/Justfile
index d732444a9286..c40e2b7d73c8 100644
--- a/Justfile
+++ b/Justfile
@@ -34,7 +34,7 @@ release-windows:
[windows]
release-windows:
- @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'rustup target add x86_64-pc-windows-msvc; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; cargo build --release --target x86_64-pc-windows-msvc -p goose-server; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goosed.exe"'
+ @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'rustup target add x86_64-pc-windows-msvc; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; cargo build --release --target x86_64-pc-windows-msvc -p goose-cli --bin goose; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; Write-Host "Windows executable created at ./target/x86_64-pc-windows-msvc/release/goose.exe"'
# Build for Intel Mac
release-intel:
@@ -43,14 +43,7 @@ release-intel:
@just copy-binary-intel
copy-binary BUILD_MODE="release":
- @if [ -f ./target/{{BUILD_MODE}}/goosed ]; then \
- echo "Copying goosed binary from target/{{BUILD_MODE}}..."; \
- rm -f ./ui/desktop/src/bin/goosed; \
- cp -p ./target/{{BUILD_MODE}}/goosed ./ui/desktop/src/bin/; \
- else \
- echo "Binary not found in target/{{BUILD_MODE}}"; \
- exit 1; \
- fi
+ @rm -f ./ui/desktop/src/bin/goosed
@if [ -f ./target/{{BUILD_MODE}}/goose ]; then \
echo "Copying goose CLI binary from target/{{BUILD_MODE}}..."; \
rm -f ./ui/desktop/src/bin/goose; \
@@ -62,14 +55,7 @@ copy-binary BUILD_MODE="release":
# Copy binary command for Intel build
copy-binary-intel:
- @if [ -f ./target/x86_64-apple-darwin/release/goosed ]; then \
- echo "Copying Intel goosed binary to ui/desktop/src/bin with permissions preserved..."; \
- rm -f ./ui/desktop/src/bin/goosed; \
- cp -p ./target/x86_64-apple-darwin/release/goosed ./ui/desktop/src/bin/; \
- else \
- echo "Intel release binary not found."; \
- exit 1; \
- fi
+ @rm -f ./ui/desktop/src/bin/goosed
@if [ -f ./target/x86_64-apple-darwin/release/goose ]; then \
echo "Copying Intel goose CLI binary to ui/desktop/src/bin..."; \
rm -f ./ui/desktop/src/bin/goose; \
@@ -87,10 +73,11 @@ copy-binary-windows:
[windows]
copy-binary-windows:
- @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goosed.exe) { \
+ @powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'if (Test-Path ./target/x86_64-pc-windows-msvc/release/goose.exe) { \
Write-Host "Copying Windows binary to ui/desktop/src/bin..."; \
New-Item -ItemType Directory -Force "./ui/desktop/src/bin" | Out-Null; \
- Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goosed.exe" -Destination "./ui/desktop/src/bin/" -Force; \
+ Remove-Item -Path "./ui/desktop/src/bin/goosed.exe" -Force -ErrorAction SilentlyContinue; \
+ Copy-Item -Path "./target/x86_64-pc-windows-msvc/release/goose.exe" -Destination "./ui/desktop/src/bin/" -Force; \
} else { \
Write-Host "Windows binary not found." -ForegroundColor Red; \
exit 1; \
@@ -115,18 +102,8 @@ run-ui-only:
@echo "Running UI..."
cd ui/desktop && pnpm install && pnpm run start-gui
-run-ui-acp:
- @echo "Building goose CLI for direct ACP..."
- cargo build -p goose-cli --bin goose
- @echo "Running UI with direct ACP backend..."
- cd ui/desktop && pnpm install && pnpm run start-gui:acp
-
-run-ui-acp-only:
- @echo "Running UI with direct ACP backend..."
- cd ui/desktop && pnpm install && pnpm run start-gui:acp
-
debug-ui:
- @echo "🚀 Starting goose frontend in external backend mode"
+ @echo "🚀 Starting goose frontend in external ACP backend mode"
cd ui/desktop && \
export GOOSE_EXTERNAL_BACKEND=true && \
export GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" && \
@@ -171,8 +148,8 @@ run-docs:
# Run server
run-server:
- @echo "Running server..."
- cargo run -p goose-server --bin goosed agent
+ @echo "Running external ACP backend..."
+ GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" cargo run -p goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000
# Check if OpenAPI schema is up-to-date
check-openapi-schema: generate-openapi
@@ -414,6 +391,7 @@ win-app-deps:
win-copy-win profile:
copy target{{s}}{{profile}}{{s}}*.exe ui{{s}}desktop{{s}}src{{s}}bin
copy target{{s}}{{profile}}{{s}}*.dll ui{{s}}desktop{{s}}src{{s}}bin
+ if exist ui{{s}}desktop{{s}}src{{s}}bin{{s}}goosed.exe del /f /q ui{{s}}desktop{{s}}src{{s}}bin{{s}}goosed.exe
### "Other" copy {release|debug} files to ui/desktop/src/bin
### s = os dependent file separator
diff --git a/ui/desktop/package.json b/ui/desktop/package.json
index 863c8319f354..7cfacb1da07e 100644
--- a/ui/desktop/package.json
+++ b/ui/desktop/package.json
@@ -13,10 +13,8 @@
"typecheck": "tsc --noEmit",
"generate-api": "openapi-ts",
"build-goose-sdk": "pnpm --filter @aaif/goose-sdk run build",
- "start-gui": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start",
- "start-gui:acp": "pnpm run build-goose-sdk && GOOSE_DESKTOP_BACKEND=acp pnpm run start-gui",
- "start-gui-debug": "pnpm run generate-api && pnpm run i18n:compile && electron-forge start -- --inspect=9229",
- "start-gui-debug:acp": "pnpm run build-goose-sdk && GOOSE_DESKTOP_BACKEND=acp pnpm run start-gui-debug",
+ "start-gui": "pnpm run build-goose-sdk && pnpm run generate-api && pnpm run i18n:compile && electron-forge start",
+ "start-gui-debug": "pnpm run build-goose-sdk && pnpm run generate-api && pnpm run i18n:compile && electron-forge start -- --inspect=9229",
"start": "cd ../.. && just run-ui",
"start:test-error": "GOOSE_TEST_ERROR=true electron-forge start",
"package": "pnpm run i18n:compile && electron-forge package",
diff --git a/ui/desktop/scripts/prepare-platform-binaries.js b/ui/desktop/scripts/prepare-platform-binaries.js
index 9679d908b726..5f698bcc96bb 100644
--- a/ui/desktop/scripts/prepare-platform-binaries.js
+++ b/ui/desktop/scripts/prepare-platform-binaries.js
@@ -23,17 +23,6 @@ const windowsFiles = [
'goose-npm/**/*'
];
-const macosFiles = [
- 'goosed',
- 'goose',
- 'jbang',
- 'npx',
- 'uvx',
- '*.db',
- '*.log',
- '.gitkeep'
-];
-
// Helper function to check if file matches patterns
function matchesPattern(filename, patterns) {
return patterns.some(pattern => {
@@ -174,9 +163,10 @@ function cleanBinDirectory(targetPlatform) {
const filePath = path.join(srcBinDir, file.name);
if (targetPlatform === 'darwin' || targetPlatform === 'linux') {
- // For macOS/Linux, remove Windows-specific files
- if (matchesPattern(file.name, windowsFiles)) {
- console.log(`Removing Windows file: ${file.name}`);
+ const isLegacyBackendBinary = file.name === 'goosed';
+ if (isLegacyBackendBinary || matchesPattern(file.name, windowsFiles)) {
+ const fileType = isLegacyBackendBinary ? 'legacy backend binary' : 'Windows file';
+ console.log(`Removing ${fileType}: ${file.name}`);
if (file.isDirectory()) {
fs.rmSync(filePath, { recursive: true, force: true });
} else {
diff --git a/ui/desktop/src/backendStatus.ts b/ui/desktop/src/backendStatus.ts
new file mode 100644
index 000000000000..864e3d1a3997
--- /dev/null
+++ b/ui/desktop/src/backendStatus.ts
@@ -0,0 +1,40 @@
+import { status } from './api';
+import type { Client } from './api/client';
+
+export interface CheckServerStatusOptions {
+ onEvent?: (name: string, details?: Record) => void;
+}
+
+export const isFatalError = (line: string): boolean => {
+ const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i];
+ return fatalPatterns.some((pattern) => pattern.test(line));
+};
+
+export const checkServerStatus = async (
+ client: Client,
+ errorLog: string[],
+ options: CheckServerStatusOptions = {}
+): Promise => {
+ const timeout = 30000;
+ const interval = 100;
+ const maxAttempts = Math.ceil(timeout / interval);
+ options.onEvent?.('healthcheck_start', { timeoutMs: timeout, intervalMs: interval });
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ if (errorLog.some(isFatalError)) {
+ options.onEvent?.('healthcheck_fatal_error', { attempt });
+ return false;
+ }
+
+ try {
+ await status({ client, throwOnError: true });
+ options.onEvent?.('healthcheck_success', { attempt });
+ return true;
+ } catch {
+ await new Promise((resolve) => setTimeout(resolve, interval));
+ }
+ }
+
+ options.onEvent?.('healthcheck_timeout', { timeoutMs: timeout });
+ return false;
+};
diff --git a/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx b/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx
index ae81cbd38732..408a1a35ac39 100644
--- a/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx
+++ b/ui/desktop/src/components/settings/app/ExternalBackendSection.tsx
@@ -4,30 +4,35 @@ import { Input } from '../../ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
import { AlertCircle } from 'lucide-react';
import { ExternalGoosedConfig, defaultSettings } from '../../../utils/settings';
-import { WEB_PROTOCOLS } from '../../../utils/urlSecurity';
import { defineMessages, useIntl } from '../../../i18n';
+import { normalizeAcpHttpBaseUrl } from '../../../acp/url';
const i18n = defineMessages({
title: {
id: 'externalBackendSection.title',
- defaultMessage: 'Goose Server',
+ defaultMessage: 'External Backend (ACP)',
},
description: {
id: 'externalBackendSection.description',
defaultMessage:
- 'By default goose launches a server for you, use this to connect to an external goose server',
+ 'By default Goose starts a local backend. Use this to connect to an external ACP-compatible backend.',
},
useExternalServer: {
id: 'externalBackendSection.useExternalServer',
- defaultMessage: 'Use external server',
+ defaultMessage: 'Use external backend',
},
useExternalServerDescription: {
id: 'externalBackendSection.useExternalServerDescription',
- defaultMessage: 'Connect to a goose server running elsewhere (requires app restart)',
+ defaultMessage: 'Connect to an ACP-compatible backend running elsewhere.',
},
serverUrl: {
id: 'externalBackendSection.serverUrl',
- defaultMessage: 'Server URL',
+ defaultMessage: 'Backend Base URL',
+ },
+ serverUrlHelp: {
+ id: 'externalBackendSection.serverUrlHelp',
+ defaultMessage:
+ 'Enter the HTTP(S) base URL. Goose checks /status and connects to /acp under this base.',
},
secretKey: {
id: 'externalBackendSection.secretKey',
@@ -39,7 +44,7 @@ const i18n = defineMessages({
},
secretKeyHelp: {
id: 'externalBackendSection.secretKeyHelp',
- defaultMessage: 'The secret key configured on the goosed server (GOOSE_SERVER__SECRET_KEY)',
+ defaultMessage: 'The secret key configured on the external backend (GOOSE_SERVER__SECRET_KEY).',
},
certFingerprint: {
id: 'externalBackendSection.certFingerprint',
@@ -56,7 +61,7 @@ const i18n = defineMessages({
restartNote: {
id: 'externalBackendSection.restartNote',
defaultMessage:
- 'Changes require restarting Goose to take effect. New chat windows will connect to the external server.',
+ 'Changes apply to new chat windows. Restart Goose to update existing windows.',
},
urlProtocolError: {
id: 'externalBackendSection.urlProtocolError',
@@ -70,6 +75,10 @@ const i18n = defineMessages({
id: 'externalBackendSection.urlFormatError',
defaultMessage: 'Invalid URL format',
},
+ urlBaseError: {
+ id: 'externalBackendSection.urlBaseError',
+ defaultMessage: 'URL must be the backend base URL before /acp, without query parameters or fragments',
+ },
});
export default function ExternalBackendSection() {
@@ -95,19 +104,26 @@ export default function ExternalBackendSection() {
return true;
}
try {
- const parsed = new URL(value);
- if (!WEB_PROTOCOLS.includes(parsed.protocol)) {
- setUrlError(intl.formatMessage(i18n.urlProtocolError));
- return false;
- }
+ const normalizedUrl = normalizeAcpHttpBaseUrl(value);
+ const parsed = new URL(normalizedUrl);
if (certFingerprint?.trim() && parsed.protocol !== 'https:') {
setUrlError(intl.formatMessage(i18n.fingerprintRequiresHttps));
return false;
}
setUrlError(null);
return true;
- } catch {
- setUrlError(intl.formatMessage(i18n.urlFormatError));
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '';
+ if (message.includes('http: or https:')) {
+ setUrlError(intl.formatMessage(i18n.urlProtocolError));
+ } else if (
+ message.includes('base URL before /acp') ||
+ message.includes('query parameters or fragments')
+ ) {
+ setUrlError(intl.formatMessage(i18n.urlBaseError));
+ } else {
+ setUrlError(intl.formatMessage(i18n.urlFormatError));
+ }
return false;
}
};
@@ -203,6 +219,9 @@ export default function ExternalBackendSection() {
{urlError}
)}
+
+ {intl.formatMessage(i18n.serverUrlHelp)}
+
diff --git a/ui/desktop/src/gooseServe.ts b/ui/desktop/src/gooseServe.ts
index d4466b078594..9faff4f96ea0 100644
--- a/ui/desktop/src/gooseServe.ts
+++ b/ui/desktop/src/gooseServe.ts
@@ -317,14 +317,28 @@ export const startGooseServe = async ({
});
}
- const gooseProcess = spawn(goosePath, args, {
+ const spawnOptions = {
env: buildGooseServeEnv(secretKey, goosePath, additionalEnv),
cwd: workingDir,
windowsHide: true,
detached: process.platform === 'win32',
- shell: false,
- stdio: ['ignore', 'pipe', 'pipe'],
- });
+ shell: false as const,
+ stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
+ };
+
+ const safeSpawnOptions = {
+ ...spawnOptions,
+ env: Object.fromEntries(
+ Object.entries(spawnOptions.env).map(([key, value]) =>
+ key.toLowerCase().includes('secret') || key.toLowerCase().includes('key')
+ ? [key, '[REDACTED]']
+ : [key, value]
+ )
+ ),
+ };
+ logger.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
+
+ const gooseProcess = spawn(goosePath, args, spawnOptions);
if (startupTrace) {
startupTrace.diagnostics.pid = gooseProcess.pid ?? null;
startupTrace.record('spawn_success', { pid: gooseProcess.pid ?? null });
diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts
index 340a0767d63a..c7e4cc7141fd 100644
--- a/ui/desktop/src/goosed.ts
+++ b/ui/desktop/src/goosed.ts
@@ -4,13 +4,14 @@ 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';
import {
appendTail,
createStartupDiagnostics,
type StartupDiagnostics,
} from './startupDiagnostics';
+import { isFatalError } from './backendStatus';
+export { checkServerStatus, isFatalError, type CheckServerStatusOptions } from './backendStatus';
export interface Logger {
info: (...args: unknown[]) => void;
@@ -82,44 +83,6 @@ export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string =>
);
};
-export interface CheckServerStatusOptions {
- onEvent?: (name: string, details?: Record
) => void;
-}
-
-export const checkServerStatus = async (
- client: Client,
- errorLog: string[],
- options: CheckServerStatusOptions = {}
-): Promise => {
- const timeout = 30000;
- const interval = 100;
- const maxAttempts = Math.ceil(timeout / interval);
- options.onEvent?.('healthcheck_start', { timeoutMs: timeout, intervalMs: interval });
-
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
- if (errorLog.some(isFatalError)) {
- options.onEvent?.('healthcheck_fatal_error', { attempt });
- return false;
- }
-
- try {
- await status({ client, throwOnError: true });
- options.onEvent?.('healthcheck_success', { attempt });
- return true;
- } catch {
- await new Promise((resolve) => setTimeout(resolve, interval));
- }
- }
-
- options.onEvent?.('healthcheck_timeout', { timeoutMs: timeout });
- return false;
-};
-
-export const isFatalError = (line: string): boolean => {
- const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i];
- return fatalPatterns.some((pattern) => pattern.test(line));
-};
-
export const buildGoosedEnv = (
port: number,
secretKey: string,
diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json
index 85085508b237..1218b3c90a5c 100644
--- a/ui/desktop/src/i18n/messages/en.json
+++ b/ui/desktop/src/i18n/messages/en.json
@@ -1173,28 +1173,34 @@
"defaultMessage": "AA:BB:CC:... or sha256/base64"
},
"externalBackendSection.description": {
- "defaultMessage": "By default goose launches a server for you, use this to connect to an external goose server"
+ "defaultMessage": "By default Goose starts a local backend. Use this to connect to an external ACP-compatible backend."
},
"externalBackendSection.fingerprintRequiresHttps": {
"defaultMessage": "Certificate fingerprint requires an https URL"
},
"externalBackendSection.restartNote": {
- "defaultMessage": "Changes require restarting Goose to take effect. New chat windows will connect to the external server."
+ "defaultMessage": "Changes apply to new chat windows. Restart Goose to update existing windows."
},
"externalBackendSection.secretKey": {
"defaultMessage": "Secret Key"
},
"externalBackendSection.secretKeyHelp": {
- "defaultMessage": "The secret key configured on the goosed server (GOOSE_SERVER__SECRET_KEY)"
+ "defaultMessage": "The secret key configured on the external backend (GOOSE_SERVER__SECRET_KEY)."
},
"externalBackendSection.secretKeyPlaceholder": {
"defaultMessage": "Enter the server's secret key"
},
"externalBackendSection.serverUrl": {
- "defaultMessage": "Server URL"
+ "defaultMessage": "Backend Base URL"
+ },
+ "externalBackendSection.serverUrlHelp": {
+ "defaultMessage": "Enter the HTTP(S) base URL. Goose checks /status and connects to /acp under this base."
},
"externalBackendSection.title": {
- "defaultMessage": "Goose Server"
+ "defaultMessage": "External Backend (ACP)"
+ },
+ "externalBackendSection.urlBaseError": {
+ "defaultMessage": "URL must be the backend base URL before /acp, without query parameters or fragments"
},
"externalBackendSection.urlFormatError": {
"defaultMessage": "Invalid URL format"
@@ -1203,10 +1209,10 @@
"defaultMessage": "URL must use http or https protocol"
},
"externalBackendSection.useExternalServer": {
- "defaultMessage": "Use external server"
+ "defaultMessage": "Use external backend"
},
"externalBackendSection.useExternalServerDescription": {
- "defaultMessage": "Connect to a goose server running elsewhere (requires app restart)"
+ "defaultMessage": "Connect to an ACP-compatible backend running elsewhere."
},
"goosehintsModal.close": {
"defaultMessage": "Close"
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index 4e3a6753ad74..b6e6d45adb38 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -25,7 +25,7 @@ import path from 'node:path';
import os from 'node:os';
import { execFileSync, spawn, execFile } from 'child_process';
import 'dotenv/config';
-import { checkServerStatus } from './goosed';
+import { checkServerStatus } from './backendStatus';
import { startGooseServe } from './gooseServe';
import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLeaseRegistry';
import { createClient, createConfig } from './api/client';
@@ -293,14 +293,13 @@ async function configureProxy() {
if (started) app.quit();
-// Certificate trust for goosed servers (local and external).
+// Certificate trust for backend servers.
// Both certificate-error (renderer) and setCertificateVerifyProc (main-process
-// net.fetch) pin to the exact cert fingerprint. For locally-spawned goosed the
-// fingerprint comes from its stdout; for external backends we use Trust-On-First-Use
-// (TOFU) — the first TLS handshake pins the cert for the lifetime of the process.
+// net.fetch) pin to the exact cert fingerprint. External backends use
+// Trust-On-First-Use (TOFU) when no fingerprint is configured.
let pinnedCertFingerprint: string | null = null;
-// Cached hostname of the configured external goosed server, updated when a
+// Cached hostname of the configured external backend, updated when a
// chat is created so we don't hit the filesystem on every TLS handshake.
let trustedExternalHostname: string | null = null;
@@ -325,9 +324,7 @@ function normalizeFingerprint(fp: string): string {
return fp.toUpperCase();
}
-// Renderer requests: pin to the exact cert goosed generated once known.
-// Before the fingerprint is available (during the health-check bootstrap
-// window) any localhost cert is accepted so the server can come up.
+// Renderer requests: pin to the exact cert once known.
app.on('certificate-error', (event, _webContents, url, _error, certificate, callback) => {
const parsed = new URL(url);
if (!isTrustedHost(parsed.hostname)) {
@@ -351,7 +348,7 @@ app.whenReady().then(() => {
appConfig.GOOSE_LOCALE = getConfiguredGooseLocale();
});
-// Main-process net.fetch: pin to the exact cert goosed generated.
+// Main-process net.fetch: pin to the exact cert once known.
app.whenReady().then(() => {
session.defaultSession.setCertificateVerifyProc((request, callback) => {
if (!isTrustedHost(request.hostname)) {
@@ -842,22 +839,85 @@ const resolveGoosePathRoot = (): string | undefined => {
const GENERATED_SECRET = crypto.randomBytes(32).toString('hex');
+interface ExternalBackend {
+ source: 'env' | 'settings';
+ url: string;
+ secret: string;
+ certFingerprint?: string;
+}
+
+const getExternalBackendUrlFromEnv = (): string | null => {
+ if (!process.env.GOOSE_EXTERNAL_BACKEND) {
+ return null;
+ }
+
+ const configuredUrl = process.env.GOOSE_EXTERNAL_BACKEND_URL?.trim();
+ if (configuredUrl) {
+ return configuredUrl;
+ }
+
+ return `http://127.0.0.1:${process.env.GOOSE_PORT || '3000'}`;
+};
+
+const getExternalBackendFromEnv = (): ExternalBackend | null => {
+ const url = getExternalBackendUrlFromEnv();
+ if (!url) {
+ return null;
+ }
+
+ const secret = process.env.GOOSE_SERVER__SECRET_KEY;
+ if (!secret) {
+ throw new Error(
+ 'GOOSE_SERVER__SECRET_KEY must be set when using GOOSE_EXTERNAL_BACKEND. ' +
+ 'Set it to the same value on both the server and the desktop client.'
+ );
+ }
+
+ return {
+ source: 'env',
+ url,
+ secret,
+ };
+};
+
const getServerSecret = (settings: Settings): string => {
if (settings.externalGoosed?.enabled && settings.externalGoosed.secret) {
return settings.externalGoosed.secret;
}
- if (process.env.GOOSE_EXTERNAL_BACKEND) {
- if (!process.env.GOOSE_SERVER__SECRET_KEY) {
- throw new Error(
- 'GOOSE_SERVER__SECRET_KEY must be set when using GOOSE_EXTERNAL_BACKEND. ' +
- 'Set it to the same value on both the server and the desktop client.'
- );
- }
- return process.env.GOOSE_SERVER__SECRET_KEY;
- }
return GENERATED_SECRET;
};
+const getActiveExternalBackend = (settings: Settings): ExternalBackend | null => {
+ const envBackend = getExternalBackendFromEnv();
+ if (envBackend) {
+ return envBackend;
+ }
+
+ if (settings.externalGoosed?.enabled && settings.externalGoosed.url) {
+ return {
+ source: 'settings',
+ url: settings.externalGoosed.url,
+ secret: getServerSecret(settings),
+ certFingerprint: settings.externalGoosed.certFingerprint,
+ };
+ }
+
+ return null;
+};
+
+const getExternalBackendForCsp = (settings: Settings) => {
+ const envUrl = getExternalBackendUrlFromEnv();
+ if (!envUrl) {
+ return settings.externalGoosed;
+ }
+
+ return {
+ ...settings.externalGoosed,
+ enabled: true,
+ url: envUrl,
+ };
+};
+
const createMainProcessBackendClient = (baseUrl: string, serverSecret: string): Client =>
createClient(
createConfig({
@@ -923,10 +983,23 @@ const createChat = async (
} = options;
const settings = getSettings();
- // `externalGoosed` is a legacy name kept for on-disk settings compatibility;
- // the remote backend it points at is now an ACP server, not goosed.
- if (settings.externalGoosed?.enabled && settings.externalGoosed.certFingerprint) {
- const url = settings.externalGoosed.url;
+ let externalBackend: ExternalBackend | null;
+ try {
+ externalBackend = getActiveExternalBackend(settings);
+ } catch (error) {
+ dialog.showMessageBoxSync({
+ type: 'error',
+ title: 'External Backend Misconfigured',
+ message: 'The external backend environment is invalid.',
+ detail: errorMessage(error),
+ buttons: ['Quit'],
+ });
+ app.quit();
+ return;
+ }
+
+ if (externalBackend?.certFingerprint) {
+ const url = externalBackend.url;
const usesHttps = (() => {
try {
return new URL(url).protocol === 'https:';
@@ -960,13 +1033,11 @@ const createChat = async (
}
}
- const externalBackend = settings.externalGoosed;
- const externalBackendEnabled = Boolean(externalBackend?.enabled && externalBackend.url);
- const serverSecret = externalBackendEnabled ? getServerSecret(settings) : GENERATED_SECRET;
+ const serverSecret = externalBackend ? externalBackend.secret : GENERATED_SECRET;
let workingDir = dir || os.homedir();
let gooseServeLease: GooseServeLease | null = null;
- if (externalBackendEnabled && externalBackend?.url) {
+ if (externalBackend) {
try {
const externalBaseUrl = normalizeAcpHttpBaseUrl(externalBackend.url);
trustedExternalHostname = new URL(externalBaseUrl).hostname;
@@ -979,17 +1050,21 @@ const createChat = async (
[]
);
if (!externalBackendReady) {
+ const canDisableExternalBackend = externalBackend.source === 'settings';
const response = dialog.showMessageBoxSync({
type: 'error',
title: 'External Backend Unreachable',
message: `Could not connect to external backend at ${externalBaseUrl}`,
- detail: 'The external backend must be running and expose /status at the configured URL.',
- buttons: ['Disable External Backend & Retry', 'Quit'],
+ detail:
+ 'The external backend must be running and expose /status under the configured base URL.',
+ buttons: canDisableExternalBackend
+ ? ['Disable External Backend & Retry', 'Quit']
+ : ['Quit'],
defaultId: 0,
- cancelId: 1,
+ cancelId: canDisableExternalBackend ? 1 : 0,
});
- if (response === 0) {
+ if (canDisableExternalBackend && response === 0) {
updateSettings((s) => {
if (s.externalGoosed) {
s.externalGoosed.enabled = false;
@@ -1007,17 +1082,20 @@ const createChat = async (
);
} catch (error) {
log.error('External ACP backend is misconfigured', error);
+ const canDisableExternalBackend = externalBackend.source === 'settings';
const response = dialog.showMessageBoxSync({
type: 'error',
title: 'External Backend Misconfigured',
message: 'The external backend URL is invalid.',
detail: errorMessage(error),
- buttons: ['Disable External Backend & Retry', 'Quit'],
+ buttons: canDisableExternalBackend
+ ? ['Disable External Backend & Retry', 'Quit']
+ : ['Quit'],
defaultId: 0,
- cancelId: 1,
+ cancelId: canDisableExternalBackend ? 1 : 0,
});
- if (response === 0) {
+ if (canDisableExternalBackend && response === 0) {
updateSettings((s) => {
if (s.externalGoosed) {
s.externalGoosed.enabled = false;
@@ -1822,10 +1900,7 @@ ipcMain.handle('set-setting', (_event, key: SettingKey, value: unknown) => {
ipcMain.handle('get-secret-key', () => {
const settings = getSettings();
- if (settings.externalGoosed?.enabled && settings.externalGoosed.url) {
- return getServerSecret(settings);
- }
- return GENERATED_SECRET;
+ return getActiveExternalBackend(settings)?.secret ?? GENERATED_SECRET;
});
ipcMain.handle('get-acp-url', async (event) => {
@@ -2286,14 +2361,14 @@ async function appMain() {
}
});
- // Add CSP headers to all sessions — recomputed on every response so that
- // changes to externalGoosed settings take effect without restarting the app.
+ // Add CSP headers to all sessions, recomputed on every response so external
+ // backend settings take effect without restarting the app.
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const currentSettings = getSettings();
callback({
responseHeaders: {
...details.responseHeaders,
- 'Content-Security-Policy': buildCSP(currentSettings.externalGoosed),
+ 'Content-Security-Policy': buildCSP(getExternalBackendForCsp(currentSettings)),
},
});
});
@@ -2969,7 +3044,7 @@ async function getAllowList(): Promise {
app.on('will-quit', async () => {
const gooseServeLeaseCount = gooseServeLeases.activeLeaseCount();
if (gooseServeLeaseCount > 0) {
- log.info(`App quitting, terminating ${gooseServeLeaseCount} goose serve process(es)`);
+ log.info(`App quitting, cleaning up ${gooseServeLeaseCount} backend lease(s)`);
await gooseServeLeases.cleanupAll();
}
diff --git a/ui/desktop/vite.main.config.mts b/ui/desktop/vite.main.config.mts
index 9afcaac6ae64..2d3f89e8610a 100644
--- a/ui/desktop/vite.main.config.mts
+++ b/ui/desktop/vite.main.config.mts
@@ -6,8 +6,5 @@ export default defineConfig({
'process.env.GITHUB_OWNER': JSON.stringify(process.env.GITHUB_OWNER || 'aaif-goose'),
'process.env.GITHUB_REPO': JSON.stringify(process.env.GITHUB_REPO || 'goose'),
'process.env.GOOSE_BUNDLE_NAME': JSON.stringify(process.env.GOOSE_BUNDLE_NAME || 'Goose'),
- 'process.env.GOOSE_DESKTOP_BACKEND': JSON.stringify(
- process.env.GOOSE_DESKTOP_BACKEND || 'goosed'
- ),
},
});
From 02efeb2eb662cb696dda06464cd756408bfab701 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Wed, 1 Jul 2026 18:05:17 +1000
Subject: [PATCH 15/34] removed dependency on openapi type client and status
---
ui/desktop/src/backendStatus.ts | 48 ++++++++++++++++++++++++---------
ui/desktop/src/goosed.ts | 33 ++++++++++++++++++++++-
ui/desktop/src/main.ts | 25 +++++------------
3 files changed, 74 insertions(+), 32 deletions(-)
diff --git a/ui/desktop/src/backendStatus.ts b/ui/desktop/src/backendStatus.ts
index 864e3d1a3997..c3cd4e8d94e6 100644
--- a/ui/desktop/src/backendStatus.ts
+++ b/ui/desktop/src/backendStatus.ts
@@ -1,23 +1,39 @@
-import { status } from './api';
-import type { Client } from './api/client';
-
export interface CheckServerStatusOptions {
onEvent?: (name: string, details?: Record) => void;
}
+export interface CheckBackendStatusParams {
+ baseUrl: string;
+ serverSecret: string;
+ fetch: typeof globalThis.fetch;
+ errorLog?: string[];
+ options?: CheckServerStatusOptions;
+}
+
export const isFatalError = (line: string): boolean => {
const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i];
return fatalPatterns.some((pattern) => pattern.test(line));
};
-export const checkServerStatus = async (
- client: Client,
- errorLog: string[],
- options: CheckServerStatusOptions = {}
-): Promise => {
+const statusUrlFromBase = (baseUrl: string): string => {
+ const url = new URL(baseUrl);
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/status`;
+ url.search = '';
+ url.hash = '';
+ return url.toString();
+};
+
+export const checkBackendStatus = async ({
+ baseUrl,
+ serverSecret,
+ fetch,
+ errorLog = [],
+ options = {},
+}: CheckBackendStatusParams): Promise => {
const timeout = 30000;
const interval = 100;
const maxAttempts = Math.ceil(timeout / interval);
+ const statusUrl = statusUrlFromBase(baseUrl);
options.onEvent?.('healthcheck_start', { timeoutMs: timeout, intervalMs: interval });
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -27,12 +43,20 @@ export const checkServerStatus = async (
}
try {
- await status({ client, throwOnError: true });
- options.onEvent?.('healthcheck_success', { attempt });
- return true;
+ const response = await fetch(statusUrl, {
+ headers: {
+ 'X-Secret-Key': serverSecret,
+ },
+ });
+ if (response.ok) {
+ options.onEvent?.('healthcheck_success', { attempt });
+ return true;
+ }
} catch {
- await new Promise((resolve) => setTimeout(resolve, interval));
+ // Retry until the backend is ready or the timeout expires.
}
+
+ await new Promise((resolve) => setTimeout(resolve, interval));
}
options.onEvent?.('healthcheck_timeout', { timeoutMs: timeout });
diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts
index c7e4cc7141fd..afaf7607c4f6 100644
--- a/ui/desktop/src/goosed.ts
+++ b/ui/desktop/src/goosed.ts
@@ -4,6 +4,7 @@ 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';
import {
appendTail,
@@ -11,7 +12,8 @@ import {
type StartupDiagnostics,
} from './startupDiagnostics';
import { isFatalError } from './backendStatus';
-export { checkServerStatus, isFatalError, type CheckServerStatusOptions } from './backendStatus';
+export { isFatalError, type CheckServerStatusOptions } from './backendStatus';
+import type { CheckServerStatusOptions } from './backendStatus';
export interface Logger {
info: (...args: unknown[]) => void;
@@ -36,6 +38,35 @@ export const findAvailablePort = (): Promise => {
});
};
+export const checkServerStatus = async (
+ client: Client,
+ errorLog: string[],
+ options: CheckServerStatusOptions = {}
+): Promise => {
+ const timeout = 30000;
+ const interval = 100;
+ const maxAttempts = Math.ceil(timeout / interval);
+ options.onEvent?.('healthcheck_start', { timeoutMs: timeout, intervalMs: interval });
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ if (errorLog.some(isFatalError)) {
+ options.onEvent?.('healthcheck_fatal_error', { attempt });
+ return false;
+ }
+
+ try {
+ await status({ client, throwOnError: true });
+ options.onEvent?.('healthcheck_success', { attempt });
+ return true;
+ } catch {
+ await new Promise((resolve) => setTimeout(resolve, interval));
+ }
+ }
+
+ options.onEvent?.('healthcheck_timeout', { timeoutMs: timeout });
+ return false;
+};
+
export interface FindBinaryOptions {
isPackaged?: boolean;
resourcesPath?: string;
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index b6e6d45adb38..aaa48375214b 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -25,10 +25,9 @@ import path from 'node:path';
import os from 'node:os';
import { execFileSync, spawn, execFile } from 'child_process';
import 'dotenv/config';
-import { checkServerStatus } from './backendStatus';
+import { checkBackendStatus } from './backendStatus';
import { startGooseServe } from './gooseServe';
import { GooseServeLeaseRegistry, type GooseServeLease } from './gooseServeLeaseRegistry';
-import { createClient, createConfig } from './api/client';
import { acpWebSocketUrlFromHttpBase, normalizeAcpHttpBaseUrl } from './acp/url';
import { expandTilde } from './utils/pathUtils';
import log from './utils/logger';
@@ -51,7 +50,6 @@ import {
} from './utils/autoUpdater';
import { UPDATES_ENABLED } from './updates';
import './utils/recipeHash';
-import { Client } from './api/client';
import type { GooseApp } from './types/apps';
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity';
@@ -918,18 +916,6 @@ const getExternalBackendForCsp = (settings: Settings) => {
};
};
-const createMainProcessBackendClient = (baseUrl: string, serverSecret: string): Client =>
- createClient(
- createConfig({
- baseUrl,
- fetch: net.fetch as unknown as typeof globalThis.fetch,
- headers: {
- 'Content-Type': 'application/json',
- 'X-Secret-Key': serverSecret,
- },
- })
- );
-
let appConfig = {
GOOSE_DEFAULT_PROVIDER: defaultProvider,
GOOSE_DEFAULT_MODEL: defaultModel,
@@ -1045,10 +1031,11 @@ const createChat = async (
? normalizeFingerprint(externalBackend.certFingerprint)
: null;
- const externalBackendReady = await checkServerStatus(
- createMainProcessBackendClient(externalBaseUrl, serverSecret),
- []
- );
+ const externalBackendReady = await checkBackendStatus({
+ baseUrl: externalBaseUrl,
+ serverSecret,
+ fetch: net.fetch as unknown as typeof globalThis.fetch,
+ });
if (!externalBackendReady) {
const canDisableExternalBackend = externalBackend.source === 'settings';
const response = dialog.showMessageBoxSync({
From 37c1b19eb335d63a5d7b5ae443432aa4b416311c Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Wed, 1 Jul 2026 18:57:04 +1000
Subject: [PATCH 16/34] enabled https/wss in local
---
Justfile | 2 +-
ui/desktop/src/gooseServe.test.ts | 204 +++++++++++++++++-
ui/desktop/src/gooseServe.ts | 196 ++++++++++++++---
.../src/gooseServeLeaseRegistry.test.ts | 1 +
ui/desktop/src/main.ts | 21 +-
5 files changed, 397 insertions(+), 27 deletions(-)
diff --git a/Justfile b/Justfile
index c40e2b7d73c8..c6a9fd25f4e7 100644
--- a/Justfile
+++ b/Justfile
@@ -21,7 +21,7 @@ check-everything:
# Default release command
release-binary:
@echo "Building release version..."
- cargo build --release
+ cargo build --release -p goose-cli --bin goose
@just copy-binary
@echo "Generating OpenAPI schema..."
cargo run -p goose-server --bin generate_schema
diff --git a/ui/desktop/src/gooseServe.test.ts b/ui/desktop/src/gooseServe.test.ts
index ad1313dc9b45..63f12437dc09 100644
--- a/ui/desktop/src/gooseServe.test.ts
+++ b/ui/desktop/src/gooseServe.test.ts
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { findGooseBinaryPath } from './gooseServe';
+import { buildLocalServeUrls, findGooseBinaryPath, startGooseServe } from './gooseServe';
const binaryName = process.platform === 'win32' ? 'goose.exe' : 'goose';
const tempDirs: string[] = [];
@@ -21,6 +21,23 @@ function makeFile(filePath: string): string {
return filePath;
}
+function makeExecutable(filePath: string, contents: string): string {
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, contents);
+ fs.chmodSync(filePath, 0o755);
+ return filePath;
+}
+
+async function waitForFileLines(filePath: string): Promise {
+ for (let attempt = 0; attempt < 50; attempt += 1) {
+ if (fs.existsSync(filePath)) {
+ return fs.readFileSync(filePath, 'utf8').trim().split('\n');
+ }
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ throw new Error(`Timed out waiting for ${filePath}`);
+}
+
describe('findGooseBinaryPath', () => {
afterEach(() => {
vi.unstubAllEnvs();
@@ -75,3 +92,188 @@ describe('findGooseBinaryPath', () => {
expect(findGooseBinaryPath({ isPackaged: true, resourcesPath })).toBe(bundledPath);
});
});
+
+describe('buildLocalServeUrls', () => {
+ it('builds HTTP and WS URLs', () => {
+ expect(buildLocalServeUrls(1234, 'secret', 'http')).toEqual({
+ httpBaseUrl: 'http://127.0.0.1:1234',
+ statusUrl: 'http://127.0.0.1:1234/status',
+ healthUrl: 'http://127.0.0.1:1234/health',
+ acpUrl: 'ws://127.0.0.1:1234/acp?token=secret',
+ redactedAcpUrl: 'ws://127.0.0.1:1234/acp?token=REDACTED',
+ });
+ });
+
+ it('builds HTTPS and WSS URLs', () => {
+ expect(buildLocalServeUrls(1234, 'secret', 'https')).toEqual({
+ httpBaseUrl: 'https://127.0.0.1:1234',
+ statusUrl: 'https://127.0.0.1:1234/status',
+ healthUrl: 'https://127.0.0.1:1234/health',
+ acpUrl: 'wss://127.0.0.1:1234/acp?token=secret',
+ redactedAcpUrl: 'wss://127.0.0.1:1234/acp?token=REDACTED',
+ });
+ });
+});
+
+describe('startGooseServe', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ process.chdir(originalCwd);
+
+ while (tempDirs.length > 0) {
+ const tempDir = tempDirs.pop();
+ if (tempDir) {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+ }
+ });
+
+ it.skipIf(process.platform === 'win32')('uses the injected readiness fetch', async () => {
+ const tempDir = makeTempDir();
+ const goosePath = makeExecutable(
+ path.join(tempDir, 'goose'),
+ '#!/usr/bin/env sh\nwhile true; do sleep 1; done\n'
+ );
+ vi.stubEnv('GOOSE_BINARY', goosePath);
+
+ const readinessUrls: string[] = [];
+ const readinessFetch = vi.fn(async (input: string, _init?: RequestInit) => {
+ readinessUrls.push(input);
+ return new Response(null, { status: 200 });
+ });
+
+ const result = await startGooseServe({
+ serverSecret: 'test-secret',
+ dir: tempDir,
+ readinessFetch,
+ });
+
+ try {
+ expect(readinessFetch).toHaveBeenCalledTimes(1);
+ expect(readinessUrls[0]).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/status$/);
+ } finally {
+ await result.cleanup();
+ }
+ });
+
+ it.skipIf(process.platform === 'win32')('captures the TLS fingerprint from stdout', async () => {
+ const tempDir = makeTempDir();
+ const goosePath = makeExecutable(
+ path.join(tempDir, 'goose'),
+ [
+ '#!/usr/bin/env sh',
+ 'printf "GOOSED_CERT_FINGERPRINT=AA:BB:CC\\n"',
+ 'while true; do sleep 1; done',
+ '',
+ ].join('\n')
+ );
+ vi.stubEnv('GOOSE_BINARY', goosePath);
+
+ let fingerprintLogged!: () => void;
+ const fingerprintSeen = new Promise((resolve) => {
+ fingerprintLogged = resolve;
+ });
+ const logger = {
+ info: vi.fn((message: unknown) => {
+ if (String(message).includes('Pinned cert fingerprint')) {
+ fingerprintLogged();
+ }
+ }),
+ error: vi.fn(),
+ };
+ const readinessFetch = vi.fn(async () => {
+ await fingerprintSeen;
+ return new Response(null, { status: 200 });
+ });
+
+ const result = await startGooseServe({
+ serverSecret: 'test-secret',
+ dir: tempDir,
+ logger,
+ readinessFetch,
+ });
+
+ try {
+ expect(result.certFingerprint).toBe('AA:BB:CC');
+ } finally {
+ await result.cleanup();
+ }
+ });
+
+ it.skipIf(process.platform === 'win32')('uses TLS URLs and args when TLS is enabled', async () => {
+ const tempDir = makeTempDir();
+ const argsPath = path.join(tempDir, 'args.txt');
+ const goosePath = makeExecutable(
+ path.join(tempDir, 'goose'),
+ [
+ '#!/usr/bin/env sh',
+ 'printf "%s\\n" "$@" > "$TEST_ARGS_PATH"',
+ 'printf "GOOSED_CERT_FINGERPRINT=DD:EE:FF\\n"',
+ 'while true; do sleep 1; done',
+ '',
+ ].join('\n')
+ );
+ vi.stubEnv('GOOSE_BINARY', goosePath);
+
+ const readinessUrls: string[] = [];
+ const logger = {
+ info: vi.fn(),
+ error: vi.fn(),
+ };
+ const readinessFetch = vi.fn(async (input: string, _init?: RequestInit) => {
+ readinessUrls.push(input);
+ return new Response(null, { status: 200 });
+ });
+
+ const result = await startGooseServe({
+ serverSecret: 'test-secret',
+ dir: tempDir,
+ tls: true,
+ env: {
+ TEST_ARGS_PATH: argsPath,
+ },
+ logger,
+ readinessFetch,
+ });
+
+ try {
+ expect(readinessUrls[0]).toMatch(/^https:\/\/127\.0\.0\.1:\d+\/status$/);
+ expect(result.acpUrl).toMatch(/^wss:\/\/127\.0\.0\.1:\d+\/acp\?token=test-secret$/);
+ expect(result.certFingerprint).toBe('DD:EE:FF');
+ await expect(waitForFileLines(argsPath)).resolves.toContain('--tls');
+ } finally {
+ await result.cleanup();
+ }
+ });
+
+ it.skipIf(process.platform === 'win32')('waits for TLS fingerprint after readiness succeeds', async () => {
+ const tempDir = makeTempDir();
+ const goosePath = makeExecutable(
+ path.join(tempDir, 'goose'),
+ [
+ '#!/usr/bin/env sh',
+ 'sleep 0.1',
+ 'printf "GOOSED_CERT_FINGERPRINT=11:22:33\\n"',
+ 'while true; do sleep 1; done',
+ '',
+ ].join('\n')
+ );
+ vi.stubEnv('GOOSE_BINARY', goosePath);
+
+ const readinessFetch = vi.fn(async () => new Response(null, { status: 200 }));
+
+ const result = await startGooseServe({
+ serverSecret: 'test-secret',
+ dir: tempDir,
+ tls: true,
+ readinessFetch,
+ });
+
+ try {
+ expect(readinessFetch).toHaveBeenCalled();
+ expect(result.certFingerprint).toBe('11:22:33');
+ } finally {
+ await result.cleanup();
+ }
+ });
+});
diff --git a/ui/desktop/src/gooseServe.ts b/ui/desktop/src/gooseServe.ts
index 9faff4f96ea0..7bfa938abece 100644
--- a/ui/desktop/src/gooseServe.ts
+++ b/ui/desktop/src/gooseServe.ts
@@ -24,12 +24,16 @@ export interface FindGooseBinaryOptions {
resourcesPath?: string;
}
+type ReadinessFetch = (input: string, init?: RequestInit) => Promise;
+
export interface StartGooseServeOptions extends FindGooseBinaryOptions {
dir?: string;
serverSecret: string;
+ tls?: boolean;
env?: Record;
logger?: Logger;
diagnosticsDir?: string;
+ readinessFetch?: ReadinessFetch;
}
export interface GooseServeResult {
@@ -37,6 +41,7 @@ export interface GooseServeResult {
workingDir: string;
process: ChildProcess;
errorLog: string[];
+ certFingerprint: string | null;
cleanup: () => Promise;
hasExited: () => boolean;
getExitDetails: () => { code: number | null; signal: NodeJS.Signals | null };
@@ -125,12 +130,18 @@ const appendErrorTail = (target: string[], lines: string[], maxLines = 100): voi
}
};
-const fetchStatus = async (statusUrl: string): Promise => {
+const CERT_FINGERPRINT_PREFIX = 'GOOSED_CERT_FINGERPRINT=';
+const TLS_FINGERPRINT_TIMEOUT_MS = 5000;
+
+const fetchStatus = async (
+ statusUrl: string,
+ readinessFetch: ReadinessFetch
+): Promise => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1000);
try {
- const response = await fetch(statusUrl, { signal: controller.signal });
+ const response = await readinessFetch(statusUrl, { signal: controller.signal });
return response.ok;
} catch {
return false;
@@ -139,12 +150,31 @@ const fetchStatus = async (statusUrl: string): Promise => {
}
};
+const waitForFingerprint = async (
+ fingerprintReady: Promise,
+ timeoutMs: number
+): Promise => {
+ let timeout: ReturnType | undefined;
+ const timeoutPromise = new Promise((resolve) => {
+ timeout = setTimeout(() => resolve(null), timeoutMs);
+ });
+
+ try {
+ return await Promise.race([fingerprintReady, timeoutPromise]);
+ } finally {
+ if (timeout) {
+ clearTimeout(timeout);
+ }
+ }
+};
+
const waitForGooseServeReady = async (
statusUrl: string,
errorLog: string[],
shouldStopWaiting: () => boolean,
options: {
healthUrl: string;
+ readinessFetch: ReadinessFetch;
onEvent?: (name: string, details?: Record) => void;
}
): Promise => {
@@ -152,7 +182,7 @@ const waitForGooseServeReady = async (
const interval = 100;
const deadline = Date.now() + timeout;
const probeDetails = {
- transport: 'plain-http',
+ transport: statusUrl.startsWith('https:') ? 'https' : 'plain-http',
method: 'GET',
path: '/status',
url: statusUrl,
@@ -185,7 +215,7 @@ const waitForGooseServeReady = async (
return false;
}
- if (await fetchStatus(statusUrl)) {
+ if (await fetchStatus(statusUrl, options.readinessFetch)) {
options.onEvent?.('healthcheck_success', {
...probeDetails,
attempt,
@@ -201,18 +231,39 @@ const waitForGooseServeReady = async (
return false;
};
-const buildAcpUrl = (port: number, token: string): string => {
- const url = new URL(`http://127.0.0.1:${port}/acp`);
- url.protocol = 'ws:';
- url.searchParams.set('token', token);
- return url.toString();
-};
+export type LocalServeScheme = 'http' | 'https';
+
+export interface LocalServeUrls {
+ httpBaseUrl: string;
+ statusUrl: string;
+ healthUrl: string;
+ acpUrl: string;
+ redactedAcpUrl: string;
+}
+
+export const buildLocalServeUrls = (
+ port: number,
+ token: string,
+ scheme: LocalServeScheme
+): LocalServeUrls => {
+ const httpBaseUrl = `${scheme}://127.0.0.1:${port}`;
+ const websocketProtocol = scheme === 'https' ? 'wss:' : 'ws:';
+
+ const acpUrl = new URL(`${httpBaseUrl}/acp`);
+ acpUrl.protocol = websocketProtocol;
+ acpUrl.searchParams.set('token', token);
-const buildRedactedAcpUrl = (port: number): string => {
- const url = new URL(`http://127.0.0.1:${port}/acp`);
- url.protocol = 'ws:';
- url.searchParams.set('token', 'REDACTED');
- return url.toString();
+ const redactedAcpUrl = new URL(`${httpBaseUrl}/acp`);
+ redactedAcpUrl.protocol = websocketProtocol;
+ redactedAcpUrl.searchParams.set('token', 'REDACTED');
+
+ return {
+ httpBaseUrl,
+ statusUrl: `${httpBaseUrl}/status`,
+ healthUrl: `${httpBaseUrl}/health`,
+ acpUrl: acpUrl.toString(),
+ redactedAcpUrl: redactedAcpUrl.toString(),
+ };
};
const errorMessage = (error: unknown): string => {
@@ -267,11 +318,13 @@ const buildGooseServeEnv = (
export const startGooseServe = async ({
dir,
serverSecret,
+ tls = false,
env: additionalEnv = {},
isPackaged,
resourcesPath,
logger = defaultLogger,
diagnosticsDir,
+ readinessFetch = fetch,
}: StartGooseServeOptions): Promise => {
const workingDir = dir || process.cwd();
const startupTrace = createGooseServeStartupDiagnostics(diagnosticsDir, workingDir);
@@ -293,13 +346,23 @@ export const startGooseServe = async ({
}
const port = await findAvailablePort();
- const httpBaseUrl = `http://127.0.0.1:${port}`;
- const statusUrl = `${httpBaseUrl}/status`;
- const healthUrl = `${httpBaseUrl}/health`;
- const acpUrl = buildAcpUrl(port, secretKey);
- const redactedAcpUrl = buildRedactedAcpUrl(port);
+ const localServeScheme: LocalServeScheme = tls ? 'https' : 'http';
+ const { httpBaseUrl, statusUrl, healthUrl, acpUrl, redactedAcpUrl } = buildLocalServeUrls(
+ port,
+ secretKey,
+ localServeScheme
+ );
const errorLog: string[] = [];
- const args = ['serve', '--platform', 'desktop', '--host', '127.0.0.1', '--port', String(port)];
+ const args = [
+ 'serve',
+ ...(tls ? ['--tls'] : []),
+ '--platform',
+ 'desktop',
+ '--host',
+ '127.0.0.1',
+ '--port',
+ String(port),
+ ];
logger.info(`Starting goose serve from: ${goosePath} on port ${port} in dir ${workingDir}`);
if (startupTrace) {
@@ -312,6 +375,7 @@ export const startGooseServe = async ({
startupTrace.record('spawn_start', {
binaryPath: goosePath,
port,
+ tls,
workingDir,
args,
});
@@ -348,8 +412,57 @@ export const startGooseServe = async ({
let spawnFailed = false;
let exitCode: number | null = null;
let exitSignal: NodeJS.Signals | null = null;
+ let certFingerprint: string | null = null;
+ let stdoutBuffer = '';
+ let stdoutCollectionStopped = false;
+ let fingerprintReadyResolved = false;
+ let resolveFingerprintReady: (fingerprint: string | null) => void = () => {};
+ const fingerprintReady = new Promise((resolve) => {
+ resolveFingerprintReady = resolve;
+ });
- gooseProcess.stdout?.resume();
+ const resolveFingerprint = (fingerprint: string | null) => {
+ if (fingerprintReadyResolved) {
+ return;
+ }
+ fingerprintReadyResolved = true;
+ resolveFingerprintReady(fingerprint);
+ };
+
+ const stopStdoutCollection = () => {
+ if (stdoutCollectionStopped) {
+ return;
+ }
+ stdoutCollectionStopped = true;
+ gooseProcess.stdout?.off('data', onStdoutData);
+ gooseProcess.stdout?.resume();
+ };
+
+ const recordCertFingerprint = (fingerprint: string) => {
+ if (!fingerprint) {
+ return;
+ }
+ certFingerprint = fingerprint;
+ logger.info(`Pinned cert fingerprint: ${certFingerprint}`);
+ startupTrace?.record('fingerprint_received', { certFingerprint });
+ resolveFingerprint(certFingerprint);
+ stopStdoutCollection();
+ };
+
+ const onStdoutData = (data: Buffer) => {
+ stdoutBuffer += data.toString();
+ const lines = stdoutBuffer.split(/\r?\n/);
+ stdoutBuffer = lines.pop() ?? '';
+
+ for (const line of lines) {
+ if (line.startsWith(CERT_FINGERPRINT_PREFIX)) {
+ recordCertFingerprint(line.slice(CERT_FINGERPRINT_PREFIX.length).trim());
+ return;
+ }
+ }
+ };
+
+ gooseProcess.stdout?.on('data', onStdoutData);
const onStderrData = (data: Buffer) => {
const lines = data.toString().split('\n');
@@ -378,6 +491,7 @@ export const startGooseServe = async ({
startupTrace.diagnostics.childExitSignal = signal;
startupTrace.record('child_exit', { code, signal });
}
+ resolveFingerprint(null);
});
gooseProcess.on('error', (error) => {
@@ -428,12 +542,18 @@ export const startGooseServe = async ({
const ready = await waitForGooseServeReady(statusUrl, errorLog, () => exited || spawnFailed, {
healthUrl,
+ readinessFetch,
onEvent: startupTrace?.record,
});
- gooseProcess.stderr?.off('data', onStderrData);
- gooseProcess.stderr?.resume();
+
+ const stopOutputCollection = () => {
+ stopStdoutCollection();
+ gooseProcess.stderr?.off('data', onStderrData);
+ gooseProcess.stderr?.resume();
+ };
if (!ready) {
+ stopOutputCollection();
await cleanup();
const exitDetails = exited
? ` Process exited with code ${exitCode} and signal ${exitSignal}.`
@@ -447,11 +567,39 @@ export const startGooseServe = async ({
);
}
+ if (tls) {
+ startupTrace?.record('fingerprint_wait_start', { timeoutMs: TLS_FINGERPRINT_TIMEOUT_MS });
+ const fingerprint = await waitForFingerprint(fingerprintReady, TLS_FINGERPRINT_TIMEOUT_MS);
+ if (!fingerprint) {
+ stopOutputCollection();
+ await cleanup();
+ const exitDetails = exited
+ ? ` Process exited with code ${exitCode} and signal ${exitSignal}.`
+ : '';
+ const stderrDetails = errorLog.length ? ` Stderr: ${errorLog.join('\n')}` : '';
+ startupTrace?.record('fingerprint_missing', {
+ timeoutMs: TLS_FINGERPRINT_TIMEOUT_MS,
+ exited,
+ exitCode,
+ exitSignal,
+ });
+ throw new Error(
+ withStartupDiagnosticsPath(
+ `goose serve did not emit TLS certificate fingerprint on ${statusUrl}.${exitDetails}${stderrDetails}`,
+ startupDiagnosticsPath
+ )
+ );
+ }
+ }
+
+ stopOutputCollection();
+
return {
acpUrl,
workingDir,
process: gooseProcess,
errorLog,
+ certFingerprint,
cleanup,
hasExited: () => exited,
getExitDetails: () => ({ code: exitCode, signal: exitSignal }),
diff --git a/ui/desktop/src/gooseServeLeaseRegistry.test.ts b/ui/desktop/src/gooseServeLeaseRegistry.test.ts
index 0dc86fa93237..152e7f9eada3 100644
--- a/ui/desktop/src/gooseServeLeaseRegistry.test.ts
+++ b/ui/desktop/src/gooseServeLeaseRegistry.test.ts
@@ -21,6 +21,7 @@ function createGooseServeResult(
workingDir: '/tmp',
process: new EventEmitter() as GooseServeResult['process'],
errorLog: [],
+ certFingerprint: null,
cleanup: vi.fn(async () => undefined),
hasExited: () => false,
getExitDetails: () => ({ code: null, signal: null }),
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index aaa48375214b..1c1489b273b6 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -1103,6 +1103,7 @@ const createChat = async (
gooseServeResult = await startGooseServe({
serverSecret,
dir: workingDir,
+ tls: true,
env: {
GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined,
},
@@ -1110,7 +1111,19 @@ const createChat = async (
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
logger: log,
diagnosticsDir: STARTUP_LOGS_DIR,
+ readinessFetch: net.fetch as unknown as typeof globalThis.fetch,
});
+ if (!gooseServeResult.certFingerprint) {
+ await gooseServeResult.cleanup();
+ throw new Error('goose serve started with TLS but did not return a certificate fingerprint');
+ }
+
+ const localCertFingerprint = normalizeFingerprint(gooseServeResult.certFingerprint);
+ if (pinnedCertFingerprint && pinnedCertFingerprint !== localCertFingerprint) {
+ await gooseServeResult.cleanup();
+ throw new Error('goose serve TLS certificate fingerprint did not match readiness probe');
+ }
+ pinnedCertFingerprint = localCertFingerprint;
} catch (error) {
log.error('goose serve failed to start', error);
dialog.showMessageBoxSync({
@@ -1119,7 +1132,7 @@ const createChat = async (
message: 'The backend server failed to start.',
detail: [
'Backend: goose serve',
- 'Readiness check: plain GET /status',
+ 'Readiness check: HTTPS GET /status',
`Startup error:\n${errorMessage(error)}`,
].join('\n\n'),
buttons: ['OK'],
@@ -1151,6 +1164,7 @@ const createChat = async (
});
mainWindow = new BrowserWindow({
+ show: false,
titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default',
trafficLightPosition: process.platform === 'darwin' ? { x: 20, y: 16 } : undefined,
vibrancy: process.platform === 'darwin' ? 'window' : undefined,
@@ -1350,6 +1364,11 @@ const createChat = async (
url.hash = `${appPath}?${searchParams.toString()}`;
let formattedUrl = formatUrl(url);
log.info('Opening URL: ', formattedUrl);
+ mainWindow.once('ready-to-show', () => {
+ if (!mainWindow.isDestroyed()) {
+ mainWindow.show();
+ }
+ });
mainWindow.loadURL(formattedUrl);
// If we have an initial message, store it to send after React is ready
From b525e84964a21d94677c7958eaa87eb0bcc817f3 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Wed, 1 Jul 2026 20:11:04 +1000
Subject: [PATCH 17/34] remove goosed integration test
---
.github/workflows/pr-smoke-test.yml | 45 +--
ui/desktop/package.json | 1 -
ui/desktop/tests/integration/goosed.test.ts | 401 --------------------
ui/desktop/tests/integration/setup.ts | 139 -------
ui/desktop/tests/integration/vitest.d.ts | 10 -
5 files changed, 1 insertion(+), 595 deletions(-)
delete mode 100644 ui/desktop/tests/integration/goosed.test.ts
delete mode 100644 ui/desktop/tests/integration/setup.ts
delete mode 100644 ui/desktop/tests/integration/vitest.d.ts
diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml
index 405cb8d3ec47..5bd6bb4d39c8 100644
--- a/.github/workflows/pr-smoke-test.yml
+++ b/.github/workflows/pr-smoke-test.yml
@@ -67,7 +67,7 @@ jobs:
- name: Build Binary for Smoke Tests
run: |
- cargo build --bin goose --bin goosed
+ cargo build --bin goose
- name: Upload goose binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -76,13 +76,6 @@ jobs:
path: target/debug/goose
retention-days: 1
- - name: Upload goosed binary
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- with:
- name: goosed-binary
- path: target/debug/goosed
- retention-days: 1
-
smoke-tests:
name: Smoke Tests
runs-on: ubuntu-latest
@@ -253,39 +246,3 @@ 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- with:
- ref: ${{ github.event.inputs.branch || github.ref }}
-
- - name: Download Binary
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: goosed-binary
- path: target/debug
-
- - name: Make Binary Executable
- run: chmod +x target/debug/goosed
-
- - name: Install Node.js Dependencies
- run: source ../../bin/activate-hermit && pnpm install --frozen-lockfile
- working-directory: ui/desktop
-
- - name: Run Integration Tests
- env:
- ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- GOOSED_BINARY: ../../target/debug/goosed
- GOOSE_PROVIDER: anthropic
- GOOSE_MODEL: claude-sonnet-4-5-20250929
- SHELL: /bin/bash
- SKIP_BUILD: 1
- run: |
- echo 'export PATH=/some/fake/path:$PATH' >> $HOME/.bash_profile
- source ../../bin/activate-hermit && pnpm run test:integration:goosed
- working-directory: ui/desktop
diff --git a/ui/desktop/package.json b/ui/desktop/package.json
index 7cfacb1da07e..a7fdc8a823a4 100644
--- a/ui/desktop/package.json
+++ b/ui/desktop/package.json
@@ -37,7 +37,6 @@
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --config vitest.integration.config.ts",
- "test:integration:goosed": "vitest run --config vitest.integration.config.ts tests/integration/goosed.test.ts",
"test:integration:providers": "vitest run --config vitest.integration.config.ts tests/integration/test_providers.test.ts",
"test:integration:providers-code-exec": "vitest run --config vitest.integration.config.ts tests/integration/test_providers_code_exec.test.ts",
"test:integration:watch": "vitest --config vitest.integration.config.ts",
diff --git a/ui/desktop/tests/integration/goosed.test.ts b/ui/desktop/tests/integration/goosed.test.ts
deleted file mode 100644
index 3e5cdc75331b..000000000000
--- a/ui/desktop/tests/integration/goosed.test.ts
+++ /dev/null
@@ -1,401 +0,0 @@
-/**
- * Integration tests for the goosed binary using the TypeScript API client.
- *
- * These tests spawn a real goosed process and issue requests via the
- * auto-generated API client to verify the server is working correctly.
- */
-
-import { describe, it, expect, beforeAll, afterAll } from 'vitest';
-import { setupGoosed, type GoosedTestContext } from './setup';
-import {
- status,
- readConfig,
- providers,
- startAgent,
- stopAgent,
- getSession,
- updateAgentProvider,
- updateSession,
- upsertConfig,
- reply,
-} from '../../src/api';
-import { execSync } from 'child_process';
-import os from 'node:os';
-
-const CONSTRAINED_PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
-
-function getUserPath(): string[] {
- try {
- const userShell = process.env.SHELL || '/bin/bash';
- const path = execSync(`${userShell} -l -i -c 'echo $PATH'`, {
- encoding: 'utf-8',
- timeout: 5000,
- env: {
- PATH: CONSTRAINED_PATH,
- },
- }).trim();
-
- const delimiter = process.platform === 'win32' ? ';' : ':';
- return path.split(delimiter).filter((entry: string) => entry.length > 0);
- } catch (error) {
- console.error('Error executing shell:', error);
- throw error;
- }
-}
-
-describe('goosed API integration tests', () => {
- let ctx: GoosedTestContext;
-
- beforeAll(async () => {
- const configYaml = `
-extensions:
- developer:
- enabled: true
- type: builtin
- name: developer
- description: General development tools useful for software engineering.
- display_name: Developer
- timeout: 300
- bundled: true
- available_tools: []
-`;
-
- ctx = await setupGoosed({ pathOverride: '/usr/bin:/bin', configYaml });
- });
-
- afterAll(async () => {
- await ctx.cleanup();
- });
-
- describe('health', () => {
- it('should respond to status endpoint', async () => {
- const response = await status({ client: ctx.client });
- expect(response.response).toBeOkResponse();
- expect(response.data).toBeDefined();
- });
- });
-
- describe('configuration', () => {
- it('should read config value (or return null for missing key)', async () => {
- const response = await readConfig({
- client: ctx.client,
- body: {
- key: 'GOOSE_PROVIDER',
- is_secret: false,
- },
- });
- expect(response.response).toBeOkResponse();
- });
- });
-
- describe('providers', () => {
- it('should list available providers', async () => {
- const response = await providers({ client: ctx.client });
- expect(response.response).toBeOkResponse();
- expect(response.data).toBeDefined();
- expect(Array.isArray(response.data)).toBe(true);
- });
- });
-
- describe('sessions', () => {
- it('should start an agent and create a session', async () => {
- const startResponse = await startAgent({
- client: ctx.client,
- body: {
- working_dir: os.tmpdir(),
- },
- });
- expect(startResponse.response).toBeOkResponse();
- expect(startResponse.data).toBeDefined();
-
- const session = startResponse.data!;
- expect(session.id).toBeDefined();
- expect(session.name).toBeDefined();
- expect(session.goose_mode).toBe('auto');
-
- const getResponse = await getSession({
- client: ctx.client,
- path: {
- session_id: session.id,
- },
- });
- expect(getResponse.response).toBeOkResponse();
- expect(getResponse.data).toBeDefined();
- expect(getResponse.data!.id).toBe(session.id);
- });
-
- it('should persist goose_mode on the session', async () => {
- await upsertConfig({
- client: ctx.client,
- body: { key: 'GOOSE_MODE', value: 'approve', is_secret: false },
- });
-
- try {
- const startResponse = await startAgent({
- client: ctx.client,
- body: { working_dir: os.tmpdir() },
- });
- expect(startResponse.response).toBeOkResponse();
- expect(startResponse.data!.goose_mode).toBe('approve');
-
- const getResponse = await getSession({
- client: ctx.client,
- path: { session_id: startResponse.data!.id },
- });
- expect(getResponse.response).toBeOkResponse();
- expect(getResponse.data!.goose_mode).toBe('approve');
- } finally {
- // Restore default so subsequent tests don't inherit approve mode
- await upsertConfig({
- client: ctx.client,
- body: { key: 'GOOSE_MODE', value: 'auto', is_secret: false },
- });
- }
- });
-
- it('should update goose_mode on an active session via /agent/update_session', async () => {
- const startResponse = await startAgent({
- client: ctx.client,
- body: { working_dir: os.tmpdir() },
- });
- expect(startResponse.response).toBeOkResponse();
- const sessionId = startResponse.data!.id;
-
- const updateResponse = await updateSession({
- client: ctx.client,
- body: { session_id: sessionId, goose_mode: 'approve' },
- });
- expect(updateResponse.response).toBeOkResponse();
-
- const getResponse = await getSession({
- client: ctx.client,
- path: { session_id: sessionId },
- });
- expect(getResponse.response).toBeOkResponse();
- expect(getResponse.data!.goose_mode).toBe('approve');
- });
-
- it('should preserve goose_mode after provider swap via /agent/update_provider', async (testContext) => {
- const configResponse = await readConfig({
- client: ctx.client,
- body: { key: 'GOOSE_PROVIDER', is_secret: false },
- });
- const providerName = configResponse.data as string | null | undefined;
- if (!providerName) {
- testContext.skip('Skipping - no GOOSE_PROVIDER configured');
- return;
- }
-
- const modelResponse = await readConfig({
- client: ctx.client,
- body: { key: 'GOOSE_MODEL', is_secret: false },
- });
- const modelName = (modelResponse.data as string | null) || undefined;
-
- const startResponse = await startAgent({
- client: ctx.client,
- body: { working_dir: os.tmpdir() },
- });
- expect(startResponse.response).toBeOkResponse();
- const sessionId = startResponse.data!.id;
-
- const updateResponse = await updateSession({
- client: ctx.client,
- body: { session_id: sessionId, goose_mode: 'approve' },
- });
- expect(updateResponse.response).toBeOkResponse();
-
- const providerResponse = await updateAgentProvider({
- client: ctx.client,
- body: {
- session_id: sessionId,
- provider: providerName,
- model: modelName,
- },
- });
- expect(providerResponse.response).toBeOkResponse();
-
- const getResponse = await getSession({
- client: ctx.client,
- path: { session_id: sessionId },
- });
- expect(getResponse.response).toBeOkResponse();
- expect(getResponse.data!.goose_mode).toBe('approve');
- });
- });
-
- describe('messaging', () => {
- it('should accept a message request to /reply endpoint', async () => {
- // Start a session first
- const startResponse = await startAgent({
- client: ctx.client,
- body: {
- working_dir: os.tmpdir(),
- },
- });
- expect(startResponse.response).toBeOkResponse();
- const sessionId = startResponse.data!.id;
-
- const abortController = new AbortController();
- const { stream } = await reply({
- client: ctx.client,
- body: {
- session_id: sessionId,
- user_message: {
- role: 'user',
- created: Math.floor(Date.now() / 1000),
- content: [
- {
- type: 'text',
- text: 'Hello',
- },
- ],
- metadata: {
- userVisible: true,
- agentVisible: true,
- },
- },
- },
- throwOnError: true,
- signal: abortController.signal,
- });
-
- const timeout = setTimeout(() => abortController.abort(), 1000);
- try {
- for await (const event of stream) {
- expect(event).toBeDefined();
- break;
- }
- } catch {
- // Aborted or error, that's fine
- }
- clearTimeout(timeout);
-
- await stopAgent({
- client: ctx.client,
- body: {
- session_id: sessionId,
- },
- });
- });
- });
-
- describe('the developer tool', () => {
- it('should see the full PATH when calling the developer tool', async (testContext) => {
- const currentPath = getUserPath();
-
- const pathEntry = currentPath.find((entry) => !CONSTRAINED_PATH.includes(entry));
- if (!pathEntry) {
- expect.fail(`Could not find a path entry not in ${CONSTRAINED_PATH}`);
- }
-
- let configResponse = await readConfig({
- client: ctx.client,
- body: {
- key: 'GOOSE_PROVIDER',
- is_secret: false,
- },
- });
-
- let providerName = configResponse.data as string | null | undefined;
-
- if (!providerName) {
- testContext.skip('Skipping tool execution test - no GOOSE_PROVIDER configured');
- return;
- }
-
- const modelResponse = await readConfig({
- client: ctx.client,
- body: {
- key: 'GOOSE_MODEL',
- is_secret: false,
- },
- });
- const modelName = (modelResponse.data as string | null) || undefined;
-
- const startResponse = await startAgent({
- client: ctx.client,
- body: {
- working_dir: os.tmpdir(),
- },
- });
- expect(startResponse.response).toBeOkResponse();
- const sessionId = startResponse.data!.id;
-
- const providerResponse = await updateAgentProvider({
- client: ctx.client,
- body: {
- session_id: sessionId,
- provider: providerName,
- model: modelName,
- },
- });
- expect(providerResponse.response).toBeOkResponse();
-
- const abortController = new AbortController();
- const { stream } = await reply({
- client: ctx.client,
- body: {
- session_id: sessionId,
- user_message: {
- role: 'user',
- created: Math.floor(Date.now() / 1000),
- content: [
- {
- type: 'text',
- text: 'Use your developer shell tool to read $PATH and return its content directly, with no further information about it',
- },
- ],
- metadata: {
- userVisible: true,
- agentVisible: true,
- },
- },
- },
- throwOnError: true,
- signal: abortController.signal,
- });
-
- let returnedPath: string | undefined = undefined;
- const timeout = setTimeout(() => abortController.abort(), 60000); // 60s timeout
-
- try {
- for await (const event of stream) {
- console.log('stream: ', JSON.stringify(event));
-
- if (event.type === 'Message') {
- const content = event.message?.content?.[0];
- if (content?.type === 'toolResponse') {
- const toolResult = content as {
- toolResult?: { value?: { content?: Array<{ text?: string }> } };
- };
- const output = toolResult?.toolResult?.value?.content?.[0]?.text;
- if (output && output.includes('/usr')) {
- clearTimeout(timeout);
- abortController.abort();
- returnedPath = output;
- break;
- }
- }
- }
- }
- } catch (error) {
- // Aborted or error
- if (!(error instanceof Error && error.name === 'AbortError')) {
- console.log('Stream error: ', error);
- }
- }
- clearTimeout(timeout);
-
- await stopAgent({
- client: ctx.client,
- body: {
- session_id: sessionId,
- },
- });
-
- expect(returnedPath, 'the agent should return a value for $PATH').toBeDefined();
- expect(returnedPath, '$PATH should contain the expected entry').toContain(pathEntry);
- });
- });
-});
diff --git a/ui/desktop/tests/integration/setup.ts b/ui/desktop/tests/integration/setup.ts
deleted file mode 100644
index e2876babaac6..000000000000
--- a/ui/desktop/tests/integration/setup.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-/**
- * Integration test setup for testing the goosed binary via the TypeScript API client.
- *
- * This test suite spawns a real goosed process and issues requests via the
- * auto-generated API client.
- */
-
-import type { ChildProcess } from 'node:child_process';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import type { Client } from '../../src/api/client';
-import { startGoosed as startGoosedBase, checkServerStatus, type Logger } from '../../src/goosed';
-import { expect } from 'vitest';
-
-function stringifyResponse(response: Response) {
- const details = {
- ok: response.ok,
- status: response.status,
- statusText: response.statusText,
- url: response.url,
- headers: response.headers ? Object.fromEntries(response.headers) : undefined,
- };
- return JSON.stringify(details, null, 2);
-}
-
-expect.extend({
- toBeOkResponse(response) {
- const pass = response.ok === true;
- return {
- pass,
- message: () =>
- pass
- ? 'expected response not to be ok'
- : `expected response to be ok, got: ${stringifyResponse(response)}`,
- };
- },
-});
-
-const TEST_SECRET_KEY = 'test';
-
-export interface GoosedTestContext {
- client: Client;
- baseUrl: string;
- secretKey: string;
- process: ChildProcess | null;
- cleanup: () => Promise;
-}
-
-export async function setupGoosed({
- pathOverride,
- configYaml,
-}: {
- pathOverride?: string;
- configYaml?: string;
-}): Promise {
- const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'goose-app-root-'));
-
- if (configYaml) {
- await fs.promises.mkdir(path.join(tempDir, 'config'), { recursive: true });
- await fs.promises.writeFile(path.join(tempDir, 'config', 'config.yaml'), configYaml);
- }
-
- const testLogger: Logger = {
- info: (...args) => {
- if (process.env.DEBUG) {
- console.log('[goosed]', ...args);
- }
- },
- error: (...args) => console.error('[goosed]', ...args),
- };
-
- // Accept self-signed TLS certs from the local goosed server.
- // In Electron this is handled by setCertificateVerifyProc, but integration
- // tests run in plain Node.js where fetch rejects self-signed certs.
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
-
- const additionalEnv: Record = {
- GOOSE_PATH_ROOT: tempDir,
- };
-
- if (pathOverride) {
- additionalEnv.PATH = pathOverride;
- }
-
- const {
- baseUrl,
- process: goosedProcess,
- client,
- cleanup: baseCleanup,
- errorLog,
- } = await startGoosedBase({
- serverSecret: TEST_SECRET_KEY,
- env: additionalEnv,
- logger: testLogger,
- });
-
- if (!goosedProcess) {
- throw new Error('Expected goosed process to be started, but got external backend');
- }
-
- const cleanup = async (): Promise => {
- // dump server logs to test logs, visible if there are test failures
- try {
- const logsPath = path.join(tempDir, 'state', 'logs', 'server');
- if (fs.existsSync(logsPath)) {
- const logDirs = await fs.promises.readdir(logsPath);
- for (const logDir of logDirs) {
- const logFiles = await fs.promises.readdir(path.join(logsPath, logDir));
- for (const logFile of logFiles) {
- const logPath = path.join(logsPath, logDir, logFile);
- const logContent = await fs.promises.readFile(logPath, 'utf8');
- console.log(logContent);
- }
- }
- }
- } catch {
- // Logs may not exist
- }
-
- await baseCleanup();
- await fs.promises.rm(tempDir, { recursive: true, force: true });
- };
-
- const serverReady = await checkServerStatus(client, errorLog);
- if (!serverReady) {
- await cleanup();
- console.error('Server stderr:', errorLog.join('\n'));
- throw new Error('Failed to start goosed');
- }
-
- return {
- client,
- baseUrl,
- secretKey: TEST_SECRET_KEY,
- process: goosedProcess,
- cleanup,
- };
-}
diff --git a/ui/desktop/tests/integration/vitest.d.ts b/ui/desktop/tests/integration/vitest.d.ts
deleted file mode 100644
index 9b98e4d1240d..000000000000
--- a/ui/desktop/tests/integration/vitest.d.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import 'vitest';
-
-declare module 'vitest' {
- interface Assertion {
- toBeOkResponse(): T;
- }
- interface AsymmetricMatchersContaining {
- toBeOkResponse(): unknown;
- }
-}
From de0e8f8d827ce304feecfe5a24a4ff6925f634b9 Mon Sep 17 00:00:00 2001
From: Lifei Zhou
Date: Wed, 1 Jul 2026 20:20:53 +1000
Subject: [PATCH 18/34] remove generate-api
---
.github/copilot-instructions.md | 1 -
.github/workflows/ci.yml | 6 -
AGENTS.md | 11 +-
CONTRIBUTING.md | 27 +-
Justfile | 8 -
scripts/check-openapi-schema.sh | 28 -
ui/desktop/openapi-ts.config.ts | 13 -
ui/desktop/package.json | 16 +-
ui/desktop/src/App.test.tsx | 9 -
ui/desktop/src/api/client.gen.ts | 16 -
ui/desktop/src/api/client/client.gen.ts | 288 --
ui/desktop/src/api/client/index.ts | 25 -
ui/desktop/src/api/client/types.gen.ts | 214 -
ui/desktop/src/api/client/utils.gen.ts | 316 --
ui/desktop/src/api/core/auth.gen.ts | 41 -
ui/desktop/src/api/core/bodySerializer.gen.ts | 84 -
ui/desktop/src/api/core/params.gen.ts | 169 -
ui/desktop/src/api/core/pathSerializer.gen.ts | 171 -
.../src/api/core/queryKeySerializer.gen.ts | 117 -
.../src/api/core/serverSentEvents.gen.ts | 243 --
ui/desktop/src/api/core/types.gen.ts | 104 -
ui/desktop/src/api/core/utils.gen.ts | 140 -
ui/desktop/src/api/index.ts | 4 -
ui/desktop/src/api/sdk.gen.ts | 466 --
ui/desktop/src/api/types.gen.ts | 3798 -----------------
ui/desktop/src/goosed.ts | 444 --
ui/pnpm-lock.yaml | 52 -
27 files changed, 17 insertions(+), 6794 deletions(-)
delete mode 100755 scripts/check-openapi-schema.sh
delete mode 100644 ui/desktop/openapi-ts.config.ts
delete mode 100644 ui/desktop/src/api/client.gen.ts
delete mode 100644 ui/desktop/src/api/client/client.gen.ts
delete mode 100644 ui/desktop/src/api/client/index.ts
delete mode 100644 ui/desktop/src/api/client/types.gen.ts
delete mode 100644 ui/desktop/src/api/client/utils.gen.ts
delete mode 100644 ui/desktop/src/api/core/auth.gen.ts
delete mode 100644 ui/desktop/src/api/core/bodySerializer.gen.ts
delete mode 100644 ui/desktop/src/api/core/params.gen.ts
delete mode 100644 ui/desktop/src/api/core/pathSerializer.gen.ts
delete mode 100644 ui/desktop/src/api/core/queryKeySerializer.gen.ts
delete mode 100644 ui/desktop/src/api/core/serverSentEvents.gen.ts
delete mode 100644 ui/desktop/src/api/core/types.gen.ts
delete mode 100644 ui/desktop/src/api/core/utils.gen.ts
delete mode 100644 ui/desktop/src/api/index.ts
delete mode 100644 ui/desktop/src/api/sdk.gen.ts
delete mode 100644 ui/desktop/src/api/types.gen.ts
delete mode 100644 ui/desktop/src/goosed.ts
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 51d3018cbb0b..00ba2fc8669b 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -56,7 +56,6 @@
- `cargo fmt --check` - Code formatting (rustfmt)
- `cargo test --jobs 2` - All tests
- `cargo clippy --all-targets -- -D warnings` - Linting (clippy)
-- `just check-openapi-schema` - OpenAPI schema validation
**Desktop app checks:**
- `pnpm install --frozen-lockfile` - Fresh dependency install (in `ui/desktop/`)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6106534c791b..ac77b41ee56d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -183,12 +183,6 @@ jobs:
cd ui/desktop && pnpm install --frozen-lockfile
cd ../sdk && pnpm install --frozen-lockfile
- - name: Check OpenAPI Schema is Up-to-Date
- run: |
- source ./bin/activate-hermit
- hermit uninstall rustup
- just check-openapi-schema
-
- name: Check ACP Schema is Up-to-Date
run: |
source ./bin/activate-hermit
diff --git a/AGENTS.md b/AGENTS.md
index e0fda6d5e075..e3ab069bd9db 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,7 +14,7 @@ cargo build
```bash
cargo build # debug
cargo build --release # release
-just release-binary # release + openapi
+just release-binary # release binary
```
### Test
@@ -33,8 +33,8 @@ cargo clippy --all-targets -- -D warnings
### UI
```bash
-just generate-openapi # after server changes
just run-ui # start desktop
+cd ui/desktop && pnpm run typecheck
cd ui/desktop && pnpm test # test UI
```
@@ -44,7 +44,6 @@ crates/
├── goose # core logic
├── goose-acp-macros # ACP proc macros
├── goose-cli # CLI entry
-├── goose-server # backend (binary: goosed)
├── goose-mcp # MCP extensions
├── goose-test # test utilities
└── goose-test-support # test helpers
@@ -65,7 +64,6 @@ ui/desktop/ # Electron app
# 1. cargo build
# 2. cargo test -p
# 3. cargo clippy --all-targets -- -D warnings
-# 4. [if server] just generate-openapi
```
## Rules
@@ -75,7 +73,7 @@ ui/desktop/ # Electron app
- Error: Use anyhow::Result
- Provider: Implement Provider trait see providers/base.rs
- MCP: Extensions in crates/goose-mcp/
-- Server: Changes need just generate-openapi
+- UI Desktop: Use ACP SDK types or local `src/types/*` types. Do not import generated OpenAPI types/client code from `ui/desktop/src/api`
## Code Quality
@@ -107,7 +105,7 @@ remaining space for dynamic text.
## Never
-- Never: Edit ui/desktop/openapi.json manually
+- Never: Recreate `ui/desktop/src/api` or add `@hey-api/openapi-ts` to `ui/desktop`
- Cargo.toml: For human-authored dependency changes, use `cargo add` instead of manually editing dependency entries unless there is a specific reason not to.
- Cargo.toml: Automated dependency bump PRs are exempt; when manual edits are necessary, keep `Cargo.lock` consistent.
- Never: Skip cargo fmt
@@ -116,6 +114,5 @@ remaining space for dynamic text.
## Entry Points
- CLI: crates/goose-cli/src/main.rs
-- Server: crates/goose-server/src/main.rs
- UI: ui/desktop/src/main.ts
- Agent: crates/goose/src/agents/agent.rs
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e76a03d71cb3..66b96b14ea88 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -184,24 +184,9 @@ cd ui && pnpm install
See #8757.
-### Regenerating the OpenAPI schema
-
-The file `ui/desktop/openapi.json` is automatically generated during the build.
-It is written by the `generate_schema` binary in `crates/goose-server`.
-To update the spec without starting the UI, run:
-
-```
-just generate-openapi
-```
-
-This command regenerates `ui/desktop/openapi.json` and then runs the UI's
-`generate-api` script to rebuild the TypeScript client from that spec.
-
-API changes should be made in the Rust source under `crates/goose-server/src/`.
-
### Debugging
-To debug the Goose server, run it from an IDE. The configuration will depend on the IDE. The command to run is:
+To debug the external ACP backend, run it from an IDE. The configuration will depend on the IDE. The command to run is:
```
export GOOSE_SERVER__SECRET_KEY=test
@@ -209,17 +194,17 @@ cargo run --package goose-cli --bin goose -- serve --platform desktop --host 127
```
The `debug-ui` recipe connects to `http://127.0.0.1:3000` by default. If the
-server uses another port, set `GOOSE_PORT` when starting the UI, or set
-`GOOSE_EXTERNAL_BACKEND_URL` to the server's HTTP base URL.
+backend uses another port, set `GOOSE_PORT` when starting the UI, or set
+`GOOSE_EXTERNAL_BACKEND_URL` to the backend's HTTP base URL.
-Once the server is running, start a UI and connect it to the server by running:
+Once the backend is running, start a UI and connect it to the backend by running:
```
just debug-ui
```
-The UI connects to the server started in the IDE, allowing breakpoints
-and stepping through the server code while interacting with the UI.
+The UI connects to the backend started in the IDE, allowing breakpoints
+and stepping through the backend code while interacting with the UI.
## Creating a fork
diff --git a/Justfile b/Justfile
index c6a9fd25f4e7..c08148357f7d 100644
--- a/Justfile
+++ b/Justfile
@@ -13,8 +13,6 @@ check-everything:
cargo clippy --all-targets -- -D warnings
@echo " → Checking UI code formatting..."
cd ui/desktop && pnpm run lint:check
- @echo " → Validating OpenAPI schema..."
- ./scripts/check-openapi-schema.sh
@echo ""
@echo "✅ All style checks passed!"
@@ -151,16 +149,10 @@ run-server:
@echo "Running external ACP backend..."
GOOSE_SERVER__SECRET_KEY="${GOOSE_SERVER__SECRET_KEY:-test}" cargo run -p goose-cli --bin goose -- serve --platform desktop --host 127.0.0.1 --port 3000
-# Check if OpenAPI schema is up-to-date
-check-openapi-schema: generate-openapi
- ./scripts/check-openapi-schema.sh
-
# Generate OpenAPI specification without starting the UI
generate-openapi:
@echo "Generating OpenAPI schema..."
cargo run -p goose-server --bin generate_schema
- @echo "Generating frontend API..."
- cd ui/desktop && npx @hey-api/openapi-ts
# Check if generated ACP schema and TypeScript types are up-to-date
check-acp-schema: generate-acp-types
diff --git a/scripts/check-openapi-schema.sh b/scripts/check-openapi-schema.sh
deleted file mode 100755
index d45f733f256c..000000000000
--- a/scripts/check-openapi-schema.sh
+++ /dev/null
@@ -1,28 +0,0 @@
-#!/usr/bin/env bash
-set -e
-
-# Check if OpenAPI schema is up-to-date
-# This script generates the OpenAPI schema and compares it with the committed version
-
-echo "🔍 Checking OpenAPI schema is up-to-date..."
-
-# Check if the generated schema differs from the committed version
-echo "🔍 Comparing generated schema with committed version..."
-if ! git diff --ignore-space-change --exit-code ui/desktop/openapi.json ui/desktop/src/api/; then
- echo ""
- echo "❌ OpenAPI schema is out of date!"
- echo ""
- echo "The generated OpenAPI schema differs from the committed version."
- echo "This usually means that API types were added or modified without updating the schema."
- echo ""
- echo "To fix this issue:"
- echo "1. Run 'just generate-openapi' locally"
- echo "2. Commit the changes to ui/desktop/openapi.json and ui/desktop/src/api/"
- echo "3. Push your changes"
- echo ""
- echo "Changes detected:"
- git diff ui/desktop/openapi.json ui/desktop/src/api/
- exit 1
-fi
-
-echo "✅ OpenAPI schema is up-to-date"
diff --git a/ui/desktop/openapi-ts.config.ts b/ui/desktop/openapi-ts.config.ts
deleted file mode 100644
index 992c5a4a0f54..000000000000
--- a/ui/desktop/openapi-ts.config.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { defineConfig } from '@hey-api/openapi-ts';
-
-export default defineConfig({
- input: './openapi.json',
- output: './src/api',
- plugins: [
- {
- name: '@hey-api/client-fetch',
- // Disable SSE support to avoid requiring SSE options on all requests
- sse: false,
- },
- ],
-});
diff --git a/ui/desktop/package.json b/ui/desktop/package.json
index a7fdc8a823a4..6db28eeb6b8e 100644
--- a/ui/desktop/package.json
+++ b/ui/desktop/package.json
@@ -11,10 +11,9 @@
"scripts": {
"postinstall": "pnpm --filter @aaif/goose-sdk run build",
"typecheck": "tsc --noEmit",
- "generate-api": "openapi-ts",
"build-goose-sdk": "pnpm --filter @aaif/goose-sdk run build",
- "start-gui": "pnpm run build-goose-sdk && pnpm run generate-api && pnpm run i18n:compile && electron-forge start",
- "start-gui-debug": "pnpm run build-goose-sdk && pnpm run generate-api && pnpm run i18n:compile && electron-forge start -- --inspect=9229",
+ "start-gui": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge start",
+ "start-gui-debug": "pnpm run build-goose-sdk && pnpm run i18n:compile && electron-forge start -- --inspect=9229",
"start": "cd ../.. && just run-ui",
"start:test-error": "GOOSE_TEST_ERROR=true electron-forge start",
"package": "pnpm run i18n:compile && electron-forge package",
@@ -22,12 +21,12 @@
"bundle:default": "node scripts/prepare-platform-binaries.js && pnpm run make && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-arm64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}.zip\")",
"bundle:intel": "node scripts/prepare-platform-binaries.js && pnpm run make --arch=x64 && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && APP_DIR=\"out/${BUNDLE_NAME}-darwin-x64\" && APP_BUNDLE=\"${APP_DIR}/${BUNDLE_NAME}.app\" && (cd \"$APP_DIR\" && ditto -c -k --sequesterRsrc --keepParent \"${BUNDLE_NAME}.app\" \"${BUNDLE_NAME}_intel_mac.zip\")",
"debug": "echo 'run --remote-debugging-port=8315' && BUNDLE_NAME=\"${GOOSE_BUNDLE_NAME:-Goose}\" && lldb \"out/${BUNDLE_NAME}-darwin-arm64/${BUNDLE_NAME}.app\"",
- "test-e2e": "pnpm run generate-api && playwright test",
- "test-e2e:dev": "pnpm run generate-api && playwright test --reporter=list --retries=0 --max-failures=1",
- "test-e2e:ui": "pnpm run generate-api && playwright test --ui",
- "test-e2e:debug": "pnpm run generate-api && playwright test --debug",
+ "test-e2e": "playwright test",
+ "test-e2e:dev": "playwright test --reporter=list --retries=0 --max-failures=1",
+ "test-e2e:ui": "playwright test --ui",
+ "test-e2e:debug": "playwright test --debug",
"test-e2e:report": "playwright show-report",
- "test-e2e:single": "pnpm run generate-api && playwright test -g",
+ "test-e2e:single": "playwright test -g",
"lint": "eslint \"src/**/*.{ts,tsx}\" --fix --no-warn-ignored",
"lint:check": "pnpm run typecheck && eslint \"src/**/*.{ts,tsx}\" --max-warnings 0 --no-warn-ignored && pnpm run i18n:check",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
@@ -121,7 +120,6 @@
"@eslint/js": "^9.39.2",
"@formatjs/cli": "^6.14.0",
"@formatjs/icu-messageformat-parser": "3.5.3",
- "@hey-api/openapi-ts": "^0.93.0",
"@modelcontextprotocol/sdk": "^1.27.0",
"@playwright/test": "^1.58.2",
"@tailwindcss/line-clamp": "^0.4.4",
diff --git a/ui/desktop/src/App.test.tsx b/ui/desktop/src/App.test.tsx
index d820ac62dbd1..2da21b72b49a 100644
--- a/ui/desktop/src/App.test.tsx
+++ b/ui/desktop/src/App.test.tsx
@@ -34,15 +34,6 @@ vi.mock('./utils/costDatabase', () => ({
initializeCostDatabase: vi.fn().mockResolvedValue(undefined),
}));
-vi.mock('./api', () => {
- return {
- initConfig: vi.fn().mockResolvedValue(undefined),
- backupConfig: vi.fn().mockResolvedValue(undefined),
- recoverConfig: vi.fn().mockResolvedValue(undefined),
- validateConfig: vi.fn().mockResolvedValue(undefined),
- };
-});
-
vi.mock('./sessions', () => ({
fetchSessionDetails: vi
.fn()
diff --git a/ui/desktop/src/api/client.gen.ts b/ui/desktop/src/api/client.gen.ts
deleted file mode 100644
index d81ce3f8f717..000000000000
--- a/ui/desktop/src/api/client.gen.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-import { type ClientOptions, type Config, createClient, createConfig } from './client';
-import type { ClientOptions as ClientOptions2 } from './types.gen';
-
-/**
- * The `createClientConfig()` function will be called on client initialization
- * and the returned object will become the client's initial configuration.
- *
- * You may want to initialize your client this way instead of calling
- * `setConfig()`. This is useful for example if you're using Next.js
- * to ensure your client always has the correct values.
- */
-export type CreateClientConfig = (override?: Config) => Config & T> | Promise & T>>;
-
-export const client = createClient(createConfig());
diff --git a/ui/desktop/src/api/client/client.gen.ts b/ui/desktop/src/api/client/client.gen.ts
deleted file mode 100644
index d2e55a14497d..000000000000
--- a/ui/desktop/src/api/client/client.gen.ts
+++ /dev/null
@@ -1,288 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-import { createSseClient } from '../core/serverSentEvents.gen';
-import type { HttpMethod } from '../core/types.gen';
-import { getValidRequestBody } from '../core/utils.gen';
-import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
-import {
- buildUrl,
- createConfig,
- createInterceptors,
- getParseAs,
- mergeConfigs,
- mergeHeaders,
- setAuthParams,
-} from './utils.gen';
-
-type ReqInit = Omit & {
- body?: any;
- headers: ReturnType;
-};
-
-export const createClient = (config: Config = {}): Client => {
- let _config = mergeConfigs(createConfig(), config);
-
- const getConfig = (): Config => ({ ..._config });
-
- const setConfig = (config: Config): Config => {
- _config = mergeConfigs(_config, config);
- return getConfig();
- };
-
- const interceptors = createInterceptors();
-
- const beforeRequest = async (options: RequestOptions) => {
- const opts = {
- ..._config,
- ...options,
- fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
- headers: mergeHeaders(_config.headers, options.headers),
- serializedBody: undefined,
- };
-
- if (opts.security) {
- await setAuthParams({
- ...opts,
- security: opts.security,
- });
- }
-
- if (opts.requestValidator) {
- await opts.requestValidator(opts);
- }
-
- if (opts.body !== undefined && opts.bodySerializer) {
- opts.serializedBody = opts.bodySerializer(opts.body);
- }
-
- // remove Content-Type header if body is empty to avoid sending invalid requests
- if (opts.body === undefined || opts.serializedBody === '') {
- opts.headers.delete('Content-Type');
- }
-
- const url = buildUrl(opts);
-
- return { opts, url };
- };
-
- const request: Client['request'] = async (options) => {
- // @ts-expect-error
- const { opts, url } = await beforeRequest(options);
- const requestInit: ReqInit = {
- redirect: 'follow',
- ...opts,
- body: getValidRequestBody(opts),
- };
-
- let request = new Request(url, requestInit);
-
- for (const fn of interceptors.request.fns) {
- if (fn) {
- request = await fn(request, opts);
- }
- }
-
- // fetch must be assigned here, otherwise it would throw the error:
- // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
- const _fetch = opts.fetch!;
- let response: Response;
-
- try {
- response = await _fetch(request);
- } catch (error) {
- // Handle fetch exceptions (AbortError, network errors, etc.)
- let finalError = error;
-
- for (const fn of interceptors.error.fns) {
- if (fn) {
- finalError = (await fn(error, undefined as any, request, opts)) as unknown;
- }
- }
-
- finalError = finalError || ({} as unknown);
-
- if (opts.throwOnError) {
- throw finalError;
- }
-
- // Return error response
- return opts.responseStyle === 'data'
- ? undefined
- : {
- error: finalError,
- request,
- response: undefined as any,
- };
- }
-
- for (const fn of interceptors.response.fns) {
- if (fn) {
- response = await fn(response, request, opts);
- }
- }
-
- const result = {
- request,
- response,
- };
-
- if (response.ok) {
- const parseAs =
- (opts.parseAs === 'auto'
- ? getParseAs(response.headers.get('Content-Type'))
- : opts.parseAs) ?? 'json';
-
- if (response.status === 204 || response.headers.get('Content-Length') === '0') {
- let emptyData: any;
- switch (parseAs) {
- case 'arrayBuffer':
- case 'blob':
- case 'text':
- emptyData = await response[parseAs]();
- break;
- case 'formData':
- emptyData = new FormData();
- break;
- case 'stream':
- emptyData = response.body;
- break;
- case 'json':
- default:
- emptyData = {};
- break;
- }
- return opts.responseStyle === 'data'
- ? emptyData
- : {
- data: emptyData,
- ...result,
- };
- }
-
- let data: any;
- switch (parseAs) {
- case 'arrayBuffer':
- case 'blob':
- case 'formData':
- case 'text':
- data = await response[parseAs]();
- break;
- case 'json': {
- // Some servers return 200 with no Content-Length and empty body.
- // response.json() would throw; read as text and parse if non-empty.
- const text = await response.text();
- data = text ? JSON.parse(text) : {};
- break;
- }
- case 'stream':
- return opts.responseStyle === 'data'
- ? response.body
- : {
- data: response.body,
- ...result,
- };
- }
-
- if (parseAs === 'json') {
- if (opts.responseValidator) {
- await opts.responseValidator(data);
- }
-
- if (opts.responseTransformer) {
- data = await opts.responseTransformer(data);
- }
- }
-
- return opts.responseStyle === 'data'
- ? data
- : {
- data,
- ...result,
- };
- }
-
- const textError = await response.text();
- let jsonError: unknown;
-
- try {
- jsonError = JSON.parse(textError);
- } catch {
- // noop
- }
-
- const error = jsonError ?? textError;
- let finalError = error;
-
- for (const fn of interceptors.error.fns) {
- if (fn) {
- finalError = (await fn(error, response, request, opts)) as string;
- }
- }
-
- finalError = finalError || ({} as string);
-
- if (opts.throwOnError) {
- throw finalError;
- }
-
- // TODO: we probably want to return error and improve types
- return opts.responseStyle === 'data'
- ? undefined
- : {
- error: finalError,
- ...result,
- };
- };
-
- const makeMethodFn = (method: Uppercase) => (options: RequestOptions) =>
- request({ ...options, method });
-
- const makeSseFn = (method: Uppercase) => async (options: RequestOptions) => {
- const { opts, url } = await beforeRequest(options);
- return createSseClient({
- ...opts,
- body: opts.body as BodyInit | null | undefined,
- headers: opts.headers as unknown as Record,
- method,
- onRequest: async (url, init) => {
- let request = new Request(url, init);
- for (const fn of interceptors.request.fns) {
- if (fn) {
- request = await fn(request, opts);
- }
- }
- return request;
- },
- serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
- url,
- });
- };
-
- return {
- buildUrl,
- connect: makeMethodFn('CONNECT'),
- delete: makeMethodFn('DELETE'),
- get: makeMethodFn('GET'),
- getConfig,
- head: makeMethodFn('HEAD'),
- interceptors,
- options: makeMethodFn('OPTIONS'),
- patch: makeMethodFn('PATCH'),
- post: makeMethodFn('POST'),
- put: makeMethodFn('PUT'),
- request,
- setConfig,
- sse: {
- connect: makeSseFn('CONNECT'),
- delete: makeSseFn('DELETE'),
- get: makeSseFn('GET'),
- head: makeSseFn('HEAD'),
- options: makeSseFn('OPTIONS'),
- patch: makeSseFn('PATCH'),
- post: makeSseFn('POST'),
- put: makeSseFn('PUT'),
- trace: makeSseFn('TRACE'),
- },
- trace: makeMethodFn('TRACE'),
- } as Client;
-};
diff --git a/ui/desktop/src/api/client/index.ts b/ui/desktop/src/api/client/index.ts
deleted file mode 100644
index b295edeca0ca..000000000000
--- a/ui/desktop/src/api/client/index.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-export type { Auth } from '../core/auth.gen';
-export type { QuerySerializerOptions } from '../core/bodySerializer.gen';
-export {
- formDataBodySerializer,
- jsonBodySerializer,
- urlSearchParamsBodySerializer,
-} from '../core/bodySerializer.gen';
-export { buildClientParams } from '../core/params.gen';
-export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen';
-export { createClient } from './client.gen';
-export type {
- Client,
- ClientOptions,
- Config,
- CreateClientConfig,
- Options,
- RequestOptions,
- RequestResult,
- ResolvedRequestOptions,
- ResponseStyle,
- TDataShape,
-} from './types.gen';
-export { createConfig, mergeHeaders } from './utils.gen';
diff --git a/ui/desktop/src/api/client/types.gen.ts b/ui/desktop/src/api/client/types.gen.ts
deleted file mode 100644
index 8c0df2321e82..000000000000
--- a/ui/desktop/src/api/client/types.gen.ts
+++ /dev/null
@@ -1,214 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-import type { Auth } from '../core/auth.gen';
-import type {
- ServerSentEventsOptions,
- ServerSentEventsResult,
-} from '../core/serverSentEvents.gen';
-import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
-import type { Middleware } from './utils.gen';
-
-export type ResponseStyle = 'data' | 'fields';
-
-export interface Config
- extends Omit, CoreConfig {
- /**
- * Base URL for all requests made by this client.
- */
- baseUrl?: T['baseUrl'];
- /**
- * Fetch API implementation. You can use this option to provide a custom
- * fetch instance.
- *
- * @default globalThis.fetch
- */
- fetch?: typeof fetch;
- /**
- * Please don't use the Fetch client for Next.js applications. The `next`
- * options won't have any effect.
- *
- * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead.
- */
- next?: never;
- /**
- * Return the response data parsed in a specified format. By default, `auto`
- * will infer the appropriate method from the `Content-Type` response header.
- * You can override this behavior with any of the {@link Body} methods.
- * Select `stream` if you don't want to parse response data at all.
- *
- * @default 'auto'
- */
- parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
- /**
- * Should we return only data or multiple fields (data, error, response, etc.)?
- *
- * @default 'fields'
- */
- responseStyle?: ResponseStyle;
- /**
- * Throw an error instead of returning it in the response?
- *
- * @default false
- */
- throwOnError?: T['throwOnError'];
-}
-
-export interface RequestOptions<
- TData = unknown,
- TResponseStyle extends ResponseStyle = 'fields',
- ThrowOnError extends boolean = boolean,
- Url extends string = string,
->
- extends
- Config<{
- responseStyle: TResponseStyle;
- throwOnError: ThrowOnError;
- }>,
- Pick<
- ServerSentEventsOptions,
- | 'onRequest'
- | 'onSseError'
- | 'onSseEvent'
- | 'sseDefaultRetryDelay'
- | 'sseMaxRetryAttempts'
- | 'sseMaxRetryDelay'
- > {
- /**
- * Any body that you want to add to your request.
- *
- * {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
- */
- body?: unknown;
- path?: Record;
- query?: Record;
- /**
- * Security mechanism(s) to use for the request.
- */
- security?: ReadonlyArray;
- url: Url;
-}
-
-export interface ResolvedRequestOptions<
- TResponseStyle extends ResponseStyle = 'fields',
- ThrowOnError extends boolean = boolean,
- Url extends string = string,
-> extends RequestOptions {
- serializedBody?: string;
-}
-
-export type RequestResult<
- TData = unknown,
- TError = unknown,
- ThrowOnError extends boolean = boolean,
- TResponseStyle extends ResponseStyle = 'fields',
-> = ThrowOnError extends true
- ? Promise<
- TResponseStyle extends 'data'
- ? TData extends Record
- ? TData[keyof TData]
- : TData
- : {
- data: TData extends Record ? TData[keyof TData] : TData;
- request: Request;
- response: Response;
- }
- >
- : Promise<
- TResponseStyle extends 'data'
- ? (TData extends Record ? TData[keyof TData] : TData) | undefined
- : (
- | {
- data: TData extends Record ? TData[keyof TData] : TData;
- error: undefined;
- }
- | {
- data: undefined;
- error: TError extends Record ? TError[keyof TError] : TError;
- }
- ) & {
- request: Request;
- response: Response;
- }
- >;
-
-export interface ClientOptions {
- baseUrl?: string;
- responseStyle?: ResponseStyle;
- throwOnError?: boolean;
-}
-
-type MethodFn = <
- TData = unknown,
- TError = unknown,
- ThrowOnError extends boolean = false,
- TResponseStyle extends ResponseStyle = 'fields',
->(
- options: Omit, 'method'>,
-) => RequestResult;
-
-type SseFn = <
- TData = unknown,
- TError = unknown,
- ThrowOnError extends boolean = false,
- TResponseStyle extends ResponseStyle = 'fields',
->(
- options: Omit, 'method'>,
-) => Promise>;
-
-type RequestFn = <
- TData = unknown,
- TError = unknown,
- ThrowOnError extends boolean = false,
- TResponseStyle extends ResponseStyle = 'fields',
->(
- options: Omit, 'method'> &
- Pick>, 'method'>,
-) => RequestResult;
-
-type BuildUrlFn = <
- TData extends {
- body?: unknown;
- path?: Record;
- query?: Record;
- url: string;
- },
->(
- options: TData & Options,
-) => string;
-
-export type Client = CoreClient & {
- interceptors: Middleware;
-};
-
-/**
- * The `createClientConfig()` function will be called on client initialization
- * and the returned object will become the client's initial configuration.
- *
- * You may want to initialize your client this way instead of calling
- * `setConfig()`. This is useful for example if you're using Next.js
- * to ensure your client always has the correct values.
- */
-export type CreateClientConfig = (
- override?: Config,
-) => Config & T> | Promise & T>>;
-
-export interface TDataShape {
- body?: unknown;
- headers?: unknown;
- path?: unknown;
- query?: unknown;
- url: string;
-}
-
-type OmitKeys = Pick>;
-
-export type Options<
- TData extends TDataShape = TDataShape,
- ThrowOnError extends boolean = boolean,
- TResponse = unknown,
- TResponseStyle extends ResponseStyle = 'fields',
-> = OmitKeys<
- RequestOptions,
- 'body' | 'path' | 'query' | 'url'
-> &
- ([TData] extends [never] ? unknown : Omit);
diff --git a/ui/desktop/src/api/client/utils.gen.ts b/ui/desktop/src/api/client/utils.gen.ts
deleted file mode 100644
index b4bd2435ce0b..000000000000
--- a/ui/desktop/src/api/client/utils.gen.ts
+++ /dev/null
@@ -1,316 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-import { getAuthToken } from '../core/auth.gen';
-import type { QuerySerializerOptions } from '../core/bodySerializer.gen';
-import { jsonBodySerializer } from '../core/bodySerializer.gen';
-import {
- serializeArrayParam,
- serializeObjectParam,
- serializePrimitiveParam,
-} from '../core/pathSerializer.gen';
-import { getUrl } from '../core/utils.gen';
-import type { Client, ClientOptions, Config, RequestOptions } from './types.gen';
-
-export const createQuerySerializer = ({
- parameters = {},
- ...args
-}: QuerySerializerOptions = {}) => {
- const querySerializer = (queryParams: T) => {
- const search: string[] = [];
- if (queryParams && typeof queryParams === 'object') {
- for (const name in queryParams) {
- const value = queryParams[name];
-
- if (value === undefined || value === null) {
- continue;
- }
-
- const options = parameters[name] || args;
-
- if (Array.isArray(value)) {
- const serializedArray = serializeArrayParam({
- allowReserved: options.allowReserved,
- explode: true,
- name,
- style: 'form',
- value,
- ...options.array,
- });
- if (serializedArray) search.push(serializedArray);
- } else if (typeof value === 'object') {
- const serializedObject = serializeObjectParam({
- allowReserved: options.allowReserved,
- explode: true,
- name,
- style: 'deepObject',
- value: value as Record,
- ...options.object,
- });
- if (serializedObject) search.push(serializedObject);
- } else {
- const serializedPrimitive = serializePrimitiveParam({
- allowReserved: options.allowReserved,
- name,
- value: value as string,
- });
- if (serializedPrimitive) search.push(serializedPrimitive);
- }
- }
- }
- return search.join('&');
- };
- return querySerializer;
-};
-
-/**
- * Infers parseAs value from provided Content-Type header.
- */
-export const getParseAs = (contentType: string | null): Exclude => {
- if (!contentType) {
- // If no Content-Type header is provided, the best we can do is return the raw response body,
- // which is effectively the same as the 'stream' option.
- return 'stream';
- }
-
- const cleanContent = contentType.split(';')[0]?.trim();
-
- if (!cleanContent) {
- return;
- }
-
- if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
- return 'json';
- }
-
- if (cleanContent === 'multipart/form-data') {
- return 'formData';
- }
-
- if (
- ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
- ) {
- return 'blob';
- }
-
- if (cleanContent.startsWith('text/')) {
- return 'text';
- }
-
- return;
-};
-
-const checkForExistence = (
- options: Pick & {
- headers: Headers;
- },
- name?: string,
-): boolean => {
- if (!name) {
- return false;
- }
- if (
- options.headers.has(name) ||
- options.query?.[name] ||
- options.headers.get('Cookie')?.includes(`${name}=`)
- ) {
- return true;
- }
- return false;
-};
-
-export const setAuthParams = async ({
- security,
- ...options
-}: Pick, 'security'> &
- Pick & {
- headers: Headers;
- }) => {
- for (const auth of security) {
- if (checkForExistence(options, auth.name)) {
- continue;
- }
-
- const token = await getAuthToken(auth, options.auth);
-
- if (!token) {
- continue;
- }
-
- const name = auth.name ?? 'Authorization';
-
- switch (auth.in) {
- case 'query':
- if (!options.query) {
- options.query = {};
- }
- options.query[name] = token;
- break;
- case 'cookie':
- options.headers.append('Cookie', `${name}=${token}`);
- break;
- case 'header':
- default:
- options.headers.set(name, token);
- break;
- }
- }
-};
-
-export const buildUrl: Client['buildUrl'] = (options) =>
- getUrl({
- baseUrl: options.baseUrl as string,
- path: options.path,
- query: options.query,
- querySerializer:
- typeof options.querySerializer === 'function'
- ? options.querySerializer
- : createQuerySerializer(options.querySerializer),
- url: options.url,
- });
-
-export const mergeConfigs = (a: Config, b: Config): Config => {
- const config = { ...a, ...b };
- if (config.baseUrl?.endsWith('/')) {
- config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
- }
- config.headers = mergeHeaders(a.headers, b.headers);
- return config;
-};
-
-const headersEntries = (headers: Headers): Array<[string, string]> => {
- const entries: Array<[string, string]> = [];
- headers.forEach((value, key) => {
- entries.push([key, value]);
- });
- return entries;
-};
-
-export const mergeHeaders = (
- ...headers: Array['headers'] | undefined>
-): Headers => {
- const mergedHeaders = new Headers();
- for (const header of headers) {
- if (!header) {
- continue;
- }
-
- const iterator = header instanceof Headers ? headersEntries(header) : Object.entries(header);
-
- for (const [key, value] of iterator) {
- if (value === null) {
- mergedHeaders.delete(key);
- } else if (Array.isArray(value)) {
- for (const v of value) {
- mergedHeaders.append(key, v as string);
- }
- } else if (value !== undefined) {
- // assume object headers are meant to be JSON stringified, i.e. their
- // content value in OpenAPI specification is 'application/json'
- mergedHeaders.set(
- key,
- typeof value === 'object' ? JSON.stringify(value) : (value as string),
- );
- }
- }
- }
- return mergedHeaders;
-};
-
-type ErrInterceptor = (
- error: Err,
- response: Res,
- request: Req,
- options: Options,
-) => Err | Promise;
-
-type ReqInterceptor = (request: Req, options: Options) => Req | Promise;
-
-type ResInterceptor = (
- response: Res,
- request: Req,
- options: Options,
-) => Res | Promise;
-
-class Interceptors {
- fns: Array = [];
-
- clear(): void {
- this.fns = [];
- }
-
- eject(id: number | Interceptor): void {
- const index = this.getInterceptorIndex(id);
- if (this.fns[index]) {
- this.fns[index] = null;
- }
- }
-
- exists(id: number | Interceptor): boolean {
- const index = this.getInterceptorIndex(id);
- return Boolean(this.fns[index]);
- }
-
- getInterceptorIndex(id: number | Interceptor): number {
- if (typeof id === 'number') {
- return this.fns[id] ? id : -1;
- }
- return this.fns.indexOf(id);
- }
-
- update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
- const index = this.getInterceptorIndex(id);
- if (this.fns[index]) {
- this.fns[index] = fn;
- return id;
- }
- return false;
- }
-
- use(fn: Interceptor): number {
- this.fns.push(fn);
- return this.fns.length - 1;
- }
-}
-
-export interface Middleware {
- error: Interceptors>;
- request: Interceptors>;
- response: Interceptors>;
-}
-
-export const createInterceptors = (): Middleware<
- Req,
- Res,
- Err,
- Options
-> => ({
- error: new Interceptors>(),
- request: new Interceptors>(),
- response: new Interceptors>(),
-});
-
-const defaultQuerySerializer = createQuerySerializer({
- allowReserved: false,
- array: {
- explode: true,
- style: 'form',
- },
- object: {
- explode: true,
- style: 'deepObject',
- },
-});
-
-const defaultHeaders = {
- 'Content-Type': 'application/json',
-};
-
-export const createConfig = (
- override: Config & T> = {},
-): Config & T> => ({
- ...jsonBodySerializer,
- headers: defaultHeaders,
- parseAs: 'auto',
- querySerializer: defaultQuerySerializer,
- ...override,
-});
diff --git a/ui/desktop/src/api/core/auth.gen.ts b/ui/desktop/src/api/core/auth.gen.ts
deleted file mode 100644
index 3ebf9947883f..000000000000
--- a/ui/desktop/src/api/core/auth.gen.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-export type AuthToken = string | undefined;
-
-export interface Auth {
- /**
- * Which part of the request do we use to send the auth?
- *
- * @default 'header'
- */
- in?: 'header' | 'query' | 'cookie';
- /**
- * Header or query parameter name.
- *
- * @default 'Authorization'
- */
- name?: string;
- scheme?: 'basic' | 'bearer';
- type: 'apiKey' | 'http';
-}
-
-export const getAuthToken = async (
- auth: Auth,
- callback: ((auth: Auth) => Promise | AuthToken) | AuthToken,
-): Promise => {
- const token = typeof callback === 'function' ? await callback(auth) : callback;
-
- if (!token) {
- return;
- }
-
- if (auth.scheme === 'bearer') {
- return `Bearer ${token}`;
- }
-
- if (auth.scheme === 'basic') {
- return `Basic ${btoa(token)}`;
- }
-
- return token;
-};
diff --git a/ui/desktop/src/api/core/bodySerializer.gen.ts b/ui/desktop/src/api/core/bodySerializer.gen.ts
deleted file mode 100644
index 8ad92c9ffd6a..000000000000
--- a/ui/desktop/src/api/core/bodySerializer.gen.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-import type { ArrayStyle, ObjectStyle, SerializerOptions } from './pathSerializer.gen';
-
-export type QuerySerializer = (query: Record) => string;
-
-export type BodySerializer = (body: any) => any;
-
-type QuerySerializerOptionsObject = {
- allowReserved?: boolean;
- array?: Partial>;
- object?: Partial>;
-};
-
-export type QuerySerializerOptions = QuerySerializerOptionsObject & {
- /**
- * Per-parameter serialization overrides. When provided, these settings
- * override the global array/object settings for specific parameter names.
- */
- parameters?: Record;
-};
-
-const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
- if (typeof value === 'string' || value instanceof Blob) {
- data.append(key, value);
- } else if (value instanceof Date) {
- data.append(key, value.toISOString());
- } else {
- data.append(key, JSON.stringify(value));
- }
-};
-
-const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => {
- if (typeof value === 'string') {
- data.append(key, value);
- } else {
- data.append(key, JSON.stringify(value));
- }
-};
-
-export const formDataBodySerializer = {
- bodySerializer: | Array>>(
- body: T,
- ): FormData => {
- const data = new FormData();
-
- Object.entries(body).forEach(([key, value]) => {
- if (value === undefined || value === null) {
- return;
- }
- if (Array.isArray(value)) {
- value.forEach((v) => serializeFormDataPair(data, key, v));
- } else {
- serializeFormDataPair(data, key, value);
- }
- });
-
- return data;
- },
-};
-
-export const jsonBodySerializer = {
- bodySerializer: (body: T): string =>
- JSON.stringify(body, (_key, value) => (typeof value === 'bigint' ? value.toString() : value)),
-};
-
-export const urlSearchParamsBodySerializer = {
- bodySerializer: | Array>>(body: T): string => {
- const data = new URLSearchParams();
-
- Object.entries(body).forEach(([key, value]) => {
- if (value === undefined || value === null) {
- return;
- }
- if (Array.isArray(value)) {
- value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
- } else {
- serializeUrlSearchParamsPair(data, key, value);
- }
- });
-
- return data.toString();
- },
-};
diff --git a/ui/desktop/src/api/core/params.gen.ts b/ui/desktop/src/api/core/params.gen.ts
deleted file mode 100644
index 7955601a5cc0..000000000000
--- a/ui/desktop/src/api/core/params.gen.ts
+++ /dev/null
@@ -1,169 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-type Slot = 'body' | 'headers' | 'path' | 'query';
-
-export type Field =
- | {
- in: Exclude;
- /**
- * Field name. This is the name we want the user to see and use.
- */
- key: string;
- /**
- * Field mapped name. This is the name we want to use in the request.
- * If omitted, we use the same value as `key`.
- */
- map?: string;
- }
- | {
- in: Extract;
- /**
- * Key isn't required for bodies.
- */
- key?: string;
- map?: string;
- }
- | {
- /**
- * Field name. This is the name we want the user to see and use.
- */
- key: string;
- /**
- * Field mapped name. This is the name we want to use in the request.
- * If `in` is omitted, `map` aliases `key` to the transport layer.
- */
- map: Slot;
- };
-
-export interface Fields {
- allowExtra?: Partial>;
- args?: ReadonlyArray;
-}
-
-export type FieldsConfig = ReadonlyArray;
-
-const extraPrefixesMap: Record = {
- $body_: 'body',
- $headers_: 'headers',
- $path_: 'path',
- $query_: 'query',
-};
-const extraPrefixes = Object.entries(extraPrefixesMap);
-
-type KeyMap = Map<
- string,
- | {
- in: Slot;
- map?: string;
- }
- | {
- in?: never;
- map: Slot;
- }
->;
-
-const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
- if (!map) {
- map = new Map();
- }
-
- for (const config of fields) {
- if ('in' in config) {
- if (config.key) {
- map.set(config.key, {
- in: config.in,
- map: config.map,
- });
- }
- } else if ('key' in config) {
- map.set(config.key, {
- map: config.map,
- });
- } else if (config.args) {
- buildKeyMap(config.args, map);
- }
- }
-
- return map;
-};
-
-interface Params {
- body: unknown;
- headers: Record;
- path: Record;
- query: Record;
-}
-
-const stripEmptySlots = (params: Params) => {
- for (const [slot, value] of Object.entries(params)) {
- if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
- delete params[slot as Slot];
- }
- }
-};
-
-export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => {
- const params: Params = {
- body: {},
- headers: {},
- path: {},
- query: {},
- };
-
- const map = buildKeyMap(fields);
-
- let config: FieldsConfig[number] | undefined;
-
- for (const [index, arg] of args.entries()) {
- if (fields[index]) {
- config = fields[index];
- }
-
- if (!config) {
- continue;
- }
-
- if ('in' in config) {
- if (config.key) {
- const field = map.get(config.key)!;
- const name = field.map || config.key;
- if (field.in) {
- (params[field.in] as Record)[name] = arg;
- }
- } else {
- params.body = arg;
- }
- } else {
- for (const [key, value] of Object.entries(arg ?? {})) {
- const field = map.get(key);
-
- if (field) {
- if (field.in) {
- const name = field.map || key;
- (params[field.in] as Record)[name] = value;
- } else {
- params[field.map] = value;
- }
- } else {
- const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));
-
- if (extra) {
- const [prefix, slot] = extra;
- (params[slot] as Record)[key.slice(prefix.length)] = value;
- } else if ('allowExtra' in config && config.allowExtra) {
- for (const [slot, allowed] of Object.entries(config.allowExtra)) {
- if (allowed) {
- (params[slot as Slot] as Record)[key] = value;
- break;
- }
- }
- }
- }
- }
- }
- }
-
- stripEmptySlots(params);
-
- return params;
-};
diff --git a/ui/desktop/src/api/core/pathSerializer.gen.ts b/ui/desktop/src/api/core/pathSerializer.gen.ts
deleted file mode 100644
index 994b2848c63f..000000000000
--- a/ui/desktop/src/api/core/pathSerializer.gen.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {}
-
-interface SerializePrimitiveOptions {
- allowReserved?: boolean;
- name: string;
-}
-
-export interface SerializerOptions {
- /**
- * @default true
- */
- explode: boolean;
- style: T;
-}
-
-export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
-export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
-type MatrixStyle = 'label' | 'matrix' | 'simple';
-export type ObjectStyle = 'form' | 'deepObject';
-type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
-
-interface SerializePrimitiveParam extends SerializePrimitiveOptions {
- value: string;
-}
-
-export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
- switch (style) {
- case 'label':
- return '.';
- case 'matrix':
- return ';';
- case 'simple':
- return ',';
- default:
- return '&';
- }
-};
-
-export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
- switch (style) {
- case 'form':
- return ',';
- case 'pipeDelimited':
- return '|';
- case 'spaceDelimited':
- return '%20';
- default:
- return ',';
- }
-};
-
-export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
- switch (style) {
- case 'label':
- return '.';
- case 'matrix':
- return ';';
- case 'simple':
- return ',';
- default:
- return '&';
- }
-};
-
-export const serializeArrayParam = ({
- allowReserved,
- explode,
- name,
- style,
- value,
-}: SerializeOptions & {
- value: unknown[];
-}) => {
- if (!explode) {
- const joinedValues = (
- allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
- ).join(separatorArrayNoExplode(style));
- switch (style) {
- case 'label':
- return `.${joinedValues}`;
- case 'matrix':
- return `;${name}=${joinedValues}`;
- case 'simple':
- return joinedValues;
- default:
- return `${name}=${joinedValues}`;
- }
- }
-
- const separator = separatorArrayExplode(style);
- const joinedValues = value
- .map((v) => {
- if (style === 'label' || style === 'simple') {
- return allowReserved ? v : encodeURIComponent(v as string);
- }
-
- return serializePrimitiveParam({
- allowReserved,
- name,
- value: v as string,
- });
- })
- .join(separator);
- return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
-};
-
-export const serializePrimitiveParam = ({
- allowReserved,
- name,
- value,
-}: SerializePrimitiveParam) => {
- if (value === undefined || value === null) {
- return '';
- }
-
- if (typeof value === 'object') {
- throw new Error(
- 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.',
- );
- }
-
- return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
-};
-
-export const serializeObjectParam = ({
- allowReserved,
- explode,
- name,
- style,
- value,
- valueOnly,
-}: SerializeOptions & {
- value: Record | Date;
- valueOnly?: boolean;
-}) => {
- if (value instanceof Date) {
- return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
- }
-
- if (style !== 'deepObject' && !explode) {
- let values: string[] = [];
- Object.entries(value).forEach(([key, v]) => {
- values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)];
- });
- const joinedValues = values.join(',');
- switch (style) {
- case 'form':
- return `${name}=${joinedValues}`;
- case 'label':
- return `.${joinedValues}`;
- case 'matrix':
- return `;${name}=${joinedValues}`;
- default:
- return joinedValues;
- }
- }
-
- const separator = separatorObjectExplode(style);
- const joinedValues = Object.entries(value)
- .map(([key, v]) =>
- serializePrimitiveParam({
- allowReserved,
- name: style === 'deepObject' ? `${name}[${key}]` : key,
- value: v as string,
- }),
- )
- .join(separator);
- return style === 'label' || style === 'matrix' ? separator + joinedValues : joinedValues;
-};
diff --git a/ui/desktop/src/api/core/queryKeySerializer.gen.ts b/ui/desktop/src/api/core/queryKeySerializer.gen.ts
deleted file mode 100644
index 5000df606f37..000000000000
--- a/ui/desktop/src/api/core/queryKeySerializer.gen.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-/**
- * JSON-friendly union that mirrors what Pinia Colada can hash.
- */
-export type JsonValue =
- | null
- | string
- | number
- | boolean
- | JsonValue[]
- | { [key: string]: JsonValue };
-
-/**
- * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes.
- */
-export const queryKeyJsonReplacer = (_key: string, value: unknown) => {
- if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
- return undefined;
- }
- if (typeof value === 'bigint') {
- return value.toString();
- }
- if (value instanceof Date) {
- return value.toISOString();
- }
- return value;
-};
-
-/**
- * Safely stringifies a value and parses it back into a JsonValue.
- */
-export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => {
- try {
- const json = JSON.stringify(input, queryKeyJsonReplacer);
- if (json === undefined) {
- return undefined;
- }
- return JSON.parse(json) as JsonValue;
- } catch {
- return undefined;
- }
-};
-
-/**
- * Detects plain objects (including objects with a null prototype).
- */
-const isPlainObject = (value: unknown): value is Record => {
- if (value === null || typeof value !== 'object') {
- return false;
- }
- const prototype = Object.getPrototypeOf(value as object);
- return prototype === Object.prototype || prototype === null;
-};
-
-/**
- * Turns URLSearchParams into a sorted JSON object for deterministic keys.
- */
-const serializeSearchParams = (params: URLSearchParams): JsonValue => {
- const entries = Array.from(params.entries()).sort(([a], [b]) => a.localeCompare(b));
- const result: Record = {};
-
- for (const [key, value] of entries) {
- const existing = result[key];
- if (existing === undefined) {
- result[key] = value;
- continue;
- }
-
- if (Array.isArray(existing)) {
- (existing as string[]).push(value);
- } else {
- result[key] = [existing, value];
- }
- }
-
- return result;
-};
-
-/**
- * Normalizes any accepted value into a JSON-friendly shape for query keys.
- */
-export const serializeQueryKeyValue = (value: unknown): JsonValue | undefined => {
- if (value === null) {
- return null;
- }
-
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
- return value;
- }
-
- if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
- return undefined;
- }
-
- if (typeof value === 'bigint') {
- return value.toString();
- }
-
- if (value instanceof Date) {
- return value.toISOString();
- }
-
- if (Array.isArray(value)) {
- return stringifyToJsonValue(value);
- }
-
- if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) {
- return serializeSearchParams(value);
- }
-
- if (isPlainObject(value)) {
- return stringifyToJsonValue(value);
- }
-
- return undefined;
-};
diff --git a/ui/desktop/src/api/core/serverSentEvents.gen.ts b/ui/desktop/src/api/core/serverSentEvents.gen.ts
deleted file mode 100644
index 6aa6cf02a4f4..000000000000
--- a/ui/desktop/src/api/core/serverSentEvents.gen.ts
+++ /dev/null
@@ -1,243 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-import type { Config } from './types.gen';
-
-export type ServerSentEventsOptions = Omit &
- Pick & {
- /**
- * Fetch API implementation. You can use this option to provide a custom
- * fetch instance.
- *
- * @default globalThis.fetch
- */
- fetch?: typeof fetch;
- /**
- * Implementing clients can call request interceptors inside this hook.
- */
- onRequest?: (url: string, init: RequestInit) => Promise;
- /**
- * Callback invoked when a network or parsing error occurs during streaming.
- *
- * This option applies only if the endpoint returns a stream of events.
- *
- * @param error The error that occurred.
- */
- onSseError?: (error: unknown) => void;
- /**
- * Callback invoked when an event is streamed from the server.
- *
- * This option applies only if the endpoint returns a stream of events.
- *
- * @param event Event streamed from the server.
- * @returns Nothing (void).
- */
- onSseEvent?: (event: StreamEvent) => void;
- serializedBody?: RequestInit['body'];
- /**
- * Default retry delay in milliseconds.
- *
- * This option applies only if the endpoint returns a stream of events.
- *
- * @default 3000
- */
- sseDefaultRetryDelay?: number;
- /**
- * Maximum number of retry attempts before giving up.
- */
- sseMaxRetryAttempts?: number;
- /**
- * Maximum retry delay in milliseconds.
- *
- * Applies only when exponential backoff is used.
- *
- * This option applies only if the endpoint returns a stream of events.
- *
- * @default 30000
- */
- sseMaxRetryDelay?: number;
- /**
- * Optional sleep function for retry backoff.
- *
- * Defaults to using `setTimeout`.
- */
- sseSleepFn?: (ms: number) => Promise;
- url: string;
- };
-
-export interface StreamEvent {
- data: TData;
- event?: string;
- id?: string;
- retry?: number;
-}
-
-export type ServerSentEventsResult = {
- stream: AsyncGenerator<
- TData extends Record ? TData[keyof TData] : TData,
- TReturn,
- TNext
- >;
-};
-
-export const createSseClient = ({
- onRequest,
- onSseError,
- onSseEvent,
- responseTransformer,
- responseValidator,
- sseDefaultRetryDelay,
- sseMaxRetryAttempts,
- sseMaxRetryDelay,
- sseSleepFn,
- url,
- ...options
-}: ServerSentEventsOptions): ServerSentEventsResult => {
- let lastEventId: string | undefined;
-
- const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
-
- const createStream = async function* () {
- let retryDelay: number = sseDefaultRetryDelay ?? 3000;
- let attempt = 0;
- const signal = options.signal ?? new AbortController().signal;
-
- while (true) {
- if (signal.aborted) break;
-
- attempt++;
-
- const headers =
- options.headers instanceof Headers
- ? options.headers
- : new Headers(options.headers as Record | undefined);
-
- if (lastEventId !== undefined) {
- headers.set('Last-Event-ID', lastEventId);
- }
-
- try {
- const requestInit: RequestInit = {
- redirect: 'follow',
- ...options,
- body: options.serializedBody,
- headers,
- signal,
- };
- let request = new Request(url, requestInit);
- if (onRequest) {
- request = await onRequest(url, requestInit);
- }
- // fetch must be assigned here, otherwise it would throw the error:
- // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
- const _fetch = options.fetch ?? globalThis.fetch;
- const response = await _fetch(request);
-
- if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
-
- if (!response.body) throw new Error('No body in SSE response');
-
- const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
-
- let buffer = '';
-
- const abortHandler = () => {
- try {
- reader.cancel();
- } catch {
- // noop
- }
- };
-
- signal.addEventListener('abort', abortHandler);
-
- try {
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer += value;
- // Normalize line endings: CRLF -> LF, then CR -> LF
- buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
-
- const chunks = buffer.split('\n\n');
- buffer = chunks.pop() ?? '';
-
- for (const chunk of chunks) {
- const lines = chunk.split('\n');
- const dataLines: Array = [];
- let eventName: string | undefined;
-
- for (const line of lines) {
- if (line.startsWith('data:')) {
- dataLines.push(line.replace(/^data:\s*/, ''));
- } else if (line.startsWith('event:')) {
- eventName = line.replace(/^event:\s*/, '');
- } else if (line.startsWith('id:')) {
- lastEventId = line.replace(/^id:\s*/, '');
- } else if (line.startsWith('retry:')) {
- const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
- if (!Number.isNaN(parsed)) {
- retryDelay = parsed;
- }
- }
- }
-
- let data: unknown;
- let parsedJson = false;
-
- if (dataLines.length) {
- const rawData = dataLines.join('\n');
- try {
- data = JSON.parse(rawData);
- parsedJson = true;
- } catch {
- data = rawData;
- }
- }
-
- if (parsedJson) {
- if (responseValidator) {
- await responseValidator(data);
- }
-
- if (responseTransformer) {
- data = await responseTransformer(data);
- }
- }
-
- onSseEvent?.({
- data,
- event: eventName,
- id: lastEventId,
- retry: retryDelay,
- });
-
- if (dataLines.length) {
- yield data as any;
- }
- }
- }
- } finally {
- signal.removeEventListener('abort', abortHandler);
- reader.releaseLock();
- }
-
- break; // exit loop on normal completion
- } catch (error) {
- // connection failed or aborted; retry after delay
- onSseError?.(error);
-
- if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
- break; // stop after firing error
- }
-
- // exponential backoff: double retry each attempt, cap at 30s
- const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
- await sleep(backoff);
- }
- }
- };
-
- const stream = createStream();
-
- return { stream };
-};
diff --git a/ui/desktop/src/api/core/types.gen.ts b/ui/desktop/src/api/core/types.gen.ts
deleted file mode 100644
index 97463257e43e..000000000000
--- a/ui/desktop/src/api/core/types.gen.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-import type { Auth, AuthToken } from './auth.gen';
-import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from './bodySerializer.gen';
-
-export type HttpMethod =
- | 'connect'
- | 'delete'
- | 'get'
- | 'head'
- | 'options'
- | 'patch'
- | 'post'
- | 'put'
- | 'trace';
-
-export type Client<
- RequestFn = never,
- Config = unknown,
- MethodFn = never,
- BuildUrlFn = never,
- SseFn = never,
-> = {
- /**
- * Returns the final request URL.
- */
- buildUrl: BuildUrlFn;
- getConfig: () => Config;
- request: RequestFn;
- setConfig: (config: Config) => Config;
-} & {
- [K in HttpMethod]: MethodFn;
-} & ([SseFn] extends [never] ? { sse?: never } : { sse: { [K in HttpMethod]: SseFn } });
-
-export interface Config {
- /**
- * Auth token or a function returning auth token. The resolved value will be
- * added to the request payload as defined by its `security` array.
- */
- auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken;
- /**
- * A function for serializing request body parameter. By default,
- * {@link JSON.stringify()} will be used.
- */
- bodySerializer?: BodySerializer | null;
- /**
- * An object containing any HTTP headers that you want to pre-populate your
- * `Headers` object with.
- *
- * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
- */
- headers?:
- | RequestInit['headers']
- | Record<
- string,
- string | number | boolean | (string | number | boolean)[] | null | undefined | unknown
- >;
- /**
- * The request method.
- *
- * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
- */
- method?: Uppercase;
- /**
- * A function for serializing request query parameters. By default, arrays
- * will be exploded in form style, objects will be exploded in deepObject
- * style, and reserved characters are percent-encoded.
- *
- * This method will have no effect if the native `paramsSerializer()` Axios
- * API function is used.
- *
- * {@link https://swagger.io/docs/specification/serialization/#query View examples}
- */
- querySerializer?: QuerySerializer | QuerySerializerOptions;
- /**
- * A function validating request data. This is useful if you want to ensure
- * the request conforms to the desired shape, so it can be safely sent to
- * the server.
- */
- requestValidator?: (data: unknown) => Promise;
- /**
- * A function transforming response data before it's returned. This is useful
- * for post-processing data, e.g. converting ISO strings into Date objects.
- */
- responseTransformer?: (data: unknown) => Promise;
- /**
- * A function validating response data. This is useful if you want to ensure
- * the response conforms to the desired shape, so it can be safely passed to
- * the transformers and returned to the user.
- */
- responseValidator?: (data: unknown) => Promise;
-}
-
-type IsExactlyNeverOrNeverUndefined = [T] extends [never]
- ? true
- : [T] extends [never | undefined]
- ? [undefined] extends [T]
- ? false
- : true
- : false;
-
-export type OmitNever> = {
- [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K];
-};
diff --git a/ui/desktop/src/api/core/utils.gen.ts b/ui/desktop/src/api/core/utils.gen.ts
deleted file mode 100644
index e7ddbe354117..000000000000
--- a/ui/desktop/src/api/core/utils.gen.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-import type { BodySerializer, QuerySerializer } from './bodySerializer.gen';
-import {
- type ArraySeparatorStyle,
- serializeArrayParam,
- serializeObjectParam,
- serializePrimitiveParam,
-} from './pathSerializer.gen';
-
-export interface PathSerializer {
- path: Record;
- url: string;
-}
-
-export const PATH_PARAM_RE = /\{[^{}]+\}/g;
-
-export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
- let url = _url;
- const matches = _url.match(PATH_PARAM_RE);
- if (matches) {
- for (const match of matches) {
- let explode = false;
- let name = match.substring(1, match.length - 1);
- let style: ArraySeparatorStyle = 'simple';
-
- if (name.endsWith('*')) {
- explode = true;
- name = name.substring(0, name.length - 1);
- }
-
- if (name.startsWith('.')) {
- name = name.substring(1);
- style = 'label';
- } else if (name.startsWith(';')) {
- name = name.substring(1);
- style = 'matrix';
- }
-
- const value = path[name];
-
- if (value === undefined || value === null) {
- continue;
- }
-
- if (Array.isArray(value)) {
- url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
- continue;
- }
-
- if (typeof value === 'object') {
- url = url.replace(
- match,
- serializeObjectParam({
- explode,
- name,
- style,
- value: value as Record,
- valueOnly: true,
- }),
- );
- continue;
- }
-
- if (style === 'matrix') {
- url = url.replace(
- match,
- `;${serializePrimitiveParam({
- name,
- value: value as string,
- })}`,
- );
- continue;
- }
-
- const replaceValue = encodeURIComponent(
- style === 'label' ? `.${value as string}` : (value as string),
- );
- url = url.replace(match, replaceValue);
- }
- }
- return url;
-};
-
-export const getUrl = ({
- baseUrl,
- path,
- query,
- querySerializer,
- url: _url,
-}: {
- baseUrl?: string;
- path?: Record;
- query?: Record;
- querySerializer: QuerySerializer;
- url: string;
-}) => {
- const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
- let url = (baseUrl ?? '') + pathUrl;
- if (path) {
- url = defaultPathSerializer({ path, url });
- }
- let search = query ? querySerializer(query) : '';
- if (search.startsWith('?')) {
- search = search.substring(1);
- }
- if (search) {
- url += `?${search}`;
- }
- return url;
-};
-
-export function getValidRequestBody(options: {
- body?: unknown;
- bodySerializer?: BodySerializer | null;
- serializedBody?: unknown;
-}) {
- const hasBody = options.body !== undefined;
- const isSerializedBody = hasBody && options.bodySerializer;
-
- if (isSerializedBody) {
- if ('serializedBody' in options) {
- const hasSerializedBody =
- options.serializedBody !== undefined && options.serializedBody !== '';
-
- return hasSerializedBody ? options.serializedBody : null;
- }
-
- // not all clients implement a serializedBody property (i.e. client-axios)
- return options.body !== '' ? options.body : null;
- }
-
- // plain/text body
- if (hasBody) {
- return options.body;
- }
-
- // no body was provided
- return undefined;
-}
diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts
deleted file mode 100644
index afe1f8254327..000000000000
--- a/ui/desktop/src/api/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-export { addExtension, agentAddExtension, agentRemoveExtension, cancelDownload, checkProvider, cleanupProviderCache, confirmToolAction, createCustomProvider, createSchedule, decodeRecipe, deleteModel, deleteProviderSecret, deleteRecipe, deleteSchedule, diagnostics, downloadModel, encodeRecipe, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModelInfo, getProviderModels, getSession, getSessionExtensions, getSlashCommands, getTools, inspectRunningJob, killRunningJob, listModels, listProviderSecrets, listRecipes, listSchedules, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, recipeToYaml, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, sendTelemetryEvent, sessionCancel, sessionEvents, sessionReply, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, status, stopAgent, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateSchedule, updateSession, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, validateConfig } from './sdk.gen';
-export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelRequest, ChatRequest, CheckProviderData, CheckProviderRequest, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponse, CleanupProviderCacheResponses, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, ContentBlock, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponse2, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponse, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DiagnosticsConfig, DiagnosticsData, DiagnosticsError, DiagnosticsErrors, DiagnosticsExtensions, DiagnosticsLevel, DiagnosticsLogs, DiagnosticsPrompt, DiagnosticsReport, DiagnosticsResponse, DiagnosticsResponses, DiagnosticsScheduledRecipe, DiagnosticsTextFile, DictationProvider, DictationProviderStatus, DownloadModelData, DownloadModelErrors, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponse, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GooseApp, GooseMode, Icon, IconTheme, ImageContent, InferenceMetadata, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponse, ListProviderSecretsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, LoadedProvider, McpAppResource, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProviderModelInfoQuery, ProvidersData, ProviderSecret, ProviderSecretsResponse, ProviderSecretStatus, ProviderSecretStorage, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionCancelData, SessionCancelResponses, SessionDisplayInfo, SessionEventsData, SessionEventsErrors, SessionEventsResponse, SessionEventsResponses, SessionExtensionsResponse, SessionReplyData, SessionReplyErrors, SessionReplyRequest, SessionReplyResponse, SessionReplyResponse2, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, ThinkingContent, ThinkingEffort, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionRequest, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, Usage, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen';
diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts
deleted file mode 100644
index 4786dcf2704e..000000000000
--- a/ui/desktop/src/api/sdk.gen.ts
+++ /dev/null
@@ -1,466 +0,0 @@
-// This file is auto-generated by @hey-api/openapi-ts
-
-import type { Client, Options as Options2, TDataShape } from './client';
-import { client } from './client.gen';
-import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CheckProviderData, CleanupProviderCacheData, CleanupProviderCacheErrors, CleanupProviderCacheResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteProviderSecretData, DeleteProviderSecretErrors, DeleteProviderSecretResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, DownloadModelData, DownloadModelErrors, DownloadModelResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetCanonicalModelInfoData, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPromptData, GetPromptErrors, GetPromptResponses, GetPromptsData, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponses, GetProviderModelInfoData, GetProviderModelInfoErrors, GetProviderModelInfoResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListModelsData, ListModelsResponses, ListProviderSecretsData, ListProviderSecretsErrors, ListProviderSecretsResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResetPromptData, ResetPromptErrors, ResetPromptResponses, RestartAgentData, RestartAgentErrors, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SavePromptData, SavePromptErrors, SavePromptResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionCancelData, SessionCancelResponses, SessionEventsData, SessionEventsErrors, SessionEventsResponses, SessionReplyData, SessionReplyErrors, SessionReplyResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StatusData, StatusResponses, StopAgentData, StopAgentErrors, StopAgentResponses, SystemInfoData, SystemInfoResponses, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionData, UpdateSessionErrors, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
-
-export type Options = Options2 & {
- /**
- * You can provide a client instance returned by `createClient()` instead of
- * individual options. This might be also useful if you want to implement a
- * custom client.
- */
- client?: Client;
- /**
- * You can pass arbitrary values through the `meta` object. This can be
- * used to access values that aren't defined as part of the SDK function.
- */
- meta?: Record;
-};
-
-export const confirmToolAction = (options: Options) => (options.client ?? client).post({
- url: '/action-required/tool-confirmation',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const agentAddExtension = (options: Options) => (options.client ?? client).post({
- url: '/agent/add_extension',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const agentRemoveExtension = (options: Options) => (options.client ?? client).post({
- url: '/agent/remove_extension',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const restartAgent = (options: Options) => (options.client ?? client).post({
- url: '/agent/restart',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const resumeAgent = (options: Options) => (options.client ?? client).post({
- url: '/agent/resume',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const startAgent = (options: Options) => (options.client ?? client).post({
- url: '/agent/start',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const stopAgent = (options: Options) => (options.client ?? client).post({
- url: '/agent/stop',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const getTools = (options: Options) => (options.client ?? client).get({ url: '/agent/tools', ...options });
-
-export const updateFromSession = (options: Options) => (options.client ?? client).post({
- url: '/agent/update_from_session',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const updateAgentProvider = (options: Options) => (options.client ?? client).post({
- url: '/agent/update_provider',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const updateSession = (options: Options) => (options.client ?? client).post({
- url: '/agent/update_session',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const updateWorkingDir = (options: Options) => (options.client ?? client).post({
- url: '/agent/update_working_dir',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const readAllConfig = (options?: Options) => (options?.client ?? client).get({ url: '/config', ...options });
-
-export const getCanonicalModelInfo = (options: Options) => (options.client ?? client).post({
- url: '/config/canonical-model-info',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const checkProvider = (options: Options) => (options.client ?? client).post({
- url: '/config/check_provider',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const createCustomProvider = (options: Options) => (options.client ?? client).post({
- url: '/config/custom-providers',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const removeCustomProvider = (options: Options) => (options.client ?? client).delete({ url: '/config/custom-providers/{id}', ...options });
-
-export const getCustomProvider = (options: Options) => (options.client ?? client).get({ url: '/config/custom-providers/{id}', ...options });
-
-export const updateCustomProvider = (options: Options) => (options.client ?? client).put({
- url: '/config/custom-providers/{id}',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const getExtensions = (options?: Options) => (options?.client ?? client).get({ url: '/config/extensions', ...options });
-
-export const addExtension = (options: Options) => (options.client ?? client).post({
- url: '/config/extensions',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const removeExtension = (options: Options) => (options.client ?? client).delete({ url: '/config/extensions/{name}', ...options });
-
-export const getPrompts = (options?: Options) => (options?.client ?? client).get({ url: '/config/prompts', ...options });
-
-export const resetPrompt = (options: Options) => (options.client ?? client).delete({ url: '/config/prompts/{name}', ...options });
-
-export const getPrompt = (options: Options) => (options.client ?? client).get({ url: '/config/prompts/{name}', ...options });
-
-export const savePrompt = (options: Options) => (options.client ?? client).put({
- url: '/config/prompts/{name}',
- ...options,
- headers: {
- 'Content-Type': 'application/json',
- ...options.headers
- }
-});
-
-export const getProviderCatalog = (options?: Options) => (options?.client ?? client).get({ url: '/config/provider-catalog', ...options });
-
-export const getProviderCatalogTemplate = (options: Options) => (options.client ?? client).get({ url: '/config/provider-catalog/{id}', ...options });
-
-export const listProviderSecrets = (options?: Options) => (options?.client ?? client).get({ url: '/config/provider-secrets', ...options });
-
-export const deleteProviderSecret = (options: Options