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/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/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/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 }); + } +});