diff --git a/package.json b/package.json index bac6efda..b6b206a4 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "description": "Convera Workspace", "scripts": { "start": "pnpm --filter app start", + "dev:web": "pnpm --filter app dev:web", "make": "pnpm --filter app make", "lint": "pnpm -r lint", "format": "pnpm -r format", diff --git a/packages/app/package.json b/packages/app/package.json index 5343e771..b6a729de 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,6 +8,7 @@ "scripts": { "start": "NODE_ENV=production-development electron-forge start", "dev:start": "NODE_ENV=development electron-forge start", + "dev:web": "tsx scripts/web-bridge-dev.mts", "package": "electron-forge package", "package:robotjs": "electron-forge package && ./scripts/post-package-robotjs.sh", "package:sign": "ENABLE_CODE_SIGNING=true pnpm run package:robotjs && ./scripts/apple-sign.sh", diff --git a/packages/app/scripts/web-bridge-dev.mts b/packages/app/scripts/web-bridge-dev.mts new file mode 100644 index 00000000..657ebbb3 --- /dev/null +++ b/packages/app/scripts/web-bridge-dev.mts @@ -0,0 +1,83 @@ +/** + * `pnpm dev:web` — the whole browser dev loop in one command. + * + * Starts the renderer dev server and the web bridge together, wires them to + * each other, and prints one ready-to-open link. Nothing to configure. + * + * The bridge runs the real local AI runtime (Claude Code / Codex CLI), so this + * is a working app in a browser tab — not a mock. Electron is not involved, + * which is what makes it usable from a non-TTY shell and from an agent. + * + * ponytail: dev-only. `CONVERA_WEB_BRIDGE=1 pnpm start` is still the path for + * the real Electron window, where MCP and the OS integrations exist. + */ +import { LocalAiRuntime } from "@/electron/ai"; +import { setupLocalAIIPC } from "@/electro-bridge/ipc/local-ai-context"; +import { + createRecordingIpcMain, + createWebBridgeEvent, + WebBridgeSender, +} from "@/electron/web-bridge/dispatch"; +import { + startWebBridge, + type WebBridgeHandle, +} from "@/electron/web-bridge/server"; +import type { IpcMain } from "electron"; +import { createServer } from "vite"; + +const RENDERER_PORT = Number(process.env.CONVERA_RENDERER_PORT ?? 5199); + +process.env.NODE_ENV ??= "production-development"; + +// Renderer first: it picks the port, so the printed link is always correct +// even when 5199 is taken and vite falls forward to 5200+. +const renderer = await createServer({ + configFile: new URL("../vite.renderer.config.mts", import.meta.url).pathname, + server: { port: RENDERER_PORT }, +}); +await renderer.listen(); + +const rendererURL = renderer.resolvedUrls?.local[0]; +if (!rendererURL) throw new Error("Renderer dev server reported no URL"); + +const runtime = new LocalAiRuntime({ + // ponytail: no MCP hub — it needs Electron's `app` paths. Builtin tools + // cover the chat loop; run the Electron app when you need MCP servers. + getToolGroups: async () => [], + executeTool: async () => { + throw new Error("MCP tools require the full Electron app"); + }, +}); + +const recordingIPC = createRecordingIpcMain({ + handle: () => {}, + removeHandler: () => {}, +} as unknown as IpcMain); + +// Deferred lookup: the sender exists before the bridge it emits through. +const emit = { to: undefined as WebBridgeHandle["emit"] | undefined }; +const sender = new WebBridgeSender((channel, payload) => + emit.to?.(channel, payload), +); + +setupLocalAIIPC( + { runtime, getAllowedWebContents: () => [sender as never] }, + recordingIPC, +); + +const bridge = await startWebBridge({ + rendererURL, + invoke: (channel, args) => + recordingIPC.dispatch(channel, args, createWebBridgeEvent(sender)), +}); + +emit.to = bridge.emit; + +const shutdown = () => { + sender.destroy(); + void Promise.allSettled([bridge.close(), renderer.close()]).then(() => + process.exit(0), + ); +}; +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/packages/app/src/electro-bridge/ipc/listeners-register.ts b/packages/app/src/electro-bridge/ipc/listeners-register.ts index 7f848254..853d6c2b 100644 --- a/packages/app/src/electro-bridge/ipc/listeners-register.ts +++ b/packages/app/src/electro-bridge/ipc/listeners-register.ts @@ -2,7 +2,13 @@ import { WindowSizeConfig } from "@/electron/windows/window-size"; import { ThemeMode } from "@/shared/types/electron"; import type { LocalAIRuntimeService } from "@/shared/types/local-ai"; -import { BrowserWindow, ipcMain, IpcRenderer } from "electron"; +import { + BrowserWindow, + ipcMain, + IpcRenderer, + type IpcMain, + type WebContents, +} from "electron"; import { getAppIcon, getPlatform } from "./active-app-context"; import { CHANNELS, IPCServer, methodChannelMap } from "./channels"; import { setupEnvIPC } from "./env-context"; @@ -91,6 +97,10 @@ export interface ListenerOptions { mainWindow?: () => BrowserWindow | null; registerGlobalShortcuts?: () => void; localAIRuntime?: LocalAIRuntimeService; + /** Extra senders (currently the web bridge) allowed to drive local AI. */ + extraLocalAISenders?: () => WebContents[]; + /** Registration target; the web bridge swaps in a recording proxy. */ + ipc?: Pick; } /** @@ -230,16 +240,22 @@ export function setupElectronAPIIPC(options: ListenerOptions = {}) { // Register all IPC listeners for main process export function registerListeners(options: ListenerOptions = {}) { - setupMCPIPC(); + const ipc = options.ipc ?? ipcMain; + setupMCPIPC(ipc); setupLoggerIPC(); setupElectronAPIIPC(options); setupEnvIPC(); - setupLocalAIIPC({ - runtime: options.localAIRuntime, - getAllowedWebContents: () => { - const window = options.mainWindow?.(); - return window && !window.isDestroyed() ? window.webContents : null; + setupLocalAIIPC( + { + runtime: options.localAIRuntime, + getAllowedWebContents: () => { + const window = options.mainWindow?.(); + const renderer = + window && !window.isDestroyed() ? [window.webContents] : []; + return [...renderer, ...(options.extraLocalAISenders?.() ?? [])]; + }, }, - }); + ipc, + ); console.log("All IPC listeners registered successfully"); } diff --git a/packages/app/src/electro-bridge/ipc/local-ai-api.ts b/packages/app/src/electro-bridge/ipc/local-ai-api.ts new file mode 100644 index 00000000..3bfe21bb --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/local-ai-api.ts @@ -0,0 +1,54 @@ +import type { ILocalAIAPI, LocalAIStreamEvent } from "@/shared/types/local-ai"; + +export const LOCAL_AI_CHANNELS = { + LIST_PROVIDERS: "local-ai:list-providers", + GET_PROVIDER_STATUS: "local-ai:get-provider-status", + START_CHAT: "local-ai:start-chat", + ABORT: "local-ai:abort", + RESPOND_INTERACTION: "local-ai:respond-interaction", + EVENT: "local-ai:event", +} as const; + +/** + * Electron-free so both the preload (real `ipcRenderer`) and the browser + * (HTTP shim) can build the same API. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type IPCListener = (...args: any[]) => void; + +export interface LocalAIRendererIPC { + invoke: (channel: string, ...args: unknown[]) => Promise; + on: (channel: string, listener: IPCListener) => void; + removeListener: (channel: string, listener: IPCListener) => void; +} + +export function createLocalAIAPI(rendererIPC: LocalAIRendererIPC): ILocalAIAPI { + const invoke = rendererIPC.invoke.bind(rendererIPC) as ( + channel: string, + ...args: unknown[] + ) => Promise; + + return { + listProviders: () => invoke(LOCAL_AI_CHANNELS.LIST_PROVIDERS), + getProviderStatus: (providerId) => + invoke(LOCAL_AI_CHANNELS.GET_PROVIDER_STATUS, providerId), + startChat: (request) => invoke(LOCAL_AI_CHANNELS.START_CHAT, request), + abort: (requestId) => invoke(LOCAL_AI_CHANNELS.ABORT, requestId), + respondToInteraction: (requestId, interactionId, response) => + invoke( + LOCAL_AI_CHANNELS.RESPOND_INTERACTION, + requestId, + interactionId, + response, + ), + onEvent: (requestId, callback) => { + const handler = (_event: unknown, event: LocalAIStreamEvent) => { + if (event.requestId === requestId) callback(event); + }; + rendererIPC.on(LOCAL_AI_CHANNELS.EVENT, handler); + return () => { + rendererIPC.removeListener(LOCAL_AI_CHANNELS.EVENT, handler); + }; + }, + }; +} diff --git a/packages/app/src/electro-bridge/ipc/local-ai-context.ts b/packages/app/src/electro-bridge/ipc/local-ai-context.ts index 77735324..83b2534a 100644 --- a/packages/app/src/electro-bridge/ipc/local-ai-context.ts +++ b/packages/app/src/electro-bridge/ipc/local-ai-context.ts @@ -1,5 +1,4 @@ import type { - ILocalAIAPI, LocalAIChatRequest, LocalAIInteractionResponse, LocalAIProviderStatus, @@ -9,28 +8,22 @@ import type { LocalAIStartResult, LocalAIStreamEvent, } from "@/shared/types/local-ai"; +import { createLocalAIAPI, LOCAL_AI_CHANNELS } from "./local-ai-api"; import { contextBridge, ipcMain, ipcRenderer, type IpcMain, type IpcMainInvokeEvent, - type IpcRenderer, type WebContents, } from "electron"; -export const LOCAL_AI_CHANNELS = { - LIST_PROVIDERS: "local-ai:list-providers", - GET_PROVIDER_STATUS: "local-ai:get-provider-status", - START_CHAT: "local-ai:start-chat", - ABORT: "local-ai:abort", - RESPOND_INTERACTION: "local-ai:respond-interaction", - EVENT: "local-ai:event", -} as const; +export { createLocalAIAPI, LOCAL_AI_CHANNELS } from "./local-ai-api"; export interface LocalAIIPCOptions { runtime?: LocalAIRuntimeService; - getAllowedWebContents: () => WebContents | null; + /** The renderer, plus any web bridge sender standing in for a browser tab. */ + getAllowedWebContents: () => WebContents | WebContents[] | null; } interface ActiveRequest { @@ -98,13 +91,18 @@ export function serializeLocalAIError( export function isAllowedLocalAISender( event: IpcMainInvokeEvent, - allowedWebContents: WebContents | null, + allowedWebContents: WebContents | WebContents[] | null, ): boolean { + if (!allowedWebContents || event.sender.isDestroyed()) return false; + + const allowed = Array.isArray(allowedWebContents) + ? allowedWebContents + : [allowedWebContents]; + if ( - !allowedWebContents || - allowedWebContents.isDestroyed() || - event.sender.isDestroyed() || - event.sender !== allowedWebContents + !allowed.some( + (candidate) => candidate === event.sender && !candidate.isDestroyed(), + ) ) { return false; } @@ -539,36 +537,6 @@ export function setupLocalAIIPC( }; } -export function createLocalAIAPI( - rendererIPC: Pick, -): ILocalAIAPI { - return { - listProviders: () => rendererIPC.invoke(LOCAL_AI_CHANNELS.LIST_PROVIDERS), - getProviderStatus: (providerId) => - rendererIPC.invoke(LOCAL_AI_CHANNELS.GET_PROVIDER_STATUS, providerId), - startChat: (request) => - rendererIPC.invoke(LOCAL_AI_CHANNELS.START_CHAT, request), - abort: (requestId) => - rendererIPC.invoke(LOCAL_AI_CHANNELS.ABORT, requestId), - respondToInteraction: (requestId, interactionId, response) => - rendererIPC.invoke( - LOCAL_AI_CHANNELS.RESPOND_INTERACTION, - requestId, - interactionId, - response, - ), - onEvent: (requestId, callback) => { - const handler = (_event: unknown, event: LocalAIStreamEvent) => { - if (event.requestId === requestId) callback(event); - }; - rendererIPC.on(LOCAL_AI_CHANNELS.EVENT, handler); - return () => { - rendererIPC.removeListener(LOCAL_AI_CHANNELS.EVENT, handler); - }; - }, - }; -} - export function exposeLocalAIContext() { contextBridge.exposeInMainWorld("localAI", createLocalAIAPI(ipcRenderer)); } diff --git a/packages/app/src/electro-bridge/ipc/mcp-api.ts b/packages/app/src/electro-bridge/ipc/mcp-api.ts new file mode 100644 index 00000000..be4565fc --- /dev/null +++ b/packages/app/src/electro-bridge/ipc/mcp-api.ts @@ -0,0 +1,37 @@ +import type { IMcpAPI, MCPServerConfig } from "@/shared/types/mcp"; + +/** + * Electron-free so both the preload (real `ipcRenderer`) and the browser + * (HTTP shim) can build the same API from one channel list. + */ +export interface McpRendererIPC { + invoke: (channel: string, ...args: unknown[]) => Promise; +} + +export function createMcpAPI(rendererIPC: McpRendererIPC): IMcpAPI { + const invoke = rendererIPC.invoke.bind(rendererIPC) as ( + channel: string, + ...args: unknown[] + ) => Promise; + + return { + getServers: () => invoke("mcp:getServers"), + getAllTools: () => invoke("mcp:getAllTools"), + startServer: (serverId: string) => invoke("mcp:startServer", serverId), + stopServer: (serverId: string) => invoke("mcp:stopServer", serverId), + getConfigurations: () => invoke("mcp:getConfigurations"), + addServer: (serverId: string, config: MCPServerConfig) => + invoke("mcp:addServer", serverId, config), + updateServer: (serverId: string, config: MCPServerConfig) => + invoke("mcp:updateServer", serverId, config), + removeServer: (serverId: string) => invoke("mcp:removeServer", serverId), + callTool: ( + serverId: string, + toolName: string, + args: Record, + ) => invoke("mcp:callTool", serverId, toolName, args), + mcpToolCall: (toolName: string, args: Record) => + invoke("mcp:mcpToolCall", toolName, args), + getAllNonInputParamTool: () => invoke("mcp:getAllNonInputParamTool"), + }; +} diff --git a/packages/app/src/electro-bridge/ipc/mcp-context.ts b/packages/app/src/electro-bridge/ipc/mcp-context.ts index 1dab6a70..e8bcb90e 100644 --- a/packages/app/src/electro-bridge/ipc/mcp-context.ts +++ b/packages/app/src/electro-bridge/ipc/mcp-context.ts @@ -1,13 +1,16 @@ +import { contextBridge, ipcMain, ipcRenderer, type IpcMain } from "electron"; import type { MCPServerConfig } from "@/shared/types/mcp"; -import { contextBridge, ipcMain, ipcRenderer } from "electron"; +import { createMcpAPI } from "./mcp-api"; import { getAllTools, getMCPHub } from "../../electron/mcp"; /** * Setup MCP IPC handlers in main process */ -export function setupMCPIPC() { +export function setupMCPIPC( + mainIPC: Pick = ipcMain, +) { // Get all server statuses including builtin tools - ipcMain.handle("mcp:getServers", async () => { + mainIPC.handle("mcp:getServers", async () => { try { const hub = getMCPHub(); if (!hub) { @@ -22,7 +25,7 @@ export function setupMCPIPC() { }); // Get all tools in simplified format for chat functionality - ipcMain.handle("mcp:getAllTools", async () => { + mainIPC.handle("mcp:getAllTools", async () => { try { const tools = getAllTools(); return { success: true, data: tools }; @@ -32,7 +35,7 @@ export function setupMCPIPC() { }); // Start specific server - ipcMain.handle("mcp:startServer", async (_, serverId: string) => { + mainIPC.handle("mcp:startServer", async (_, serverId: string) => { try { const hub = getMCPHub(); if (!hub) { @@ -47,7 +50,7 @@ export function setupMCPIPC() { }); // Stop specific server - ipcMain.handle("mcp:stopServer", async (_, serverId: string) => { + mainIPC.handle("mcp:stopServer", async (_, serverId: string) => { try { const hub = getMCPHub(); if (!hub) { @@ -62,7 +65,7 @@ export function setupMCPIPC() { }); // Get server configurations - ipcMain.handle("mcp:getConfigurations", async () => { + mainIPC.handle("mcp:getConfigurations", async () => { try { const hub = getMCPHub(); if (!hub) { @@ -77,7 +80,7 @@ export function setupMCPIPC() { }); // Update server configuration - ipcMain.handle( + mainIPC.handle( "mcp:updateServer", async (_, serverId: string, config: MCPServerConfig) => { try { @@ -95,7 +98,7 @@ export function setupMCPIPC() { ); // Add new server (manual configuration) - ipcMain.handle( + mainIPC.handle( "mcp:addServer", async (_, serverId: string, config: MCPServerConfig) => { try { @@ -113,7 +116,7 @@ export function setupMCPIPC() { ); // Remove server - ipcMain.handle("mcp:removeServer", async (_, serverId: string) => { + mainIPC.handle("mcp:removeServer", async (_, serverId: string) => { try { const hub = getMCPHub(); if (!hub) { @@ -128,7 +131,7 @@ export function setupMCPIPC() { }); // Call tool on server - ipcMain.handle( + mainIPC.handle( "mcp:callTool", async ( _, @@ -151,7 +154,7 @@ export function setupMCPIPC() { ); // Simplified tool call - finds first server with the tool - ipcMain.handle( + mainIPC.handle( "mcp:mcpToolCall", async (_, toolName: string, args: Record) => { try { @@ -169,7 +172,7 @@ export function setupMCPIPC() { ); // Get all tools that don't require input parameters - ipcMain.handle("mcp:getAllNonInputParamTool", async () => { + mainIPC.handle("mcp:getAllNonInputParamTool", async () => { try { const hub = getMCPHub(); if (!hub) { @@ -190,28 +193,5 @@ export function setupMCPIPC() { * Expose MCP context to renderer process */ export function exposeMCPContext() { - contextBridge.exposeInMainWorld("mcpAPI", { - getServers: () => ipcRenderer.invoke("mcp:getServers"), - getAllTools: () => ipcRenderer.invoke("mcp:getAllTools"), - startServer: (serverId: string) => - ipcRenderer.invoke("mcp:startServer", serverId), - stopServer: (serverId: string) => - ipcRenderer.invoke("mcp:stopServer", serverId), - getConfigurations: () => ipcRenderer.invoke("mcp:getConfigurations"), - addServer: (serverId: string, config: MCPServerConfig) => - ipcRenderer.invoke("mcp:addServer", serverId, config), - updateServer: (serverId: string, config: MCPServerConfig) => - ipcRenderer.invoke("mcp:updateServer", serverId, config), - removeServer: (serverId: string) => - ipcRenderer.invoke("mcp:removeServer", serverId), - callTool: ( - serverId: string, - toolName: string, - args: Record, - ) => ipcRenderer.invoke("mcp:callTool", serverId, toolName, args), - mcpToolCall: (toolName: string, args: Record) => - ipcRenderer.invoke("mcp:mcpToolCall", toolName, args), - getAllNonInputParamTool: () => - ipcRenderer.invoke("mcp:getAllNonInputParamTool"), - }); + contextBridge.exposeInMainWorld("mcpAPI", createMcpAPI(ipcRenderer)); } diff --git a/packages/app/src/electron/main.ts b/packages/app/src/electron/main.ts index e623b600..f8bbdec5 100644 --- a/packages/app/src/electron/main.ts +++ b/packages/app/src/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, globalShortcut } from "electron"; +import { app, BrowserWindow, globalShortcut, ipcMain } from "electron"; import { getLogger, initializeLogger } from "@/electron/logger"; import { @@ -16,6 +16,16 @@ import { ListenerOptions, registerListeners, } from "@/electro-bridge/ipc/listeners-register"; +import { + createWebBridgeEvent, + createRecordingIpcMain, + WebBridgeSender, +} from "@/electron/web-bridge/dispatch"; +import { + isWebBridgeEnabled, + startWebBridge, + type WebBridgeHandle, +} from "@/electron/web-bridge/server"; import { createSystemTray, destroySystemTray } from "./tray"; import { createMainWindow, @@ -26,6 +36,8 @@ import { // Initialize logger for main process const logger = getLogger("main-process"); +let webBridge: WebBridgeHandle | undefined; +let webBridgeSender: WebBridgeSender | undefined; const localAIRuntime = new LocalAiRuntime({ getToolGroups: async () => { await initializeMCPHub(); @@ -119,16 +131,36 @@ app.whenReady().then(async () => { mainWindow.focus(); } + const recordingIPC = isWebBridgeEnabled() + ? createRecordingIpcMain(ipcMain) + : undefined; + // Set up options for the new unified listener system const listenerOptions: ListenerOptions = { mainWindow: () => getMainWindow(), registerGlobalShortcuts, localAIRuntime, + ipc: recordingIPC, + extraLocalAISenders: () => + webBridgeSender ? [webBridgeSender as never] : [], }; logger.debug("Registering IPC listeners"); registerListeners(listenerOptions); + if (recordingIPC) { + // The sender is created first; its emit closure reads `webBridge` lazily. + const sender = new WebBridgeSender((channel, payload) => + webBridge?.emit(channel, payload), + ); + webBridgeSender = sender; + webBridge = await startWebBridge({ + rendererURL: MAIN_WINDOW_VITE_DEV_SERVER_URL || undefined, + invoke: (channel, args) => + recordingIPC.dispatch(channel, args, createWebBridgeEvent(sender)), + }); + } + app.on("activate", () => { const mainWin = getMainWindow(); if (mainWin) { @@ -156,6 +188,10 @@ app.on("will-quit", () => { hub.cleanup(); console.log("MCP Hub cleaned up"); } + webBridgeSender?.destroy(); + void webBridge?.close().catch((error) => { + logger.error("Web bridge cleanup failed:", error); + }); void localAIRuntime.dispose().catch((error) => { logger.error("Local AI runtime cleanup failed:", error); }); diff --git a/packages/app/src/electron/web-bridge/dispatch.ts b/packages/app/src/electron/web-bridge/dispatch.ts new file mode 100644 index 00000000..f4b92a40 --- /dev/null +++ b/packages/app/src/electron/web-bridge/dispatch.ts @@ -0,0 +1,84 @@ +import type { IpcMain, IpcMainInvokeEvent, WebContents } from "electron"; +import { EventEmitter } from "node:events"; + +type InvokeHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown; + +export interface RecordingIpcMain + extends Pick { + /** Call a handler registered through this proxy, bypassing Electron IPC. */ + dispatch: ( + channel: string, + args: unknown[], + event: IpcMainInvokeEvent, + ) => Promise; +} + +/** + * Wraps `ipcMain` so every registered handler stays reachable by channel name. + * Electron exposes no way to call its own handlers, and the web bridge needs + * exactly that — the alternative is duplicating every handler for HTTP. + */ +export function createRecordingIpcMain(target: IpcMain): RecordingIpcMain { + const handlers = new Map(); + + return { + handle: (channel: string, handler: InvokeHandler) => { + handlers.set(channel, handler); + target.handle(channel, handler); + }, + removeHandler: (channel: string) => { + handlers.delete(channel); + target.removeHandler(channel); + }, + dispatch: async (channel, args, event) => { + const handler = handlers.get(channel); + if (!handler) { + throw new Error(`No IPC handler registered for "${channel}"`); + } + return handler(event, ...args); + }, + }; +} + +/** + * A WebContents stand-in for browser clients. `local-ai-context` identifies + * senders by object reference and pushes stream events through `send`, so the + * browser gets the same lifecycle (per-request tracking, abort on disconnect) + * as a real renderer without any special-casing there. + */ +export class WebBridgeSender extends EventEmitter { + readonly id = -1; + readonly mainFrame = {}; + + private destroyed = false; + + constructor( + private readonly emit_: (channel: string, payload: unknown) => void, + ) { + super(); + } + + isDestroyed() { + return this.destroyed; + } + + send(channel: string, payload: unknown) { + if (this.destroyed) return; + this.emit_(channel, payload); + } + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.emit("destroyed"); + } +} + +export function createWebBridgeEvent( + sender: WebBridgeSender, +): IpcMainInvokeEvent { + return { + sender: sender as unknown as WebContents, + senderFrame: sender.mainFrame, + } as unknown as IpcMainInvokeEvent; +} diff --git a/packages/app/src/electron/web-bridge/integration.test.ts b/packages/app/src/electron/web-bridge/integration.test.ts new file mode 100644 index 00000000..f9c4b499 --- /dev/null +++ b/packages/app/src/electron/web-bridge/integration.test.ts @@ -0,0 +1,179 @@ +import type { + LocalAIRuntimeService, + LocalAIStreamEvent, +} from "@/shared/types/local-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/electron/logger", () => ({ + getLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock("electron", () => ({ + contextBridge: { exposeInMainWorld: vi.fn() }, + ipcMain: { handle: vi.fn(), removeHandler: vi.fn() }, + ipcRenderer: { invoke: vi.fn(), on: vi.fn(), removeListener: vi.fn() }, +})); + +import { createLocalAIAPI } from "@/electro-bridge/ipc/local-ai-api"; +import { setupLocalAIIPC } from "@/electro-bridge/ipc/local-ai-context"; +import { ipcMain } from "electron"; +import WebSocket from "ws"; +import { + createRecordingIpcMain, + createWebBridgeEvent, + WebBridgeSender, +} from "./dispatch"; +import { startWebBridge, type WebBridgeHandle } from "./server"; +import { + WEB_BRIDGE_INVOKE_PATH, + WEB_BRIDGE_TOKEN_HEADER, + type WebBridgeEventFrame, +} from "@/shared/web-bridge/protocol"; + +let bridge: WebBridgeHandle | undefined; + +afterEach(async () => { + await bridge?.close(); + bridge = undefined; +}); + +/** + * The browser-side shim, minus the DOM: same shape `createLocalAIAPI` expects, + * talking to the bridge over real HTTP and a real WebSocket. + */ +function createBrowserIPC(handle: WebBridgeHandle, socket: WebSocket) { + const listeners = new Map void>>(); + + socket.on("message", (raw) => { + const frame = JSON.parse(String(raw)) as WebBridgeEventFrame; + listeners + .get(frame.channel) + ?.forEach((listener) => listener(null, frame.payload)); + }); + + return { + invoke: async (channel: string, ...args: unknown[]) => { + const response = await fetch(`${handle.url}${WEB_BRIDGE_INVOKE_PATH}`, { + method: "POST", + headers: { + "content-type": "application/json", + [WEB_BRIDGE_TOKEN_HEADER]: handle.token, + }, + body: JSON.stringify({ channel, args }), + }); + const body = await response.json(); + if (!body.ok) throw new Error(body.error); + return body.data; + }, + on: (channel: string, listener: (...args: unknown[]) => void) => { + const set = listeners.get(channel) ?? new Set(); + set.add(listener); + listeners.set(channel, set); + }, + removeListener: ( + channel: string, + listener: (...args: unknown[]) => void, + ) => { + listeners.get(channel)?.delete(listener); + }, + }; +} + +describe("web bridge end to end", () => { + it("drives a local AI chat from a browser client and streams events back", async () => { + const runtime: LocalAIRuntimeService = { + listProviders: async () => [ + { + id: "claude-code", + name: "Claude Code", + kind: "claude-code", + availability: "available", + }, + ], + getProviderStatus: async () => ({ + id: "claude-code", + name: "Claude Code", + kind: "claude-code", + availability: "available", + }), + startChat: async (request, emit) => { + emit({ + type: "ui-message", + requestId: request.requestId, + chunk: { + type: "text-delta", + id: "t1", + delta: "hello from the runtime", + }, + }); + }, + abort: async () => true, + respondToInteraction: async () => false, + }; + + const recordingIPC = createRecordingIpcMain(ipcMain); + const sender = new WebBridgeSender((channel, payload) => + bridge?.emit(channel, payload), + ); + + setupLocalAIIPC( + { runtime, getAllowedWebContents: () => [sender as never] }, + recordingIPC, + ); + + bridge = await startWebBridge({ + port: 45921, + invoke: (channel, args) => + recordingIPC.dispatch(channel, args, createWebBridgeEvent(sender)), + }); + + const socket = new WebSocket( + `ws://127.0.0.1:45921/ipc/events?token=${bridge.token}`, + ); + await new Promise((resolve) => socket.on("open", resolve)); + + const api = createLocalAIAPI(createBrowserIPC(bridge, socket)); + + // The browser reaches the real ipcMain handler through the bridge. + const providers = await api.listProviders(); + expect(providers).toEqual({ + success: true, + data: [ + { + id: "claude-code", + name: "Claude Code", + kind: "claude-code", + availability: "available", + }, + ], + }); + + const received: LocalAIStreamEvent[] = []; + const unsubscribe = api.onEvent("req-1", (event) => received.push(event)); + + const started = await api.startChat({ + requestId: "req-1", + providerId: "claude-code", + messages: [{ role: "user", content: "hi" }], + }); + expect(started).toEqual({ success: true, accepted: true }); + + // Stream events travel main process -> WebSocket -> browser callback. + await vi.waitFor(() => + expect(received.some((event) => event.type === "finish")).toBe(true), + ); + expect(received[0]).toMatchObject({ + type: "ui-message", + requestId: "req-1", + chunk: { type: "text-delta", delta: "hello from the runtime" }, + }); + + unsubscribe(); + socket.close(); + }); +}); diff --git a/packages/app/src/electron/web-bridge/server.test.ts b/packages/app/src/electron/web-bridge/server.test.ts new file mode 100644 index 00000000..9dcaec60 --- /dev/null +++ b/packages/app/src/electron/web-bridge/server.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/electron/logger", () => ({ + getLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { WEB_BRIDGE_TOKEN_HEADER } from "@/shared/web-bridge/protocol"; +import { startWebBridge, type WebBridgeHandle } from "./server"; + +let bridge: WebBridgeHandle | undefined; + +afterEach(async () => { + await bridge?.close(); + bridge = undefined; +}); + +async function invoke( + handle: WebBridgeHandle, + channel: string, + args: unknown[] = [], + overrides: { token?: string; origin?: string } = {}, +) { + const headers: Record = { + "content-type": "application/json", + [WEB_BRIDGE_TOKEN_HEADER]: overrides.token ?? handle.token, + }; + if (overrides.origin) headers.origin = overrides.origin; + + return fetch(`${handle.url}/ipc/invoke`, { + method: "POST", + headers, + body: JSON.stringify({ channel, args }), + }); +} + +describe("web bridge server", () => { + it("dispatches allowlisted channels and blocks everything else", async () => { + const invokeSpy = vi.fn(async (channel: string, args: unknown[]) => ({ + channel, + args, + })); + bridge = await startWebBridge({ invoke: invokeSpy, port: 45911 }); + + const allowed = await invoke(bridge, "local-ai:list-providers", []); + expect(allowed.status).toBe(200); + expect(await allowed.json()).toEqual({ + ok: true, + data: { channel: "local-ai:list-providers", args: [] }, + }); + + // Window control is deliberately not reachable from a browser tab. + const blocked = await invoke(bridge, "window:close", []); + expect(blocked.status).toBe(403); + expect(invokeSpy).toHaveBeenCalledTimes(1); + }); + + it("rejects a wrong token and a non-loopback origin", async () => { + const invokeSpy = vi.fn(async () => "ok"); + bridge = await startWebBridge({ invoke: invokeSpy, port: 45912 }); + + const badToken = await invoke(bridge, "local-ai:list-providers", [], { + token: "wrong-token", + }); + expect(badToken.status).toBe(403); + + const badOrigin = await invoke(bridge, "local-ai:list-providers", [], { + origin: "https://evil.example.com", + }); + expect(badOrigin.status).toBe(403); + + const goodOrigin = await invoke(bridge, "local-ai:list-providers", [], { + origin: "http://127.0.0.1:5199", + }); + expect(goodOrigin.status).toBe(200); + + expect(invokeSpy).toHaveBeenCalledTimes(1); + }); + + it("reports handler failures as ok:false instead of throwing", async () => { + bridge = await startWebBridge({ + invoke: async () => { + throw new Error("runtime exploded"); + }, + port: 45913, + }); + + const response = await invoke(bridge, "mcp:getServers", []); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: false, + error: "runtime exploded", + }); + }); +}); diff --git a/packages/app/src/electron/web-bridge/server.ts b/packages/app/src/electron/web-bridge/server.ts new file mode 100644 index 00000000..886c912e --- /dev/null +++ b/packages/app/src/electron/web-bridge/server.ts @@ -0,0 +1,298 @@ +import { getLogger } from "@/electron/logger"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { WebSocketServer, type WebSocket } from "ws"; +import { + WEB_BRIDGE_DEFAULT_PORT, + WEB_BRIDGE_EVENT_PATH, + WEB_BRIDGE_INVOKE_PATH, + type WebBridgeEventFrame, + type WebBridgeInvokeRequest, + type WebBridgeInvokeResponse, +} from "@/shared/web-bridge/protocol"; + +const logger = getLogger("web-bridge"); + +const MAX_BODY_BYTES = 8 * 1024 * 1024; + +/** + * Channels the browser is allowed to reach. Window control, clipboard and + * global shortcuts are deliberately absent — they are meaningless outside + * Electron, and every extra channel here is extra attack surface. + */ +const ALLOWED_INVOKE_CHANNELS = new Set([ + "local-ai:list-providers", + "local-ai:get-provider-status", + "local-ai:start-chat", + "local-ai:abort", + "local-ai:respond-interaction", + "mcp:getServers", + "mcp:getAllTools", + "mcp:startServer", + "mcp:stopServer", + "mcp:getConfigurations", + "mcp:addServer", + "mcp:updateServer", + "mcp:removeServer", + "mcp:callTool", + "mcp:mcpToolCall", + "mcp:getAllNonInputParamTool", +]); + +/** Channels the main process may push to the browser. */ +const ALLOWED_EVENT_CHANNELS = new Set(["local-ai:event"]); + +export interface WebBridgeOptions { + /** Dispatch an invoke to the already-registered ipcMain handler. */ + invoke: (channel: string, args: unknown[]) => Promise; + port?: number; + host?: string; + /** Renderer dev server URL, used to print a ready-to-open browser link. */ + rendererURL?: string; +} + +export interface WebBridgeHandle { + url: string; + token: string; + /** Renderer URL with bridge + token already attached. */ + browserURL: string; + /** Push an event frame to every connected browser client. */ + emit: (channel: string, payload: unknown) => void; + close: () => Promise; +} + +export function isWebBridgeEnabled(): boolean { + return process.env.CONVERA_WEB_BRIDGE === "1"; +} + +function tokensMatch(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** + * Reject cross-origin callers. Combined with binding to loopback this is what + * stops a random page on the machine from driving the local CLI agents: + * a browser will not let a page on evil.com set Origin, and a null/absent + * Origin (curl) still needs the token. + */ +function isAllowedOrigin(origin: string | undefined): boolean { + if (!origin) return true; // non-browser caller; token check still applies + try { + // ponytail: any loopback port is fine — the vite dev port is not fixed. + const { hostname } = new URL(origin); + return hostname === "127.0.0.1" || hostname === "localhost"; + } catch { + return false; + } +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + request.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error("Request body too large")); + request.destroy(); + return; + } + chunks.push(chunk); + }); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }); +} + +function sendJSON(response: ServerResponse, status: number, body: unknown) { + const payload = JSON.stringify(body); + response.writeHead(status, { + "content-type": "application/json", + "access-control-allow-origin": "*", + "access-control-allow-headers": "content-type, x-convera-bridge-token", + "access-control-allow-methods": "POST, OPTIONS", + }); + response.end(payload); +} + +function parseInvokeRequest(raw: string): WebBridgeInvokeRequest | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const candidate = parsed as Record; + if (typeof candidate.channel !== "string") return null; + if (candidate.args !== undefined && !Array.isArray(candidate.args)) { + return null; + } + return { + channel: candidate.channel, + args: (candidate.args as unknown[]) ?? [], + }; +} + +export async function startWebBridge( + options: WebBridgeOptions, +): Promise { + const host = options.host ?? "127.0.0.1"; + const port = + options.port ?? + Number(process.env.CONVERA_WEB_BRIDGE_PORT ?? WEB_BRIDGE_DEFAULT_PORT); + const token = randomBytes(24).toString("hex"); + const clients = new Set(); + + const authorize = ( + tokenHeader: string | string[] | undefined, + origin: string | undefined, + ): boolean => { + const provided = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader; + if (!provided || !tokensMatch(provided, token)) return false; + return isAllowedOrigin(origin); + }; + + const httpServer = createServer((request, response) => { + if (request.method === "OPTIONS") { + sendJSON(response, 204, {}); + return; + } + if (request.method !== "POST" || request.url !== WEB_BRIDGE_INVOKE_PATH) { + sendJSON(response, 404, { error: "Not found" }); + return; + } + if ( + !authorize( + request.headers["x-convera-bridge-token"], + request.headers.origin, + ) + ) { + sendJSON(response, 403, { error: "Forbidden" }); + return; + } + + void (async () => { + let invokeRequest: WebBridgeInvokeRequest | null = null; + try { + invokeRequest = parseInvokeRequest(await readBody(request)); + } catch (error) { + sendJSON(response, 413, { error: String(error) }); + return; + } + + if (!invokeRequest) { + sendJSON(response, 400, { error: "Malformed invoke request" }); + return; + } + if (!ALLOWED_INVOKE_CHANNELS.has(invokeRequest.channel)) { + sendJSON(response, 403, { + error: `Channel "${invokeRequest.channel}" is not exposed over the web bridge`, + }); + return; + } + + try { + const data = await options.invoke( + invokeRequest.channel, + invokeRequest.args, + ); + sendJSON(response, 200, { + ok: true, + data, + } satisfies WebBridgeInvokeResponse); + } catch (error) { + sendJSON(response, 200, { + ok: false, + error: error instanceof Error ? error.message : String(error), + } satisfies WebBridgeInvokeResponse); + } + })(); + }); + + const wsServer = new WebSocketServer({ noServer: true }); + + httpServer.on("upgrade", (request, socket, head) => { + const url = new URL(request.url ?? "/", `http://${host}:${port}`); + if ( + url.pathname !== WEB_BRIDGE_EVENT_PATH || + !authorize( + url.searchParams.get("token") ?? undefined, + request.headers.origin, + ) + ) { + socket.destroy(); + return; + } + wsServer.handleUpgrade(request, socket, head, (ws) => { + clients.add(ws); + ws.on("close", () => clients.delete(ws)); + ws.on("error", () => clients.delete(ws)); + }); + }); + + // Fall forward when the port is taken, the way vite does — a second dev + // instance should just work rather than crash on EADDRINUSE. + const boundPort = await new Promise((resolve, reject) => { + let candidate = port; + + const onError = (error: NodeJS.ErrnoException) => { + if (error.code === "EADDRINUSE" && candidate < port + 20) { + candidate += 1; + httpServer.listen(candidate, host); + return; + } + reject(error); + }; + + httpServer.on("error", onError); + httpServer.listen(candidate, host, () => { + httpServer.removeListener("error", onError); + resolve(candidate); + }); + }); + + const url = `http://${host}:${boundPort}`; + logger.info(`Web bridge listening on ${url}`); + + // Print the whole link, not just the token: without the query string the + // page loads but installs nothing, and the UI can only say "runtime is not + // available". The token never leaves this machine. + const rendererURL = options.rendererURL ?? "http://localhost:5199/"; + const browserURL = new URL(rendererURL); + browserURL.searchParams.set("bridge", url); + browserURL.searchParams.set("token", token); + console.log( + `\n Convera is ready in the browser:\n\n ${browserURL}\n\n (bridge on ${url} — the token in that link is what authorizes this tab)\n`, + ); + + return { + url, + token, + browserURL: browserURL.toString(), + emit: (channel, payload) => { + if (!ALLOWED_EVENT_CHANNELS.has(channel)) return; + const frame: WebBridgeEventFrame = { channel, payload }; + const message = JSON.stringify(frame); + clients.forEach((client) => { + try { + client.send(message); + } catch { + clients.delete(client); + } + }); + }, + close: async () => { + clients.forEach((client) => client.close()); + clients.clear(); + wsServer.close(); + await new Promise((resolve) => httpServer.close(() => resolve())); + }, + }; +} diff --git a/packages/app/src/renderer.ts b/packages/app/src/renderer.ts index 5c469d36..08c3ba85 100644 --- a/packages/app/src/renderer.ts +++ b/packages/app/src/renderer.ts @@ -1 +1,4 @@ +// Must come first: populates window.localAI / window.mcpAPI when running in a +// plain browser, before any app module reads them. No-op under Electron. +import "@/renderer/libs/web-bridge/install"; import "@/renderer/app"; diff --git a/packages/app/src/renderer/components/chat/input/context-button.tsx b/packages/app/src/renderer/components/chat/input/context-button.tsx index f3a80ffc..ff9f1840 100644 --- a/packages/app/src/renderer/components/chat/input/context-button.tsx +++ b/packages/app/src/renderer/components/chat/input/context-button.tsx @@ -36,7 +36,7 @@ const FileBadge = ({ return (
{isImage && preview ? ( @@ -49,7 +49,7 @@ const FileBadge = ({ {file.name}

Welcome to Convera

-

+

Ask me anything about coding, tech, or problems you're facing with your projects.

diff --git a/packages/app/src/renderer/components/chat/message/tool-call.tsx b/packages/app/src/renderer/components/chat/message/tool-call.tsx index 44183e92..c495da8c 100644 --- a/packages/app/src/renderer/components/chat/message/tool-call.tsx +++ b/packages/app/src/renderer/components/chat/message/tool-call.tsx @@ -65,7 +65,7 @@ const ToolCall = ({ toolPart }: ToolCallProps) => {
{!isCompleted ? ( -
+
Waiting for results...
diff --git a/packages/app/src/renderer/components/chat/popover/agent-popover.tsx b/packages/app/src/renderer/components/chat/popover/agent-popover.tsx index 87421256..bafd82ac 100644 --- a/packages/app/src/renderer/components/chat/popover/agent-popover.tsx +++ b/packages/app/src/renderer/components/chat/popover/agent-popover.tsx @@ -655,7 +655,7 @@ export default function AgentPopover() { >
-
+
{tool.id === "websearch" ? ( ) : tool.id === "thinking" ? ( ) : ( - + {tool.name.charAt(0)} )} @@ -747,9 +747,7 @@ export default function AgentPopover() {