diff --git a/src/main/index.ts b/src/main/index.ts index 2bf3ef13be0..89ba125ae31 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -162,6 +162,8 @@ import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { PluginService } from './plugins/plugin-service' import { PluginKillListService } from './plugins/plugin-kill-list-service' import { getPluginsDataDir } from './plugins/plugin-discovery' +import { PluginMarketplaceService } from './plugins/plugin-marketplace-service' +import { PluginMarketplaceInstaller } from './plugins/plugin-marketplace-installer' import { resolvePluginHostEntryPath } from './plugins/plugin-host-process' import { applyPluginConsent, applyPluginEnablement } from './plugins/plugin-enablement' import { setPluginServiceForRpc } from './runtime/rpc/methods/plugins' @@ -232,6 +234,8 @@ let watcherShutdownDone = false let automations: AutomationService | null = null let pluginService: PluginService | null = null let pluginKillListService: PluginKillListService | null = null +let pluginMarketplaceService: PluginMarketplaceService | null = null +let pluginMarketplaceInstaller: PluginMarketplaceInstaller | null = null let keybindings: KeybindingService | null = null function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { @@ -975,7 +979,10 @@ function openMainWindow(): BrowserWindow { await preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }) } }, - pluginService ?? undefined + pluginService ?? undefined, + pluginMarketplaceService && pluginMarketplaceInstaller + ? { marketplace: pluginMarketplaceService, installer: pluginMarketplaceInstaller } + : undefined ) automations.setWebContents(window.webContents) automations.start() @@ -1937,6 +1944,16 @@ app.whenReady().then(async () => { pluginsDataDir: getPluginsDataDir(app.getPath('userData')) }) await pluginKillListService.initialize() + pluginMarketplaceService = new PluginMarketplaceService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')), + getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null + }) + pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ + marketplace: pluginMarketplaceService, + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null + }) pluginService = new PluginService({ userDataPath: app.getPath('userData'), hostVersion: app.getVersion(), @@ -2366,6 +2383,8 @@ app.on('will-quit', (e) => { // Electron exit first and orphan the hosts. setPluginServiceForRpc(null) pluginKillListService = null + pluginMarketplaceService = null + pluginMarketplaceInstaller = null const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() pluginService = null setUnreadDockBadgeCount(0) diff --git a/src/main/ipc/plugin-marketplaces.test.ts b/src/main/ipc/plugin-marketplaces.test.ts new file mode 100644 index 00000000000..b1badba09c4 --- /dev/null +++ b/src/main/ipc/plugin-marketplaces.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import type { PluginService } from '../plugins/plugin-service' + +type IpcHandler = (event: unknown, args?: unknown) => unknown + +const electronMocks = vi.hoisted(() => ({ handle: vi.fn() })) +vi.mock('electron', () => ({ ipcMain: { handle: electronMocks.handle } })) + +import { + registerPluginMarketplaceHandlers, + type PluginMarketplaceHandlerServices +} from './plugin-marketplaces' + +const SOURCE_ID = 'a'.repeat(32) +const MARKETPLACE_COMMIT = 'b'.repeat(40) +const PLUGIN_COMMIT = 'c'.repeat(40) +const PLUGIN_KEY = 'orca-samples.demo' + +let handlers: Map + +function createServices(): PluginMarketplaceHandlerServices { + return { + marketplace: { + listSources: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]), + addSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }), + removeSource: vi.fn().mockResolvedValue(true), + refreshSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }), + refreshAll: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]), + listPlugins: vi.fn().mockResolvedValue([{ pluginKey: PLUGIN_KEY }]) + } as unknown as PluginMarketplaceService, + installer: { + preview: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }), + install: vi.fn().mockResolvedValue({ ok: true, pluginKey: PLUGIN_KEY }), + previewInstalledUpdate: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }), + rollback: vi.fn().mockResolvedValue({ ok: true, pluginKey: PLUGIN_KEY }) + } as unknown as PluginMarketplaceInstaller + } +} + +function createPluginService(): PluginService { + return { + deactivatePlugin: vi.fn().mockResolvedValue(undefined), + refresh: vi.fn().mockResolvedValue(undefined) + } as unknown as PluginService +} + +async function invoke(channel: string, args?: unknown): Promise { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`missing IPC handler: ${channel}`) + } + return handler({}, args) +} + +beforeEach(() => { + handlers = new Map() + electronMocks.handle.mockReset() + electronMocks.handle.mockImplementation((channel: string, handler: IpcHandler) => { + handlers.set(channel, handler) + }) +}) + +describe('plugin marketplace IPC authority', () => { + it('validates every mutating or plugin-selecting request strictly', async () => { + registerPluginMarketplaceHandlers(createPluginService(), createServices()) + + await expect( + invoke('plugins:addMarketplace', { + kind: 'git', + url: 'https://example.com/marketplace.git', + ref: 'main', + unexpected: true + }) + ).rejects.toThrow() + await expect( + invoke('plugins:removeMarketplace', { sourceId: SOURCE_ID, unexpected: true }) + ).rejects.toThrow() + await expect( + invoke('plugins:refreshMarketplaces', { sourceId: 'not-a-source' }) + ).rejects.toThrow() + await expect( + invoke('plugins:previewMarketplacePlugin', { + marketplaceSourceId: SOURCE_ID, + pluginKey: '__proto__.demo' + }) + ).rejects.toThrow() + await expect( + invoke('plugins:installMarketplacePlugin', { + marketplaceSourceId: SOURCE_ID, + marketplaceCommit: 'moving-ref', + pluginKey: PLUGIN_KEY, + resolvedCommit: PLUGIN_COMMIT + }) + ).rejects.toThrow() + await expect( + invoke('plugins:previewMarketplaceUpdate', { + pluginKey: PLUGIN_KEY, + unexpected: true + }) + ).rejects.toThrow() + await expect( + invoke('plugins:rollbackMarketplacePlugin', { pluginKey: 'bare-id' }) + ).rejects.toThrow() + }) + + it('dispatches source listing, add, removal, and refresh operations', async () => { + const services = createServices() + registerPluginMarketplaceHandlers(createPluginService(), services) + const source = { + kind: 'git' as const, + url: 'https://example.com/marketplace.git', + ref: 'main' + } + + await invoke('plugins:listMarketplaces') + await invoke('plugins:addMarketplace', source) + await invoke('plugins:removeMarketplace', { sourceId: SOURCE_ID }) + await invoke('plugins:refreshMarketplaces', { sourceId: SOURCE_ID }) + await invoke('plugins:refreshMarketplaces', {}) + await invoke('plugins:listMarketplacePlugins') + + expect(services.marketplace.listSources).toHaveBeenCalledTimes(2) + expect(services.marketplace.addSource).toHaveBeenCalledWith(source) + expect(services.marketplace.removeSource).toHaveBeenCalledWith(SOURCE_ID) + expect(services.marketplace.refreshSource).toHaveBeenCalledWith(SOURCE_ID) + expect(services.marketplace.refreshAll).toHaveBeenCalledOnce() + expect(services.marketplace.listPlugins).toHaveBeenCalledOnce() + }) + + it('refreshes discovery only after a successful install', async () => { + const services = createServices() + const pluginService = createPluginService() + registerPluginMarketplaceHandlers(pluginService, services) + const preview = { + marketplaceSourceId: SOURCE_ID, + marketplaceCommit: MARKETPLACE_COMMIT, + pluginKey: PLUGIN_KEY, + resolvedCommit: PLUGIN_COMMIT + } + + await invoke('plugins:installMarketplacePlugin', preview) + expect(services.installer.install).toHaveBeenCalledWith(preview) + expect(pluginService.refresh).toHaveBeenCalledOnce() + + vi.mocked(pluginService.refresh).mockClear() + vi.mocked(services.installer.install).mockResolvedValueOnce({ ok: false, error: 'failed' }) + await invoke('plugins:installMarketplacePlugin', preview) + expect(pluginService.refresh).not.toHaveBeenCalled() + }) + + it('deactivates before rollback and refreshes discovery only on success', async () => { + const services = createServices() + const pluginService = createPluginService() + registerPluginMarketplaceHandlers(pluginService, services) + + await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY }) + + expect(pluginService.deactivatePlugin).toHaveBeenCalledWith(PLUGIN_KEY) + expect(services.installer.rollback).toHaveBeenCalledWith(PLUGIN_KEY) + expect(vi.mocked(pluginService.deactivatePlugin).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(services.installer.rollback).mock.invocationCallOrder[0] + ) + expect(pluginService.refresh).toHaveBeenCalledOnce() + + vi.mocked(pluginService.refresh).mockClear() + vi.mocked(services.installer.rollback).mockResolvedValueOnce({ ok: false, error: 'failed' }) + await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY }) + expect(pluginService.refresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/plugin-marketplaces.ts b/src/main/ipc/plugin-marketplaces.ts new file mode 100644 index 00000000000..e7116d4c56d --- /dev/null +++ b/src/main/ipc/plugin-marketplaces.ts @@ -0,0 +1,74 @@ +import { ipcMain } from 'electron' +import { z } from 'zod' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { pluginMarketplaceGitSourceSchema } from '../../shared/plugins/plugin-marketplace' +import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import type { PluginService } from '../plugins/plugin-service' + +export type PluginMarketplaceHandlerServices = { + marketplace: PluginMarketplaceService + installer: PluginMarketplaceInstaller +} + +const sourceIdSchema = z.string().regex(/^[0-9a-f]{32}$/) +const removeMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema }) +const refreshMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema.optional() }) +const marketplacePluginSchema = z.strictObject({ + marketplaceSourceId: sourceIdSchema, + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key') +}) +const installMarketplacePluginSchema = marketplacePluginSchema.extend({ + marketplaceCommit: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/), + resolvedCommit: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/) +}) +const installedPluginSchema = z.strictObject({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key') +}) + +export function registerPluginMarketplaceHandlers( + pluginService: PluginService, + services: PluginMarketplaceHandlerServices +): void { + ipcMain.handle('plugins:listMarketplaces', () => services.marketplace.listSources()) + ipcMain.handle('plugins:addMarketplace', async (_event, args: unknown) => { + const source = pluginMarketplaceGitSourceSchema.parse(args) + return services.marketplace.addSource(source) + }) + ipcMain.handle('plugins:removeMarketplace', async (_event, args: unknown) => { + const { sourceId } = removeMarketplaceSchema.parse(args) + await services.marketplace.removeSource(sourceId) + return services.marketplace.listSources() + }) + ipcMain.handle('plugins:refreshMarketplaces', async (_event, args: unknown) => { + const { sourceId } = refreshMarketplaceSchema.parse(args ?? {}) + return sourceId + ? [await services.marketplace.refreshSource(sourceId)] + : services.marketplace.refreshAll() + }) + ipcMain.handle('plugins:listMarketplacePlugins', () => services.marketplace.listPlugins()) + ipcMain.handle('plugins:previewMarketplacePlugin', async (_event, args: unknown) => { + const parsed = marketplacePluginSchema.parse(args) + return services.installer.preview(parsed.marketplaceSourceId, parsed.pluginKey) + }) + ipcMain.handle('plugins:installMarketplacePlugin', async (_event, args: unknown) => { + const result = await services.installer.install(installMarketplacePluginSchema.parse(args)) + if (result.ok) { + await pluginService.refresh() + } + return result + }) + ipcMain.handle('plugins:previewMarketplaceUpdate', async (_event, args: unknown) => { + const { pluginKey } = installedPluginSchema.parse(args) + return services.installer.previewInstalledUpdate(pluginKey) + }) + ipcMain.handle('plugins:rollbackMarketplacePlugin', async (_event, args: unknown) => { + const { pluginKey } = installedPluginSchema.parse(args) + await pluginService.deactivatePlugin(pluginKey) + const result = await services.installer.rollback(pluginKey) + if (result.ok) { + await pluginService.refresh() + } + return result + }) +} diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts index 960a4c1011d..fc754ffa77d 100644 --- a/src/main/ipc/plugins.ts +++ b/src/main/ipc/plugins.ts @@ -23,6 +23,10 @@ import { normalizePluginIdList } from '../../shared/plugins/plugin-consent-state import { isAllowedPluginGitUrl } from '../../shared/plugins/plugin-install-lockfile' import type { PluginSkillStoreSnapshot } from '../../shared/plugins/plugin-skill-store' import { authorizePluginSkillMapping } from '../plugins/plugin-skill-mapping-authority' +import { + registerPluginMarketplaceHandlers, + type PluginMarketplaceHandlerServices +} from './plugin-marketplaces' export function parsePluginConsentArgs(args: unknown): z.infer { return pluginConsentRequestSchema.parse(args) @@ -93,7 +97,8 @@ function rendererPanelOwner(webContentsId: number): string { export function registerPluginHandlers( store: Store, pluginService: PluginService, - runtime: OrcaRuntimeService | null + runtime: OrcaRuntimeService | null, + marketplaceServices?: PluginMarketplaceHandlerServices ): void { // The runtime IS the delegate: the structural PluginRuntimeDelegate type // keeps the facade electron-free while main binds the real service. @@ -281,4 +286,7 @@ export function registerPluginHandlers( await pluginService.refresh() return listPluginsForClients(pluginService) }) + if (marketplaceServices) { + registerPluginMarketplaceHandlers(pluginService, marketplaceServices) + } } diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index b08176da464..fb60a4c7493 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -78,6 +78,7 @@ import { scanRuntimeAiVaultSessions } from '../ai-vault/runtime-session-scanner' import type { PluginService } from '../plugins/plugin-service' +import type { PluginMarketplaceHandlerServices } from './plugin-marketplaces' let registered = false @@ -103,7 +104,8 @@ export function registerCoreHandlers( crashReports?: CrashReportStore, keybindings?: KeybindingService, lifecycleOptions: CoreHandlerLifecycleOptions = {}, - pluginService?: PluginService + pluginService?: PluginService, + marketplaceServices?: PluginMarketplaceHandlerServices ): void { // Why: on macOS the app can stay alive after all windows close, then // openMainWindow() is called again on 'activate'. ipcMain.handle() throws @@ -164,7 +166,7 @@ export function registerCoreHandlers( }) } if (pluginService) { - registerPluginHandlers(store, pluginService, runtime) + registerPluginHandlers(store, pluginService, runtime, marketplaceServices) } registerTelemetryHandlers(store) registerOrcaProfileHandlers(store, { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index db13eeb2970..a202e411c55 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -66,6 +66,8 @@ import type { } from '../shared/plugins/plugin-icon-theme-artifact' import type { PluginTerminalThemeRegistration } from '../shared/plugins/plugin-terminal-theme-artifact' import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' +import type { PluginManifest } from '../shared/plugins/plugin-manifest' +import type { PluginMarketplaceGitSource } from '../shared/plugins/plugin-marketplace' import type { LocalhostWorktreeLabelResult, LocalhostWorktreeLabelRoute @@ -997,6 +999,50 @@ export type PluginHostInstallResult = } | { ok: false; error: string } +export type PluginMarketplaceHostSourceState = { + id: string + source: PluginMarketplaceGitSource + addedAt: number + marketplace: { + name: string + owner: string + resolvedCommit: string + fetchedAt: number + } | null + stale: boolean + error?: string +} + +export type PluginMarketplaceHostListing = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceGitSource + description?: string + categories: string[] + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} + +export type PluginMarketplaceHostInstallPreview = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceGitSource + resolvedCommit: string + contentHash: string + consentFingerprint: string + manifest: PluginManifest + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} + export type PreloadApi = { app: AppApi orcaProfiles: { @@ -3234,6 +3280,29 @@ export type PreloadApi = { params?: unknown }) => Promise install: (source: PluginHostInstallSource) => Promise + listMarketplaces: () => Promise + addMarketplace: ( + source: PluginMarketplaceGitSource + ) => Promise + removeMarketplace: (args: { sourceId: string }) => Promise + refreshMarketplaces: (args?: { + sourceId?: string + }) => Promise + listMarketplacePlugins: () => Promise + previewMarketplacePlugin: (args: { + marketplaceSourceId: string + pluginKey: string + }) => Promise + installMarketplacePlugin: ( + preview: Pick< + PluginMarketplaceHostInstallPreview, + 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' + > + ) => Promise + previewMarketplaceUpdate: (args: { + pluginKey: string + }) => Promise + rollbackMarketplacePlugin: (args: { pluginKey: string }) => Promise remove: (args: { pluginKey: string }) => Promise getLogs: (args: { pluginKey: string }) => Promise /** Re-discovers after settings edits (feature flag, dev paths). */ diff --git a/src/preload/index.ts b/src/preload/index.ts index ae76579fb39..954ed524854 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -587,6 +587,19 @@ const api = { }): Promise => ipcRenderer.invoke('plugins:panelAction', args), install: (source: PluginHostInstallSource): Promise => ipcRenderer.invoke('plugins:install', source), + listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), + addMarketplace: (source) => ipcRenderer.invoke('plugins:addMarketplace', source), + removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), + refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), + listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), + previewMarketplacePlugin: (args) => + ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), + installMarketplacePlugin: (preview) => + ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), + previewMarketplaceUpdate: (args) => + ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), + rollbackMarketplacePlugin: (args) => + ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), remove: (args: { pluginKey: string }): Promise => ipcRenderer.invoke('plugins:remove', args), getLogs: (args: { pluginKey: string }): Promise =>