codex-plugins.ts 7.3 KB

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