codex-plugins.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. // Codex 插件目录(ADR-0018):OpenAI 官方 openai-curated 目录中的插件,经 app-server 的 plugin/* 方法安装和管理。
  2. // 插件可能带应用连接器(Gmail、Google Drive 等,使用登录账号的授权),所以只在后台 Playground 的会话中开放,
  3. // 经 API 调用的会话始终关闭(providers/codex/permissions.ts)。
  4. import type { AppSettings, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginDto, CodexPluginInstallResultDto } from '@agent-server/shared'
  5. import { AppError, badRequest, notFound } from '../lib/errors'
  6. import type {
  7. PluginInstallResponse, PluginInstalledResponse, PluginListResponse, PluginMarketplaceEntry, PluginReadResponse, PluginSummary,
  8. } from '../providers/codex/protocol/v2'
  9. import { RpcRequestError } from '../providers/codex/rpc'
  10. import { NO_CODEX_PLUGINS, type CodexPluginGrant } from '../providers/types'
  11. const CACHE_MS = 10 * 60_000
  12. const PAGE_MAX = 100
  13. /** 只管理 OpenAI 官方目录(远程目录 openai-curated-remote) */
  14. const isCurated = (marketplace: string) => marketplace.startsWith('openai-curated')
  15. /** CodexProvider 中本服务用到的部分 */
  16. export interface CodexRpc {
  17. request<T>(settings: AppSettings, method: string, params: unknown, timeoutMs?: number): Promise<T>
  18. }
  19. export class CodexPluginService {
  20. private cache: { at: number; items: CodexPluginDto[] } | null = null
  21. /** 插件 ID → 它带的应用连接器 ID */
  22. private appCache = new Map<string, { at: number; ids: string[] }>()
  23. constructor(
  24. private codex: () => CodexRpc | null,
  25. private settings: () => AppSettings,
  26. private audit: (summary: string) => void,
  27. ) {}
  28. private rpc(): CodexRpc {
  29. const c = this.codex()
  30. if (!c) throw new AppError(503, 'codex_unavailable', 'Codex 引擎未启用')
  31. return c
  32. }
  33. /** Codex 返回的错误(如远程目录请求失败)原样转给后台,而不是笼统的"服务器内部错误" */
  34. private async call<T>(method: string, params: unknown, timeoutMs = 60_000): Promise<T> {
  35. try {
  36. return await this.rpc().request<T>(this.settings(), method, params, timeoutMs)
  37. } catch (err) {
  38. if (err instanceof RpcRequestError) throw new AppError(502, 'codex_error', `Codex:${err.rpc.message}`)
  39. throw err
  40. }
  41. }
  42. async catalog(q: { query?: string; category?: string; installed?: boolean; offset?: number; limit?: number; refresh?: boolean }): Promise<CodexPluginCatalogDto> {
  43. let all: CodexPluginDto[]
  44. try {
  45. all = await this.load(q.refresh ?? false)
  46. } catch (err) {
  47. return { items: [], total: 0, categories: [], fetchedAt: null, error: (err as Error).message }
  48. }
  49. const text = q.query?.trim().toLowerCase()
  50. const filtered = all.filter(p =>
  51. (!text || `${p.name} ${p.displayName} ${p.description} ${p.developer ?? ''}`.toLowerCase().includes(text))
  52. && (!q.category || p.category === q.category)
  53. && (!q.installed || p.installed))
  54. const offset = Math.max(0, q.offset ?? 0)
  55. const limit = Math.min(Math.max(1, q.limit ?? 50), PAGE_MAX)
  56. return {
  57. items: filtered.slice(offset, offset + limit),
  58. total: filtered.length,
  59. categories: [...new Set(all.map(p => p.category).filter((c): c is string => !!c))].sort(),
  60. fetchedAt: this.cache ? new Date(this.cache.at).toISOString() : null,
  61. error: null,
  62. }
  63. }
  64. async detail(name: string): Promise<CodexPluginDetailDto> {
  65. const p = await this.find(name)
  66. const r = await this.call<PluginReadResponse>('plugin/read', { remoteMarketplaceName: p.marketplace, pluginName: p.name })
  67. const d = r.plugin
  68. const i = d.summary.interface
  69. return {
  70. ...toDto(d.summary, d.marketplaceName),
  71. // plugin/read 对远程插件不返回安装状态,以目录为准
  72. installed: p.installed, enabled: p.enabled,
  73. description: d.description ?? i?.shortDescription ?? '',
  74. longDescription: i?.longDescription ?? null,
  75. skills: d.skills.map(s => ({ name: s.name, description: s.shortDescription ?? s.description })),
  76. apps: d.apps.map(a => ({ id: a.id, name: a.name, description: a.description, installUrl: a.installUrl })),
  77. mcpServers: d.mcpServers,
  78. websiteUrl: i?.websiteUrl ?? null, privacyPolicyUrl: i?.privacyPolicyUrl ?? null, termsOfServiceUrl: i?.termsOfServiceUrl ?? null,
  79. }
  80. }
  81. async install(name: string): Promise<CodexPluginInstallResultDto> {
  82. const p = await this.find(name)
  83. if (p.availability !== 'AVAILABLE' || p.installPolicy === 'NOT_AVAILABLE') throw badRequest(`插件 ${p.displayName} 当前不可安装`)
  84. // 远程插件按 remotePluginId 安装:目录里的名称(如 clickup)在远程安装接口上会返回 404 Plugin not found
  85. const read = await this.call<PluginReadResponse>('plugin/read', { remoteMarketplaceName: p.marketplace, pluginName: p.name })
  86. const pluginName = read.plugin.summary.remotePluginId ?? p.name
  87. const r = await this.call<PluginInstallResponse>('plugin/install', { remoteMarketplaceName: p.marketplace, pluginName }, 300_000)
  88. this.cache = null
  89. this.audit(`安装 Codex 插件 ${p.displayName}(${p.id})`)
  90. return { appsNeedingAuth: r.appsNeedingAuth.map(a => ({ id: a.id, name: a.name, installUrl: a.installUrl })) }
  91. }
  92. async uninstall(name: string): Promise<{ ok: true }> {
  93. const p = await this.find(name)
  94. if (!p.installed) throw badRequest(`插件 ${p.displayName} 没有安装`)
  95. await this.call('plugin/uninstall', { pluginId: p.id }, 120_000)
  96. this.cache = null
  97. this.audit(`卸载 Codex 插件 ${p.displayName}(${p.id})`)
  98. return { ok: true }
  99. }
  100. /** Profile 勾选的插件 → 本次对话开放的插件与应用连接器(已卸载的插件忽略) */
  101. async grant(pluginIds: string[]): Promise<CodexPluginGrant> {
  102. if (!pluginIds.length) return NO_CODEX_PLUGINS
  103. const chosen = (await this.load(false)).filter(p => p.installed && pluginIds.includes(p.id))
  104. const appIds = new Set<string>()
  105. for (const p of chosen) for (const id of await this.appsOf(p)) appIds.add(id)
  106. return { pluginIds: chosen.map(p => p.id), appIds: [...appIds] }
  107. }
  108. private async appsOf(p: CodexPluginDto): Promise<string[]> {
  109. const hit = this.appCache.get(p.id)
  110. if (hit && Date.now() - hit.at < CACHE_MS) return hit.ids
  111. const r = await this.call<PluginReadResponse>('plugin/read', { remoteMarketplaceName: p.marketplace, pluginName: p.name })
  112. const ids = r.plugin.apps.map(a => a.id)
  113. this.appCache.set(p.id, { at: Date.now(), ids })
  114. return ids
  115. }
  116. private async find(name: string): Promise<CodexPluginDto> {
  117. if (!/^[\w.-]{1,120}$/.test(name)) throw badRequest('插件名称格式不正确')
  118. const p = (await this.load(false)).find(x => x.name === name)
  119. if (!p) throw notFound(`Codex 插件 ${name}`)
  120. return p
  121. }
  122. /** 目录(7000+ 项)缓存 10 分钟;已安装状态以 plugin/installed 为准 */
  123. private async load(refresh: boolean): Promise<CodexPluginDto[]> {
  124. if (!refresh && this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.items
  125. const [list, installed] = await Promise.all([
  126. this.call<PluginListResponse>('plugin/list', { forceRefetch: refresh }, 120_000),
  127. this.call<PluginInstalledResponse>('plugin/installed', {}),
  128. ])
  129. const installedIds = new Set(curated(installed.marketplaces).filter(x => x.p.installed).map(x => x.p.id))
  130. const items = new Map<string, CodexPluginDto>()
  131. // 已安装但不在目录中的插件也要列出,便于卸载
  132. for (const { p, m } of [...curated(list.marketplaces), ...curated(installed.marketplaces)]) {
  133. if (!items.has(p.id)) items.set(p.id, { ...toDto(p, m), installed: installedIds.has(p.id) || p.installed })
  134. }
  135. const sorted = [...items.values()].sort((a, b) => Number(b.installed) - Number(a.installed))
  136. this.cache = { at: Date.now(), items: sorted }
  137. return sorted
  138. }
  139. }
  140. function curated(mkts: PluginMarketplaceEntry[]): { p: PluginSummary; m: string }[] {
  141. return mkts.filter(m => isCurated(m.name)).flatMap(m => m.plugins.map(p => ({ p, m: m.name })))
  142. }
  143. function toDto(p: PluginSummary, marketplace: string): CodexPluginDto {
  144. const i = p.interface
  145. return {
  146. id: p.id, name: p.name, displayName: i?.displayName ?? p.name, description: i?.shortDescription ?? '',
  147. category: i?.category ?? null, developer: i?.developerName ?? null, marketplace,
  148. installed: p.installed, enabled: p.enabled, installPolicy: p.installPolicy, availability: p.availability,
  149. logoUrl: i?.logoUrl ?? null,
  150. }
  151. }