diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 5936e7a..13f78a6 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -1157,8 +1157,8 @@ async function writeCoreGatewayConfig( mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true }); const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); const providerPlugins = withCodexOauthRuntimeDefaults([ - ...(config.providerPlugins ?? []), - ...pluginService.getCoreProviderPlugins() + ...(config.providerPlugins ?? []).filter(providerPluginEnabled), + ...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled) ]); const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins); const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ @@ -1244,6 +1244,10 @@ function writePrivateTextFile(file: string, content: string): void { } } +function providerPluginEnabled(plugin: unknown): boolean { + return !isRecord(plugin) || plugin.enabled !== false; +} + export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] { return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config)); } diff --git a/packages/core/src/plugins/service.ts b/packages/core/src/plugins/service.ts index ae9ebd9..05fc11c 100644 --- a/packages/core/src/plugins/service.ts +++ b/packages/core/src/plugins/service.ts @@ -144,6 +144,18 @@ type LoadedPlugin = { stop?: () => MaybePromise; }; +type PluginServiceStateSnapshot = { + apps: InstalledBrowserApp[]; + coreGatewayConfig: Record; + coreProviderPlugins: unknown[]; + gatewayRoutes: RegisteredGatewayRoute[]; + providerAccountConnectors: Map; + proxyRoutes: RegisteredProxyRoute[]; + resourceOwnerIds: Set; + stopHooks: Array<() => MaybePromise>; + virtualModelProfiles: unknown[]; +}; + const requireFromHere = createRequire(__filename); const builtInMarketplacePluginModules = new Map([ ["claude-design", path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs")], @@ -172,8 +184,14 @@ class GatewayPluginService { if (pluginConfig.enabled === false) { continue; } + const snapshot = this.createStateSnapshot(); this.resourceOwnerIds.add(pluginConfig.id); - await this.loadConfiguredPlugin(pluginConfig); + try { + await this.loadConfiguredPlugin(pluginConfig); + } catch (error) { + await this.rollbackConfiguredPluginLoad(pluginConfig.id, snapshot); + console.warn(`[plugin:${pluginConfig.id}] Disabled after startup failure: ${formatError(error)}`); + } } } @@ -513,6 +531,47 @@ class GatewayPluginService { ): Promise { return backendService.openSqliteStore(pluginId, pluginDataDir, options); } + + private createStateSnapshot(): PluginServiceStateSnapshot { + return { + apps: [...this.apps], + coreGatewayConfig: { ...this.coreGatewayConfig }, + coreProviderPlugins: [...this.coreProviderPlugins], + gatewayRoutes: [...this.gatewayRoutes], + providerAccountConnectors: new Map(this.providerAccountConnectors), + proxyRoutes: [...this.proxyRoutes], + resourceOwnerIds: new Set(this.resourceOwnerIds), + stopHooks: [...this.stopHooks], + virtualModelProfiles: [...this.virtualModelProfiles] + }; + } + + private async rollbackConfiguredPluginLoad(pluginId: string, snapshot: PluginServiceStateSnapshot): Promise { + const newStopHooks = this.stopHooks.slice(snapshot.stopHooks.length).reverse(); + this.apps = snapshot.apps; + this.coreGatewayConfig = snapshot.coreGatewayConfig; + this.coreProviderPlugins = snapshot.coreProviderPlugins; + this.gatewayRoutes = snapshot.gatewayRoutes; + this.providerAccountConnectors = snapshot.providerAccountConnectors; + this.proxyRoutes = snapshot.proxyRoutes; + this.resourceOwnerIds = snapshot.resourceOwnerIds; + this.stopHooks = snapshot.stopHooks; + this.virtualModelProfiles = snapshot.virtualModelProfiles; + + for (const stopHook of newStopHooks) { + try { + await stopHook(); + } catch (error) { + console.warn(`[plugin:${pluginId}] Rollback stop hook failed: ${formatError(error)}`); + } + } + + try { + await backendService.stopOwner(pluginId); + } catch (error) { + console.warn(`[plugin:${pluginId}] Rollback resource cleanup failed: ${formatError(error)}`); + } + } } export const pluginService = new GatewayPluginService(); diff --git a/packages/electron/src/main/browser-automation-mcp.ts b/packages/electron/src/main/browser-automation-mcp.ts index 884905f..10f8558 100644 --- a/packages/electron/src/main/browser-automation-mcp.ts +++ b/packages/electron/src/main/browser-automation-mcp.ts @@ -70,6 +70,13 @@ type BrowserSessionRef = { userId?: string; }; +type SnapshotOptions = { + limit?: number; + maxElements: number; + maxText?: number; + offset?: number; +}; + type AttachedSession = { attachedAt: number; leaseId: string; @@ -140,6 +147,8 @@ const defaultSnapshotMaxElements = 80; const defaultSnapshotMaxText = 3_000; const defaultEventAwaitTimeoutMs = 10_000; const defaultJavascriptTimeoutMs = 8_000; +const maxSnapshotTextLimit = 20_000; +const maxSnapshotTextOffset = 1_000_000_000; const maxMcpRequestBytes = 2 * 1024 * 1024; const maxSubscriptionEvents = 512; const maxToolResultChars = 60_000; @@ -148,7 +157,7 @@ const maxToolResultStringChars = 2_000; const maxToolResultObjectKeys = 120; const maxSnapshotResultElements = 80; const maxAxSnapshotResultNodes = 80; -const maxBrowserResultTextChars = 3_000; +const maxBrowserResultTextChars = maxSnapshotTextLimit; const defaultWaitTimeoutMs = 10_000; const sessionSchema = objectSchema({ @@ -552,8 +561,10 @@ function browserLegacyAliasTools(): McpTool[] { { description: "Capture page text plus interactable element refs for browser automation. Use returned refs for browser_click, browser_type, browser_select, browser_press_key, or browser_scroll.", inputSchema: objectSchema({ + limit: { description: "Maximum page text characters to include. Overrides maxText when both are provided.", maximum: maxSnapshotTextLimit, minimum: 0, type: "number" }, maxElements: { description: "Maximum interactable elements to include.", maximum: 300, minimum: 1, type: "number" }, - maxText: { description: "Maximum page text characters to include.", maximum: 20000, minimum: 0, type: "number" }, + maxText: { description: "Legacy alias for limit.", maximum: maxSnapshotTextLimit, minimum: 0, type: "number" }, + offset: { description: "Starting character offset into the normalized page text.", maximum: maxSnapshotTextOffset, minimum: 0, type: "number" }, tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" } }), name: "browser_snapshot", @@ -1014,8 +1025,9 @@ class BrowserAutomationMcpService implements BrowserAutomationMcpIntegration { builtInBrowserService.getAutomationWebContents(readString(args.tabId)), readString(args.tabId), { + limit: clampInteger(readNumber(args.limit) ?? readNumber(args.maxText) ?? defaultSnapshotMaxText, 0, maxSnapshotTextLimit), maxElements: clampInteger(readNumber(args.maxElements) ?? defaultSnapshotMaxElements, 1, 300), - maxText: clampInteger(readNumber(args.maxText) ?? defaultSnapshotMaxText, 0, 20000) + offset: clampInteger(readNumber(args.offset) ?? 0, 0, maxSnapshotTextOffset) } ); case "browser_click": @@ -2339,7 +2351,7 @@ async function withTimeout(promise: Promise, timeoutMs: number, message: s } } -async function captureSnapshot(webContents: WebContents, options: { maxElements: number; maxText: number }): Promise { +async function captureSnapshot(webContents: WebContents, options: SnapshotOptions): Promise { return await executeJavaScriptWithTimeout( webContents, `(${snapshotScript})(${JSON.stringify(options)})`, @@ -2351,7 +2363,7 @@ async function captureSnapshot(webContents: WebContents, options: { maxElements: async function captureSnapshotWithHandoff( webContents: WebContents, tabId: string | undefined, - options: { maxElements: number; maxText: number } + options: SnapshotOptions ): Promise { const snapshot = await captureSnapshot(webContents, options); const handoff = await maybeRequestPageHandoff(webContents, { @@ -2670,9 +2682,11 @@ async function waitForPageCondition( }; } -const snapshotScript = function(options: { maxElements: number; maxText: number }) { +const snapshotScript = function(options: { limit?: number; maxElements: number; maxText?: number; offset?: number }) { const maxElements = Math.max(1, Math.min(300, Math.floor(options.maxElements || 80))); - const maxText = Math.max(0, Math.min(20000, Math.floor(options.maxText || 3000))); + const textLimitInput = options.limit ?? options.maxText ?? 3000; + const textLimit = Math.max(0, Math.min(20000, Math.floor(textLimitInput))); + const requestedTextOffset = Math.max(0, Math.floor(options.offset || 0)); const refAttribute = "data-ccr-browser-ref"; const elementSelector = [ "a[href]", @@ -2860,6 +2874,11 @@ const snapshotScript = function(options: { maxElements: number; maxText: number }; }); + const pageText = (document.body?.innerText || "").replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); + const textOffset = Math.min(requestedTextOffset, pageText.length); + const textWindow = pageText.slice(textOffset, textOffset + textLimit); + const textNextOffset = textOffset + textWindow.length; + return { activeElement: document.activeElement ? { name: nameOf(document.activeElement).slice(0, 240), @@ -2868,7 +2887,15 @@ const snapshotScript = function(options: { maxElements: number; maxText: number tag: document.activeElement.tagName.toLowerCase() } : undefined, elements, - text: (document.body?.innerText || "").replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim().slice(0, maxText), + text: textWindow, + textHasMore: textNextOffset < pageText.length, + textLength: pageText.length, + textLimit, + textNextOffset, + textOffset, + textRemaining: Math.max(0, pageText.length - textNextOffset), + textReturned: textWindow.length, + textRequestedOffset: requestedTextOffset !== textOffset ? requestedTextOffset : undefined, title: document.title, url: location.href }; @@ -3437,7 +3464,7 @@ function truncateString(value: string, maxChars: number): string { function largeResultGuidance(toolName: string): string { if (toolName === "browser_snapshot") { - return "Result was compacted. Re-run browser_snapshot with lower maxElements/maxText, or use browser_ax_query to fetch targeted elements."; + return "Result was compacted. Re-run browser_snapshot with lower maxElements/limit, page text with offset/limit, or use browser_ax_query to fetch targeted elements."; } if (toolName === "browser_ax_snapshot") { return "Result was compacted. Re-run browser_ax_snapshot with lower limit or scope=outline, or use browser_ax_query with role/name/text filters."; diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index 3f28c94..1d971dd 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -46,7 +46,7 @@ function Dialog({ return ( { diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 5bd3b5e..69db5c7 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -827,12 +827,23 @@ function App() { void checkForAppUpdate(); } - function openUpdateDownloadDialog() { + function openSidebarUpdateDialog() { setUpdateDialogOpen(true); setUpdateActionError(""); if (updateDialogStatus.canDownload || updateDialogStatus.state === "available") { void downloadAppUpdate(); + return; } + if ( + updateDialogStatus.canInstall || + updateDialogStatus.state === "checking" || + updateDialogStatus.state === "downloading" || + updateDialogStatus.state === "downloaded" || + updateDialogStatus.state === "installing" + ) { + return; + } + void checkForAppUpdate(); } async function checkForAppUpdate() { @@ -2816,7 +2827,7 @@ function App() { isMac={isMac} needsTrafficLightSafeArea={needsTrafficLightSafeArea} networkCaptureEnabled={networkCaptureEnabled} - onDownloadUpdate={openUpdateDownloadDialog} + onOpenUpdate={openSidebarUpdateDialog} onOpenSettings={openSettingsDialog} onSelectNavigationItem={selectNavigationItem} onToggleSidebar={() => setSidebarOpen((current) => !current)} diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index 1f7f517..310d111 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -3705,7 +3705,7 @@ function ToolPayloadDialog({ : undefined; return ( - !open && onClose()} open> + !open && onClose()} open>
diff --git a/packages/ui/src/pages/home/components/layout.tsx b/packages/ui/src/pages/home/components/layout.tsx index 663d7ac..468afc2 100644 --- a/packages/ui/src/pages/home/components/layout.tsx +++ b/packages/ui/src/pages/home/components/layout.tsx @@ -1,8 +1,8 @@ import type { ComponentProps } from "react"; import { - AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar, + AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, Check, cn, EndpointTitleBar, AppUpdateStatus, Download, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, - LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, + LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, RefreshCw, reducedMotionTransition, ServiceControlButton, Settings, ViewId, ViewMotionShell, viewUsesInternalScroll } from "../shared/index"; @@ -63,7 +63,7 @@ export function MainLayout({ needsTrafficLightSafeArea, agentAnalysisEnabled, networkCaptureEnabled, - onDownloadUpdate, + onOpenUpdate, onOpenSettings, onSelectNavigationItem, onToggleSidebar, @@ -86,7 +86,7 @@ export function MainLayout({ isMac: boolean; needsTrafficLightSafeArea: boolean; networkCaptureEnabled: boolean; - onDownloadUpdate: () => void; + onOpenUpdate: () => void; onOpenSettings: () => void; onSelectNavigationItem: (id: NavigationId) => void; onToggleSidebar: () => void; @@ -99,15 +99,24 @@ export function MainLayout({ requestLogsEnabled: boolean; visibleNavigation: MainNavigationItem[]; }) { - const windowControlSafeAreaWidth = isMac ? 152 : 88; - const showUpdateDownloadButton = - updateStatus.supported && - (updateStatus.state === "available" || updateStatus.state === "downloading" || updateStatus.state === "downloaded"); - const updateDownloadLabel = updateStatus.state === "downloaded" + const showUpdateButton = updateStatus.supported; + const windowControlSafeAreaWidth = showUpdateButton + ? (isMac ? 188 : 124) + : (isMac ? 152 : 88); + const updateBusy = + updateActionBusy || + updateStatus.state === "checking" || + updateStatus.state === "downloading" || + updateStatus.state === "installing"; + const updateLabel = updateStatus.state === "downloaded" ? copy.text["Update ready to install"] ?? "Update ready to install" : updateStatus.state === "downloading" ? copy.text["Downloading update"] ?? "Downloading update" - : copy.text["Download update"] ?? "Download update"; + : updateStatus.state === "checking" + ? copy.text["Checking for updates"] ?? "Checking for updates" + : updateStatus.state === "available" + ? copy.text["Download update"] ?? "Download update" + : copy.text["Check for updates"] ?? "Check for updates"; return ( <> @@ -132,17 +141,25 @@ export function MainLayout({ onClick={toggleGatewayService} state={gatewayStatus.state} /> - {showUpdateDownloadButton ? ( + {showUpdateButton ? ( ) : null}
diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 5f1753a..3d62fa8 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -2516,7 +2516,7 @@ export function AddProviderDialog({
{checkConfirmOpen ? ( - !open && !checkConfirmBusy && setCheckConfirmOpen(false)}> + !open && !checkConfirmBusy && setCheckConfirmOpen(false)}>
diff --git a/packages/ui/src/pages/home/components/settings.tsx b/packages/ui/src/pages/home/components/settings.tsx index 2b6657f..4ef57a5 100644 --- a/packages/ui/src/pages/home/components/settings.tsx +++ b/packages/ui/src/pages/home/components/settings.tsx @@ -636,7 +636,7 @@ function ToolHubMcpServerDialog({ const stdioMessageModeOptions = translateOptions(mcpStdioMessageModeOptions, t); return ( - !nextOpen && onClose()} open={open}> + !nextOpen && onClose()} open={open}>
@@ -755,7 +755,7 @@ function ToolHubMcpJsonDialog({ const t = useAppText(); return ( - !nextOpen && onClose()} open={open}> + !nextOpen && onClose()} open={open}>
diff --git a/packages/ui/src/pages/home/components/virtual-models.tsx b/packages/ui/src/pages/home/components/virtual-models.tsx index 6e7c2c4..bbfcc35 100644 --- a/packages/ui/src/pages/home/components/virtual-models.tsx +++ b/packages/ui/src/pages/home/components/virtual-models.tsx @@ -605,7 +605,7 @@ function CustomMcpToolDialog({ } return ( - !nextOpen && onClose()} open={open}> + !nextOpen && onClose()} open={open}>
diff --git a/tests/main/plugin-service.test.mjs b/tests/main/plugin-service.test.mjs new file mode 100644 index 0000000..04a4978 --- /dev/null +++ b/tests/main/plugin-service.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts"; +import { pluginService } from "../../packages/core/src/plugins/service.ts"; + +test("plugin service skips failed plugins and rolls back their registrations", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-plugin-service-test-")); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.map(String).join(" ")); + + try { + const brokenPlugin = path.join(dir, "broken-plugin.cjs"); + const goodPlugin = path.join(dir, "good-plugin.cjs"); + writeFileSync(brokenPlugin, ` +module.exports = async function brokenPlugin(context) { + context.registerApp({ id: "broken-context-app", name: "Broken context", url: "http://broken-context.local" }); + context.registerGatewayRoute({ id: "broken-context-route", path: "/broken-context", handler(_request, response) { response.end("broken"); } }); + context.registerCoreGatewayProviderPlugin({ key: "broken-context-provider" }); + context.registerCoreGatewayVirtualModelProfile({ id: "broken-context-vm" }); + context.registerProviderAccountConnector({ id: "broken-account", resolve() { return []; } }); + throw new Error("broken plugin setup failed"); +}; +`); + writeFileSync(goodPlugin, ` +module.exports = { + setup(context) { + context.registerCoreGatewayProviderPlugin({ key: "good-context-provider" }); + context.registerCoreGatewayVirtualModelProfile({ id: "good-context-vm" }); + return { + apps: [{ id: "good-app", name: "Good app", url: "http://good.local" }], + coreGateway: { + config: { agent: { mcpServers: [{ name: "good-mcp", command: "good" }] } }, + providerPlugins: [{ key: "good-provider" }], + virtualModelProfiles: [{ id: "good-vm" }] + }, + gatewayRoutes: [{ id: "good-route", path: "/good", handler(_request, response) { response.end("good"); } }], + providerAccountConnectors: [{ id: "good-account", resolve() { return []; } }], + proxyRoutes: [{ host: "good.local", upstream: "http://127.0.0.1" }] + }; + } +}; +`); + + const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.json") }); + config.plugins = [ + { + apps: [{ id: "broken-config-app", name: "Broken config", url: "http://broken-config.local" }], + coreGateway: { + config: { agent: { mcpServers: [{ name: "broken-config-mcp", command: "broken" }] } }, + providerPlugins: [{ key: "broken-config-provider" }], + virtualModelProfiles: [{ id: "broken-config-vm" }] + }, + id: "broken", + module: brokenPlugin, + proxy: { routes: [{ host: "broken.local", upstream: "http://127.0.0.1" }] } + }, + { + id: "good", + module: goodPlugin + } + ]; + + await pluginService.start(config); + + assert.match(warnings.join("\n"), /plugin:broken.*Disabled after startup failure.*broken plugin setup failed/); + assert.deepEqual(pluginService.getApps().map((app) => app.id), ["good-app"]); + assert.equal(pluginService.matchGatewayRoute("GET", "/broken-context"), undefined); + assert.equal(pluginService.matchGatewayRoute("GET", "/good")?.id, "good-route"); + assert.deepEqual(pluginService.getProxyRouteTargets(), [{ host: "good.local", paths: undefined }]); + assert.deepEqual(pluginService.getCoreProviderPlugins().map((plugin) => plugin.key), ["good-context-provider", "good-provider"]); + assert.deepEqual(pluginService.getVirtualModelProfiles().map((profile) => profile.id), ["good-context-vm", "good-vm"]); + assert.deepEqual(pluginService.getCoreGatewayConfig(), { agent: { mcpServers: [{ name: "good-mcp", command: "good" }] } }); + assert.equal(pluginService.getProviderAccountConnector("broken", "broken-account"), undefined); + assert.equal(typeof pluginService.getProviderAccountConnector("good", "good-account")?.resolve, "function"); + } finally { + console.warn = originalWarn; + await pluginService.stop(); + rmSync(dir, { force: true, recursive: true }); + } +});