feat(plugins): expose marketplace lifecycle

This commit is contained in:
Neil
2026-07-13 03:33:44 -07:00
parent 586a5c92af
commit 52a70b7658
7 changed files with 361 additions and 4 deletions
+20 -1
View File
@@ -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)
+172
View File
@@ -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<string, IpcHandler>
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<unknown> {
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()
})
})
+74
View File
@@ -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
})
}
+9 -1
View File
@@ -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<typeof pluginConsentRequestSchema> {
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)
}
}
+4 -2
View File
@@ -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, {
+69
View File
@@ -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<PluginPanelActionOutcome>
install: (source: PluginHostInstallSource) => Promise<PluginHostInstallResult>
listMarketplaces: () => Promise<PluginMarketplaceHostSourceState[]>
addMarketplace: (
source: PluginMarketplaceGitSource
) => Promise<PluginMarketplaceHostSourceState>
removeMarketplace: (args: { sourceId: string }) => Promise<PluginMarketplaceHostSourceState[]>
refreshMarketplaces: (args?: {
sourceId?: string
}) => Promise<PluginMarketplaceHostSourceState[]>
listMarketplacePlugins: () => Promise<PluginMarketplaceHostListing[]>
previewMarketplacePlugin: (args: {
marketplaceSourceId: string
pluginKey: string
}) => Promise<PluginMarketplaceHostInstallPreview>
installMarketplacePlugin: (
preview: Pick<
PluginMarketplaceHostInstallPreview,
'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit'
>
) => Promise<PluginHostInstallResult>
previewMarketplaceUpdate: (args: {
pluginKey: string
}) => Promise<PluginMarketplaceHostInstallPreview>
rollbackMarketplacePlugin: (args: { pluginKey: string }) => Promise<PluginHostInstallResult>
remove: (args: { pluginKey: string }) => Promise<PluginHostListEntry[]>
getLogs: (args: { pluginKey: string }) => Promise<PluginHostLogLine[]>
/** Re-discovers after settings edits (feature flag, dev paths). */
+13
View File
@@ -587,6 +587,19 @@ const api = {
}): Promise<PluginPanelActionOutcome> => ipcRenderer.invoke('plugins:panelAction', args),
install: (source: PluginHostInstallSource): Promise<PluginHostInstallResult> =>
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<PluginHostListEntry[]> =>
ipcRenderer.invoke('plugins:remove', args),
getLogs: (args: { pluginKey: string }): Promise<PluginHostLogLine[]> =>