mount acp in the goosed server to migrate using acp protocols iteratively (#9097)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user