mount acp in the goosed server to migrate using acp protocols iteratively (#9097)

This commit is contained in:
Lifei Zhou
2026-05-18 09:43:30 +10:00
committed by GitHub
parent d04c882388
commit 1fafd74413
17 changed files with 1430 additions and 61 deletions
+93
View File
@@ -0,0 +1,93 @@
import {
DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
GooseClient,
type Client,
type GooseInitializeRequest,
} from '@aaif/goose-sdk';
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
import packageJson from '../../package.json';
import { createWebSocketStream } from './createWebSocketStream';
let clientPromise: Promise<GooseClient> | null = null;
let resolvedClient: GooseClient | null = null;
function createClientCallbacks(): () => Client {
return () => ({
requestPermission: async () => {
return {
outcome: {
outcome: 'cancelled',
},
};
},
sessionUpdate: async () => {},
});
}
function monitorConnection(client: GooseClient): void {
client.closed
.then(() => {
resolvedClient = null;
clientPromise = null;
})
.catch(() => {
resolvedClient = null;
clientPromise = null;
});
}
async function initializeConnection(): Promise<GooseClient> {
const wsUrl = await window.electron.getAcpUrl();
if (!wsUrl) {
throw new Error('ACP URL is not available');
}
const stream = createWebSocketStream(wsUrl);
const client = new GooseClient(createClientCallbacks(), stream);
await client.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
_meta: {
goose: {
mcpHostCapabilities: DEFAULT_GOOSE_MCP_HOST_CAPABILITIES,
},
},
},
clientInfo: {
name: packageJson.name,
version: packageJson.version,
},
} satisfies GooseInitializeRequest);
monitorConnection(client);
return client;
}
export async function getAcpClient(): Promise<GooseClient> {
if (resolvedClient) {
return resolvedClient;
}
if (!clientPromise) {
clientPromise = initializeConnection()
.then((client) => {
resolvedClient = client;
return client;
})
.catch((error) => {
clientPromise = null;
throw error;
});
}
return clientPromise;
}
export function getAcpClientSync(): GooseClient | null {
return resolvedClient;
}
export function isAcpClientReady(): boolean {
return resolvedClient !== null;
}
@@ -0,0 +1,77 @@
import type { Stream } from '@aaif/goose-sdk';
export function createWebSocketStream(wsUrl: string): Stream {
const ws = new window.WebSocket(wsUrl);
const incoming: unknown[] = [];
const waiters: Array<() => void> = [];
let closed = false;
function pushMessage(message: unknown): void {
incoming.push(message);
waiters.shift()?.();
}
function waitForMessage(): Promise<void> {
if (incoming.length > 0 || closed) {
return Promise.resolve();
}
return new Promise<void>((resolve) => waiters.push(resolve));
}
const openPromise = new Promise<void>((resolve, reject) => {
ws.addEventListener('open', () => resolve(), { once: true });
ws.addEventListener('error', () => reject(new Error('ACP WebSocket connection failed')), {
once: true,
});
});
ws.addEventListener('message', (event) => {
if (typeof event.data !== 'string') {
return;
}
try {
pushMessage(JSON.parse(event.data));
} catch {
// Ignore malformed messages from the transport.
}
});
const closeWaiters = () => {
closed = true;
for (const waiter of waiters) {
waiter();
}
waiters.length = 0;
};
ws.addEventListener('close', closeWaiters);
ws.addEventListener('error', closeWaiters);
const readable = new window.ReadableStream({
async pull(controller) {
await waitForMessage();
while (incoming.length > 0) {
controller.enqueue(incoming.shift());
}
if (closed && incoming.length === 0) {
controller.close();
}
},
});
const writable = new window.WritableStream({
async write(message) {
await openPromise;
ws.send(JSON.stringify(message));
},
close() {
ws.close();
},
abort() {
ws.close();
},
});
return { readable, writable } as Stream;
}
+11
View File
@@ -0,0 +1,11 @@
import type { ExtensionResponse, ExtensionEntry } from '../api';
import { getAcpClient } from './acpConnection';
export async function getConfiguredExtensions(): Promise<ExtensionResponse> {
const client = await getAcpClient();
const response = await client.goose.GooseConfigExtensions({});
return {
extensions: response.extensions as ExtensionEntry[],
warnings: response.warnings,
};
}
+11 -31
View File
@@ -4,17 +4,16 @@ import {
readConfig,
removeConfig,
upsertConfig,
getExtensions as apiGetExtensions,
addExtension as apiAddExtension,
removeExtension as apiRemoveExtension,
providers,
} from '../api';
import { getConfiguredExtensions } from '../acp/extensions';
import { pruneDeprecatedBundledExtensions, syncBundledExtensions } from './settings/extensions';
import type {
ConfigResponse,
UpsertConfigQuery,
ConfigKeyQuery,
ExtensionResponse,
ProviderDetails,
ExtensionQuery,
ExtensionConfig,
@@ -48,14 +47,6 @@ interface ConfigProviderProps {
children: React.ReactNode;
}
export class MalformedConfigError extends Error {
constructor() {
super('Check contents of ~/.config/goose/config.yaml');
this.name = 'MalformedConfigError';
Object.setPrototypeOf(this, MalformedConfigError.prototype);
}
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
@@ -114,22 +105,11 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
);
const refreshExtensions = useCallback(async () => {
const result = await apiGetExtensions();
if (result.response.status === 422) {
throw new MalformedConfigError();
}
if (result.error && !result.data) {
console.error(result.error);
return extensionsList;
}
const extensionResponse: ExtensionResponse = result.data!;
setExtensionsList(extensionResponse.extensions);
setExtensionWarnings(extensionResponse.warnings || []);
return extensionResponse.extensions;
}, [extensionsList]);
const { extensions, warnings } = await getConfiguredExtensions();
setExtensionsList(extensions);
setExtensionWarnings(warnings || []);
return extensions;
}, []);
const addExtension = useCallback(
async (name: string, config: ExtensionConfig, enabled: boolean) => {
@@ -212,8 +192,8 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
// Load extensions
try {
const extensionsResponse = await apiGetExtensions();
let extensions = extensionsResponse.data?.extensions || [];
const extensionsResponse = await getConfiguredExtensions();
let extensions = extensionsResponse.extensions;
// Always sync bundled extensions from bundled-extensions.json
// This ensures:
@@ -235,11 +215,11 @@ export const ConfigProvider: React.FC<ConfigProviderProps> = ({ children }) => {
extensions = await pruneDeprecatedBundledExtensions(extensions, removeExtensionForSync);
await syncBundledExtensions(extensions, addExtensionForSync);
// Reload extensions after sync
const refreshedResponse = await apiGetExtensions();
extensions = refreshedResponse.data?.extensions || [];
const refreshedResponse = await getConfiguredExtensions();
extensions = refreshedResponse.extensions;
setExtensionsList(extensions);
setExtensionWarnings(extensionsResponse.data?.warnings || []);
setExtensionWarnings(extensionsResponse.warnings || []);
} catch (error) {
console.error('Failed to load extensions:', error);
}
+21
View File
@@ -723,6 +723,14 @@ 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();
};
let appConfig = {
GOOSE_DEFAULT_PROVIDER: defaultProvider,
GOOSE_DEFAULT_MODEL: defaultModel,
@@ -1624,6 +1632,19 @@ ipcMain.handle('get-goosed-host-port', async (event) => {
return client.getConfig().baseUrl || null;
});
ipcMain.handle('get-acp-url', async (event) => {
const windowId = BrowserWindow.fromWebContents(event.sender)?.id;
if (!windowId) {
return null;
}
const client = goosedClients.get(windowId);
const baseUrl = client?.getConfig().baseUrl;
if (!baseUrl) {
return null;
}
return buildAcpWebSocketUrl(baseUrl, getServerSecret(getSettings()));
});
// Handle menu bar icon visibility
ipcMain.handle('set-menu-bar-icon', async (_event, show: boolean) => {
updateSettings((s) => {
+2
View File
@@ -142,6 +142,7 @@ type ElectronAPI = {
setSetting: <K extends SettingKey>(key: K, value: Settings[K]) => Promise<void>;
getSecretKey: () => Promise<string>;
getGoosedHostPort: () => Promise<string | null>;
getAcpUrl: () => Promise<string | null>;
setWakelock: (enable: boolean) => Promise<boolean>;
getWakelockState: () => Promise<boolean>;
setSpellcheck: (enable: boolean) => Promise<boolean>;
@@ -265,6 +266,7 @@ const electronAPI: ElectronAPI = {
},
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'),
setSpellcheck: (enable: boolean) => ipcRenderer.invoke('set-spellcheck', enable),
@@ -7,6 +7,7 @@ describe('buildConnectSrc', () => {
const result = buildConnectSrc(undefined);
expect(result).toContain("'self'");
expect(result).toContain('http://127.0.0.1:*');
expect(result).toContain('wss://127.0.0.1:*');
});
it('includes external backend origin when enabled', () => {
@@ -17,6 +18,18 @@ describe('buildConnectSrc', () => {
};
const result = buildConnectSrc(config);
expect(result).toContain('http://dev.company.net:12604');
expect(result).toContain('ws://dev.company.net:12604');
});
it('includes external secure WebSocket origin for HTTPS backends', () => {
const config: ExternalGoosedConfig = {
enabled: true,
url: 'https://secure.company.net:12604',
secret: 'test',
};
const result = buildConnectSrc(config);
expect(result).toContain('https://secure.company.net:12604');
expect(result).toContain('wss://secure.company.net:12604');
});
it('does not include external origin when disabled', () => {
+6
View File
@@ -4,8 +4,12 @@ const DEFAULT_CONNECT_SOURCES = [
"'self'",
'http://127.0.0.1:*',
'https://127.0.0.1:*',
'ws://127.0.0.1:*',
'wss://127.0.0.1:*',
'http://localhost:*',
'https://localhost:*',
'ws://localhost:*',
'wss://localhost:*',
'https://api.github.com',
'https://github.com',
'https://objects.githubusercontent.com',
@@ -18,6 +22,8 @@ export function buildConnectSrc(externalGoosed?: ExternalGoosedConfig): string {
try {
const externalUrl = new URL(externalGoosed.url);
sources.push(externalUrl.origin);
externalUrl.protocol = externalUrl.protocol === 'https:' ? 'wss:' : 'ws:';
sources.push(externalUrl.origin);
} catch {
console.warn('Invalid external goosed URL in settings, skipping CSP entry');
}