// Codex 插件目录(ADR-0018):OpenAI 官方 openai-curated 目录中的插件,经 app-server 的 plugin/* 方法安装和管理。 // 插件可能带应用连接器(Gmail、Google Drive 等,使用登录账号的授权),所以只在后台 Playground 的会话中开放, // 经 API 调用的会话始终关闭(providers/codex/permissions.ts)。 import type { AppSettings, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginDto, CodexPluginInstallResultDto } from '@agent-server/shared' import { AppError, badRequest, notFound } from '../lib/errors' import type { PluginInstallResponse, PluginInstalledResponse, PluginListResponse, PluginMarketplaceEntry, PluginReadResponse, PluginSummary, } from '../providers/codex/protocol/v2' import { RpcRequestError } from '../providers/codex/rpc' import { NO_CODEX_PLUGINS, type CodexPluginGrant } from '../providers/types' const CACHE_MS = 10 * 60_000 const PAGE_MAX = 100 /** 只管理 OpenAI 官方目录(远程目录 openai-curated-remote) */ const isCurated = (marketplace: string) => marketplace.startsWith('openai-curated') /** CodexProvider 中本服务用到的部分 */ export interface CodexRpc { request(settings: AppSettings, method: string, params: unknown, timeoutMs?: number): Promise } export class CodexPluginService { private cache: { at: number; items: CodexPluginDto[] } | null = null /** 插件 ID → 它带的应用连接器 ID */ private appCache = new Map() constructor( private codex: () => CodexRpc | null, private settings: () => AppSettings, private audit: (summary: string) => void, ) {} private rpc(): CodexRpc { const c = this.codex() if (!c) throw new AppError(503, 'codex_unavailable', 'Codex 引擎未启用') return c } /** Codex 返回的错误(如远程目录请求失败)原样转给后台,而不是笼统的"服务器内部错误" */ private async call(method: string, params: unknown, timeoutMs = 60_000): Promise { try { return await this.rpc().request(this.settings(), method, params, timeoutMs) } catch (err) { if (err instanceof RpcRequestError) throw new AppError(502, 'codex_error', `Codex:${err.rpc.message}`) throw err } } async catalog(q: { query?: string; category?: string; installed?: boolean; offset?: number; limit?: number; refresh?: boolean }): Promise { let all: CodexPluginDto[] try { all = await this.load(q.refresh ?? false) } catch (err) { return { items: [], total: 0, categories: [], fetchedAt: null, error: (err as Error).message } } const text = q.query?.trim().toLowerCase() const filtered = all.filter(p => (!text || `${p.name} ${p.displayName} ${p.description} ${p.developer ?? ''}`.toLowerCase().includes(text)) && (!q.category || p.category === q.category) && (!q.installed || p.installed)) const offset = Math.max(0, q.offset ?? 0) const limit = Math.min(Math.max(1, q.limit ?? 50), PAGE_MAX) return { items: filtered.slice(offset, offset + limit), total: filtered.length, categories: [...new Set(all.map(p => p.category).filter((c): c is string => !!c))].sort(), fetchedAt: this.cache ? new Date(this.cache.at).toISOString() : null, error: null, } } async detail(name: string): Promise { const p = await this.find(name) const r = await this.call('plugin/read', { remoteMarketplaceName: p.marketplace, pluginName: p.name }) const d = r.plugin const i = d.summary.interface return { ...toDto(d.summary, d.marketplaceName), // plugin/read 对远程插件不返回安装状态,以目录为准 installed: p.installed, enabled: p.enabled, description: d.description ?? i?.shortDescription ?? '', longDescription: i?.longDescription ?? null, skills: d.skills.map(s => ({ name: s.name, description: s.shortDescription ?? s.description })), apps: d.apps.map(a => ({ id: a.id, name: a.name, description: a.description, installUrl: a.installUrl })), mcpServers: d.mcpServers, websiteUrl: i?.websiteUrl ?? null, privacyPolicyUrl: i?.privacyPolicyUrl ?? null, termsOfServiceUrl: i?.termsOfServiceUrl ?? null, } } async install(name: string): Promise { const p = await this.find(name) if (p.availability !== 'AVAILABLE' || p.installPolicy === 'NOT_AVAILABLE') throw badRequest(`插件 ${p.displayName} 当前不可安装`) // 远程插件按 remotePluginId 安装:目录里的名称(如 clickup)在远程安装接口上会返回 404 Plugin not found const read = await this.call('plugin/read', { remoteMarketplaceName: p.marketplace, pluginName: p.name }) const pluginName = read.plugin.summary.remotePluginId ?? p.name const r = await this.call('plugin/install', { remoteMarketplaceName: p.marketplace, pluginName }, 300_000) this.cache = null this.audit(`安装 Codex 插件 ${p.displayName}(${p.id})`) return { appsNeedingAuth: r.appsNeedingAuth.map(a => ({ id: a.id, name: a.name, installUrl: a.installUrl })) } } async uninstall(name: string): Promise<{ ok: true }> { const p = await this.find(name) if (!p.installed) throw badRequest(`插件 ${p.displayName} 没有安装`) await this.call('plugin/uninstall', { pluginId: p.id }, 120_000) this.cache = null this.audit(`卸载 Codex 插件 ${p.displayName}(${p.id})`) return { ok: true } } /** Profile 勾选的插件 → 本次对话开放的插件与应用连接器(已卸载的插件忽略) */ async grant(pluginIds: string[]): Promise { if (!pluginIds.length) return NO_CODEX_PLUGINS const chosen = (await this.load(false)).filter(p => p.installed && pluginIds.includes(p.id)) const appIds = new Set() for (const p of chosen) for (const id of await this.appsOf(p)) appIds.add(id) return { pluginIds: chosen.map(p => p.id), appIds: [...appIds] } } private async appsOf(p: CodexPluginDto): Promise { const hit = this.appCache.get(p.id) if (hit && Date.now() - hit.at < CACHE_MS) return hit.ids const r = await this.call('plugin/read', { remoteMarketplaceName: p.marketplace, pluginName: p.name }) const ids = r.plugin.apps.map(a => a.id) this.appCache.set(p.id, { at: Date.now(), ids }) return ids } private async find(name: string): Promise { if (!/^[\w.-]{1,120}$/.test(name)) throw badRequest('插件名称格式不正确') const p = (await this.load(false)).find(x => x.name === name) if (!p) throw notFound(`Codex 插件 ${name}`) return p } /** 目录(7000+ 项)缓存 10 分钟;已安装状态以 plugin/installed 为准 */ private async load(refresh: boolean): Promise { if (!refresh && this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.items const [list, installed] = await Promise.all([ this.call('plugin/list', { forceRefetch: refresh }, 120_000), this.call('plugin/installed', {}), ]) const installedIds = new Set(curated(installed.marketplaces).filter(x => x.p.installed).map(x => x.p.id)) const items = new Map() // 已安装但不在目录中的插件也要列出,便于卸载 for (const { p, m } of [...curated(list.marketplaces), ...curated(installed.marketplaces)]) { if (!items.has(p.id)) items.set(p.id, { ...toDto(p, m), installed: installedIds.has(p.id) || p.installed }) } const sorted = [...items.values()].sort((a, b) => Number(b.installed) - Number(a.installed)) this.cache = { at: Date.now(), items: sorted } return sorted } } function curated(mkts: PluginMarketplaceEntry[]): { p: PluginSummary; m: string }[] { return mkts.filter(m => isCurated(m.name)).flatMap(m => m.plugins.map(p => ({ p, m: m.name }))) } function toDto(p: PluginSummary, marketplace: string): CodexPluginDto { const i = p.interface return { id: p.id, name: p.name, displayName: i?.displayName ?? p.name, description: i?.shortDescription ?? '', category: i?.category ?? null, developer: i?.developerName ?? null, marketplace, installed: p.installed, enabled: p.enabled, installPolicy: p.installPolicy, availability: p.availability, logoUrl: i?.logoUrl ?? null, } }