Преглед изворни кода

feat: Codex 插件目录(openai-curated),只在 Playground 中使用

- 后台"技能与插件"新增"Codex · OpenAI 插件"页:浏览/搜索/分类筛选 openai-curated 目录(7700+ 项,缓存 10 分钟),
  查看详情(技能、应用连接器、MCP),安装、卸载;安装后提示需要在 ChatGPT 中连接的应用
- 后台 Playground 创建的 Codex 会话(source = admin)开放 plugins / remote_plugin / apps;插件安装建议、子代理等仍关闭;
  经 API Key 创建的会话始终关闭
- 远程插件的技能不进入 Profile 技能列表:开放插件的会话全部保留,其他会话全部禁用
- 应用连接器的调用确认(mcpServer/elicitation)在 Playground 中转为审批卡片,需要填表或跳转网址的请求拒绝
- Playground 工具栏显示"插件已开放"
- ADR-0018

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang пре 1 недеља
родитељ
комит
fb22960278

+ 9 - 1
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, AppSettings, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
+  ApiKeyDto, ApiKeyProfiles, AppSettings, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
   ProviderStatusDto, ResolvedAccessDto, SkillInfo,
 } from '@agent-server/shared'
 
@@ -40,6 +40,14 @@ export const admin = {
     install: (name: string) => req<CodexSkillCatalogDto>('POST', `/codex-skills/${encodeURIComponent(name)}`),
     uninstall: (name: string) => req<CodexSkillCatalogDto>('DELETE', `/codex-skills/${encodeURIComponent(name)}`),
   },
+  codexPlugins: {
+    catalog: (q: { q?: string; category?: string; installed?: boolean; offset?: number; refresh?: boolean }) => req<CodexPluginCatalogDto>('GET', `/codex-plugins?${new URLSearchParams({
+      q: q.q ?? '', category: q.category ?? '', installed: q.installed ? '1' : '', offset: String(q.offset ?? 0), refresh: q.refresh ? '1' : '',
+    })}`),
+    detail: (name: string) => req<CodexPluginDetailDto>('GET', `/codex-plugins/${encodeURIComponent(name)}`),
+    install: (name: string) => req<CodexPluginInstallResultDto>('POST', `/codex-plugins/${encodeURIComponent(name)}/install`),
+    uninstall: (name: string) => req<{ ok: true }>('POST', `/codex-plugins/${encodeURIComponent(name)}/uninstall`),
+  },
   communitySkills: {
     list: () => req<CommunitySkillDto[]>('GET', '/community-skills'),
     search: (q: string) => req<CommunitySearchResultDto[]>('GET', `/community-skills/search?q=${encodeURIComponent(q)}`),

+ 4 - 0
apps/admin/src/views/PlaygroundView.vue

@@ -218,6 +218,10 @@ onBeforeUnmount(() => {
                     <option v-for="(label, k) in PERMISSION_LABELS" :key="k" :value="k">{{ label }}(仅本会话)</option>
                   </select>
                 </label>
+                <!-- Codex 插件与应用连接器只在 Playground 自己创建的会话中开放(ADR-0018) -->
+                <span v-if="detail.provider === 'codex' && detail.source === 'admin'" class="pill" title="本会话可使用已安装的 Codex 插件与应用连接器(使用登录账号的授权);经 API 创建的会话不会开放">
+                  <span class="codicon codicon-extensions text-[12px]" />插件已开放
+                </span>
               </template>
             </template>
           </AgentChat>

+ 182 - 5
apps/admin/src/views/PluginsView.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
-import { computed, onMounted, ref } from 'vue'
+import { computed, onMounted, ref, watch } from 'vue'
 import type {
-  AvailablePluginDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, PluginAction, PluginStateDto,
+  AvailablePluginDto, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, PluginAction, PluginStateDto,
 } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
@@ -10,7 +10,7 @@ import { formatTime, toast } from '../lib/ui'
 const OFFICIAL_SOURCE = 'anthropics/skills'
 
 // 每个引擎接入各自厂商的官方技能仓库;第三方技能两个引擎共用
-const tab = ref<'claude' | 'codex' | 'community'>('claude')
+const tab = ref<'claude' | 'codex' | 'codex-plugins' | 'community'>('claude')
 const codex = ref<CodexSkillCatalogDto | null>(null)
 const codexBusy = ref<string | null>(null)
 const codexSearch = ref('')
@@ -44,6 +44,77 @@ async function codexAct(key: string, fn: () => Promise<CodexSkillCatalogDto>, do
   }
 }
 
+// Codex 插件(openai-curated):只在 Playground 中使用
+const plugins = ref<CodexPluginCatalogDto | null>(null)
+const pluginQuery = ref('')
+const pluginCategory = ref('')
+const pluginInstalledOnly = ref(false)
+const pluginBusy = ref<string | null>(null)
+const pluginDetail = ref<CodexPluginDetailDto | null>(null)
+const pluginAuth = ref<{ name: string; apps: { id: string; name: string; installUrl: string | null }[] } | null>(null)
+
+async function loadPlugins(opts: { more?: boolean; refresh?: boolean } = {}) {
+  pluginBusy.value = opts.refresh ? 'refresh' : 'load'
+  try {
+    const r = await admin.codexPlugins.catalog({
+      q: pluginQuery.value.trim(), category: pluginCategory.value, installed: pluginInstalledOnly.value,
+      offset: opts.more ? plugins.value?.items.length ?? 0 : 0, refresh: opts.refresh,
+    })
+    plugins.value = opts.more && plugins.value ? { ...r, items: [...plugins.value.items, ...r.items] } : r
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    pluginBusy.value = null
+  }
+}
+let pluginTimer: ReturnType<typeof setTimeout> | undefined
+watch([pluginQuery, pluginCategory, pluginInstalledOnly], () => {
+  clearTimeout(pluginTimer)
+  pluginTimer = setTimeout(() => void loadPlugins(), 300)
+})
+watch(tab, t => { if (t === 'codex-plugins' && !plugins.value) void loadPlugins() })
+
+async function openPlugin(p: CodexPluginDto) {
+  pluginBusy.value = p.name
+  try {
+    pluginDetail.value = await admin.codexPlugins.detail(p.name)
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    pluginBusy.value = null
+  }
+}
+
+async function installPlugin(p: CodexPluginDto) {
+  pluginBusy.value = p.name
+  try {
+    const r = await admin.codexPlugins.install(p.name)
+    toast(`${p.displayName} 已安装,可在 Playground 的 Codex 会话中使用`, 'success')
+    if (r.appsNeedingAuth.length) pluginAuth.value = { name: p.displayName, apps: r.appsNeedingAuth }
+    pluginDetail.value = null
+    await loadPlugins()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    pluginBusy.value = null
+  }
+}
+
+async function uninstallPlugin(p: CodexPluginDto) {
+  if (!window.confirm(`卸载 Codex 插件 ${p.displayName}?`)) return
+  pluginBusy.value = p.name
+  try {
+    await admin.codexPlugins.uninstall(p.name)
+    toast(`${p.displayName} 已卸载`, 'success')
+    pluginDetail.value = null
+    await loadPlugins()
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    pluginBusy.value = null
+  }
+}
+
 // 第三方技能:skills.sh 搜索 → 预览 SKILL.md → 按提交安装
 const community = ref<CommunitySkillDto[]>([])
 const communityQuery = ref('')
@@ -179,14 +250,71 @@ onMounted(() => {
     </PageHeader>
     <div class="flex-none flex gap-1 px-6 border-b border-b-solid border-border">
       <button
-        v-for="t in ([['claude', 'Claude · Anthropic 官方技能'], ['codex', 'Codex · OpenAI 官方技能'], ['community', '第三方技能 · 两个引擎']] as const)" :key="t[0]" type="button"
+        v-for="t in ([['claude', 'Claude · Anthropic 官方技能'], ['codex', 'Codex · OpenAI 官方技能'], ['codex-plugins', 'Codex · OpenAI 插件'], ['community', '第三方技能 · 两个引擎']] as const)" :key="t[0]" type="button"
         class="h-9 px-3 border-none bg-transparent cursor-pointer text-fg-muted hover:text-fg border-b-solid border-b-2"
         :class="tab === t[0] ? '!text-fg border-b-accent' : 'border-b-transparent'" @click="tab = t[0]"
       >{{ t[1] }}</button>
     </div>
     <div class="flex-1 overflow-y-auto">
+      <!-- Codex 插件:openai-curated 目录,只在 Playground 中使用 -->
+      <div v-if="tab === 'codex-plugins'" class="max-w-240 p-6 flex flex-col gap-5">
+        <section class="card p-4 flex gap-3 border-[color-mix(in_srgb,var(--app-warning)_45%,transparent)]">
+          <span class="codicon codicon-extensions text-warning text-[18px] mt-0.5" />
+          <div class="flex flex-col gap-1.5 text-[12.5px]">
+            <div class="font-600 text-[13px]">来源:OpenAI 官方插件目录(openai-curated),<b>只在 Playground 中使用</b></div>
+            <div>插件由 Codex 自己安装和管理,可能包含技能和应用连接器(如 Gmail、Google Drive)。连接器<b>使用 Codex 登录账号的授权</b>访问对应数据,首次使用需要在 ChatGPT 中完成连接。</div>
+            <div>只有在后台 Playground 中创建的 Codex 会话会开放已安装的插件,调用连接器前会弹出审批;<b>经 API Key 创建的会话始终关闭插件与连接器</b>。对话中不能安装新插件。</div>
+          </div>
+        </section>
+        <div class="flex items-center gap-3 flex-wrap">
+          <div class="relative flex-1 min-w-60 max-w-100">
+            <span class="codicon codicon-search absolute left-2 top-1/2 -translate-y-1/2 text-fg-muted text-[13px]" />
+            <input v-model="pluginQuery" class="field pl-7" placeholder="搜索插件名称、描述或开发者" aria-label="搜索插件">
+          </div>
+          <select v-model="pluginCategory" class="field w-44" aria-label="分类">
+            <option value="">全部分类</option>
+            <option v-for="c in plugins?.categories" :key="c" :value="c">{{ c }}</option>
+          </select>
+          <label class="flex items-center gap-1.5 cursor-pointer text-[12px]"><input v-model="pluginInstalledOnly" type="checkbox">只看已安装</label>
+          <span class="flex-1" />
+          <button type="button" class="btn-secondary" :disabled="!!pluginBusy" @click="loadPlugins({ refresh: true })">
+            <span class="codicon" :class="pluginBusy === 'refresh' ? 'codicon-loading codicon-modifier-spin' : 'codicon-sync'" />更新目录
+          </button>
+        </div>
+        <div v-if="plugins?.error" class="text-error text-[12px]">读取插件目录失败:{{ plugins.error }}(需要 Codex 已登录)</div>
+        <div v-if="plugins && !plugins.error" class="hint">共 {{ plugins.total }} 个<template v-if="plugins.fetchedAt"> · 目录获取于 {{ formatTime(plugins.fetchedAt) }}</template></div>
+        <div class="card divide-y divide-x-0 divide-solid divide-border">
+          <div v-for="p in plugins?.items" :key="p.id" class="flex items-start gap-3 px-4 py-2.5">
+            <img v-if="p.logoUrl" :src="p.logoUrl" alt="" class="w-7 h-7 rounded-md flex-none mt-0.5 bg-code" loading="lazy" referrerpolicy="no-referrer">
+            <span v-else class="w-7 h-7 rounded-md flex-none mt-0.5 bg-code flex items-center justify-center"><span class="codicon codicon-extensions text-fg-muted" /></span>
+            <div class="flex-1 min-w-0">
+              <div class="flex items-center gap-2 flex-wrap">
+                <span class="font-500">{{ p.displayName }}</span>
+                <span class="hint">{{ [p.developer, p.category].filter(Boolean).join(' · ') }}</span>
+                <span v-if="p.installed" class="badge bg-code text-accent">已安装</span>
+                <span v-if="p.installPolicy === 'INSTALLED_BY_DEFAULT'" class="badge bg-code">默认安装</span>
+                <span v-if="p.availability !== 'AVAILABLE' || p.installPolicy === 'NOT_AVAILABLE'" class="badge bg-code text-warning">不可用</span>
+              </div>
+              <div class="hint line-clamp-2">{{ p.description }}</div>
+            </div>
+            <button type="button" class="btn-ghost flex-none" :disabled="!!pluginBusy" @click="openPlugin(p)">
+              <span class="codicon" :class="pluginBusy === p.name ? 'codicon-loading codicon-modifier-spin' : 'codicon-info'" />详情
+            </button>
+            <button v-if="p.installed" type="button" class="icon-btn flex-none" title="卸载" :disabled="!!pluginBusy" @click="uninstallPlugin(p)"><span class="codicon codicon-trash" /></button>
+            <button v-else type="button" class="btn-secondary flex-none" :disabled="!!pluginBusy || p.availability !== 'AVAILABLE' || p.installPolicy === 'NOT_AVAILABLE'" @click="installPlugin(p)">
+              <span class="codicon codicon-cloud-download" />安装
+            </button>
+          </div>
+          <div v-if="plugins && !plugins.error && !plugins.items.length" class="px-4 py-3 hint">没有匹配的插件</div>
+          <div v-if="!plugins" class="px-4 py-3 hint">正在读取插件目录…</div>
+        </div>
+        <button v-if="plugins && plugins.items.length < plugins.total" type="button" class="btn-secondary self-center" :disabled="!!pluginBusy" @click="loadPlugins({ more: true })">
+          加载更多({{ plugins.items.length }} / {{ plugins.total }})
+        </button>
+      </div>
+
       <!-- 第三方技能:skills.sh 目录 → GitHub -->
-      <div v-if="tab === 'community'" class="max-w-240 p-6 flex flex-col gap-6">
+      <div v-else-if="tab === 'community'" class="max-w-240 p-6 flex flex-col gap-6">
         <section class="card p-4 flex gap-3 border-[color-mix(in_srgb,var(--app-warning)_45%,transparent)]">
           <span class="codicon codicon-shield text-warning text-[18px] mt-0.5" />
           <div class="flex flex-col gap-1.5 text-[12.5px]">
@@ -431,6 +559,55 @@ onMounted(() => {
       </div>
     </div>
 
+    <!-- Codex 插件详情 -->
+    <div v-if="pluginDetail" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="pluginDetail = null">
+      <div class="w-[min(640px,92vw)] max-h-[85vh] overflow-y-auto card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
+        <div class="flex items-center gap-2">
+          <img v-if="pluginDetail.logoUrl" :src="pluginDetail.logoUrl" alt="" class="w-8 h-8 rounded-md" referrerpolicy="no-referrer">
+          <span class="font-600 text-[14px]">{{ pluginDetail.displayName }}</span>
+          <span class="hint">{{ [pluginDetail.developer, pluginDetail.category].filter(Boolean).join(' · ') }}</span>
+          <span class="flex-1" />
+          <button type="button" class="icon-btn" title="关闭" @click="pluginDetail = null"><span class="codicon codicon-close" /></button>
+        </div>
+        <div class="whitespace-pre-wrap">{{ pluginDetail.longDescription || pluginDetail.description }}</div>
+        <div v-if="pluginDetail.apps.length" class="flex flex-col gap-1">
+          <div class="font-600">应用连接器(使用 Codex 登录账号的授权)</div>
+          <div v-for="a in pluginDetail.apps" :key="a.id" class="flex items-center gap-2">
+            <span>{{ a.name }}</span>
+            <a v-if="a.installUrl" :href="a.installUrl" target="_blank" rel="noopener noreferrer" class="text-link">在 ChatGPT 中连接</a>
+          </div>
+        </div>
+        <div v-if="pluginDetail.skills.length" class="flex flex-col gap-1">
+          <div class="font-600">技能({{ pluginDetail.skills.length }})</div>
+          <div v-for="s in pluginDetail.skills" :key="s.name"><span class="font-mono text-[12px]">{{ s.name }}</span><span class="hint"> — {{ s.description }}</span></div>
+        </div>
+        <div v-if="pluginDetail.mcpServers.length"><span class="font-600">MCP 服务:</span>{{ pluginDetail.mcpServers.join('、') }}</div>
+        <div class="flex gap-3 hint">
+          <a v-if="pluginDetail.websiteUrl" :href="pluginDetail.websiteUrl" target="_blank" rel="noopener noreferrer" class="text-link">网站</a>
+          <a v-if="pluginDetail.privacyPolicyUrl" :href="pluginDetail.privacyPolicyUrl" target="_blank" rel="noopener noreferrer" class="text-link">隐私政策</a>
+          <a v-if="pluginDetail.termsOfServiceUrl" :href="pluginDetail.termsOfServiceUrl" target="_blank" rel="noopener noreferrer" class="text-link">服务条款</a>
+        </div>
+        <div class="flex justify-end gap-2 mt-1">
+          <button type="button" class="btn-secondary" @click="pluginDetail = null">关闭</button>
+          <button v-if="pluginDetail.installed" type="button" class="btn-secondary" :disabled="!!pluginBusy" @click="uninstallPlugin(pluginDetail)">卸载</button>
+          <button v-else type="button" class="btn-primary" :disabled="!!pluginBusy || pluginDetail.availability !== 'AVAILABLE' || pluginDetail.installPolicy === 'NOT_AVAILABLE'" @click="installPlugin(pluginDetail)">安装</button>
+        </div>
+      </div>
+    </div>
+
+    <!-- 安装后需要授权的应用 -->
+    <div v-if="pluginAuth" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="pluginAuth = null">
+      <div class="w-[480px] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">
+        <div class="font-600 text-[14px]">{{ pluginAuth.name }} 需要连接应用</div>
+        <div>以下应用需要在 ChatGPT 中用 Codex 登录的账号完成连接,之后才能在 Playground 中使用:</div>
+        <div v-for="a in pluginAuth.apps" :key="a.id" class="flex items-center gap-2">
+          <span>{{ a.name }}</span>
+          <a v-if="a.installUrl" :href="a.installUrl" target="_blank" rel="noopener noreferrer" class="text-link">去连接</a>
+        </div>
+        <div class="flex justify-end"><button type="button" class="btn-primary" @click="pluginAuth = null">知道了</button></div>
+      </div>
+    </div>
+
     <!-- 第三方技能预览:安装前必须查看 -->
     <div v-if="preview" class="fixed inset-0 z-40 flex items-center justify-center bg-[var(--app-overlay)]" @click.self="preview = null">
       <div class="w-[min(860px,92vw)] max-h-[88vh] card bg-input-alt p-5 flex flex-col gap-3 shadow-xl text-[12.5px]">

+ 1 - 1
apps/server/scripts/try-claude-adapter.ts

@@ -16,7 +16,7 @@ const ac = new AbortController()
 const res = await new ClaudeProvider().run({
   sessionId: 'ses_test', runId: 'run_test', model: 'haiku', permissionMode: mode as PermissionMode,
   providerSessionId: null, workspaceDir: ws, systemPromptAppend: '', allowedTools: [], disallowedTools: [], skills: 'all',
-  plugins: [], gateway: { url: 'http://127.0.0.1:8788/mcp', token: 'none' }, input: { text: prompt, images: [], attachments: [] }, access, settings, signal: ac.signal,
+  plugins: [], codexPlugins: false, gateway: { url: 'http://127.0.0.1:8788/mcp', token: 'none' }, input: { text: prompt, images: [], attachments: [] }, access, settings, signal: ac.signal,
   emit: (type, payload) => {
     if (type === 'message.delta' || type === 'reasoning.delta') return
     console.log(`[event] ${type}`, JSON.stringify(payload).slice(0, Number(process.env.EVENT_MAX ?? 220)))

+ 6 - 1
apps/server/src/app.ts

@@ -14,6 +14,7 @@ import { PluginService } from './services/plugins'
 import { ClaudeLoginManager } from './services/claude-login'
 import { CodexSkillService } from './services/codex-skills'
 import { CommunitySkillService } from './services/community-skills'
+import { CodexPluginService } from './services/codex-plugins'
 import { RunTokenRegistry } from './mcp-gateway/tokens'
 import { RunManager } from './services/runs'
 import { SessionService } from './services/sessions'
@@ -46,10 +47,14 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const gatewayTokens = new RunTokenRegistry()
   const claudeLogin = new ClaudeLoginManager(() => settings.get().providers.claude.cliPath, summary => audit.log({ actor: 'admin', kind: 'admin', summary }))
   const providers: Partial<Record<ProviderId, AgentProvider>> = opts.providers ?? { claude: new ClaudeProvider(), codex: new CodexProvider(log) }
+  const codexPlugins = new CodexPluginService(
+    () => (providers.codex instanceof CodexProvider ? providers.codex : null), () => settings.get(),
+    summary => audit.log({ actor: 'admin', kind: 'admin', summary }),
+  )
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
   const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, plugins, communitySkills, gatewayTokens, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, gatewayTokens, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

+ 10 - 0
apps/server/src/http/routes/admin.ts

@@ -145,6 +145,16 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     })
     g.delete<{ Params: { name: string } }>('/community-skills/:name', async req => app.communitySkills.uninstall(req.params.name))
 
+    // Codex 插件(openai-curated 目录,只在 Playground 中使用)
+    g.get<{ Querystring: { q?: string; category?: string; installed?: string; offset?: string; limit?: string; refresh?: string } }>('/codex-plugins', async req =>
+      app.codexPlugins.catalog({
+        query: req.query.q, category: req.query.category || undefined, installed: req.query.installed === '1',
+        offset: Number(req.query.offset ?? 0) || 0, limit: Number(req.query.limit ?? 50) || 50, refresh: req.query.refresh === '1',
+      }))
+    g.get<{ Params: { name: string } }>('/codex-plugins/:name', async req => app.codexPlugins.detail(req.params.name))
+    g.post<{ Params: { name: string } }>('/codex-plugins/:name/install', async req => app.codexPlugins.install(req.params.name))
+    g.post<{ Params: { name: string } }>('/codex-plugins/:name/uninstall', async req => app.codexPlugins.uninstall(req.params.name))
+
     // Codex 官方技能(openai/skills)
     g.get('/codex-skills', async () => app.codexSkills.catalog())
     g.post('/codex-skills/refresh', async () => app.codexSkills.refresh())

+ 28 - 7
apps/server/src/providers/codex/adapter.ts

@@ -12,7 +12,7 @@ import { ALWAYS_DISABLED, skillDisplayId, skillsToDisable } from './skills'
 import type {
   AskForApproval, CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, GetAccountResponse,
   ItemCompletedNotification, ItemStartedNotification, LoginAccountResponse, ModelListResponse, SkillMetadata, SkillsListResponse,
-  PermissionsRequestApprovalParams, ThreadItem, ThreadStartResponse, ThreadTokenUsageUpdatedNotification,
+  McpServerElicitationRequestParams, PermissionsRequestApprovalParams, ThreadItem, ThreadStartResponse, ThreadTokenUsageUpdatedNotification,
   ToolRequestUserInputParams, TurnCompletedNotification, TurnStartResponse,
 } from './protocol/v2'
 import { AppServerClient, RpcRequestError } from './rpc'
@@ -96,11 +96,11 @@ export class CodexProvider implements AgentProvider {
     fs.mkdirSync(path.join(ctx.workspaceDir, '.agent', 'tmp'), { recursive: true })
     const config = buildThreadConfig({
       access: ctx.access, permissionMode: ctx.permissionMode, settings: ctx.settings,
-      codexBin: this.bin(ctx.settings), codexHome: codexHome(),
+      codexBin: this.bin(ctx.settings), codexHome: codexHome(), allowPlugins: ctx.codexPlugins,
     })
     // 按 Profile 的技能选择禁用其余技能(只作用于本次对话)
     const skills = await this.listSkills(ctx.settings, ctx.workspaceDir).catch(() => [])
-    const disabled = skillsToDisable(skills, ctx.skills, codexHome(), localPluginName(ctx.settings))
+    const disabled = skillsToDisable(skills, ctx.skills, codexHome(), localPluginName(ctx.settings), ctx.codexPlugins)
     if (disabled.length) config.skills = { config: disabled }
     // 与 Claude 共用的 MCP Gateway;令牌每个 Run 不同,thread/start 与 thread/resume 都会重新传入
     config.mcp_servers = {
@@ -178,13 +178,19 @@ export class CodexProvider implements AgentProvider {
     return r.data.flatMap(e => e.skills)
   }
 
-  /** 后台展示用:技能及其展示 ID(不含始终禁用的技能) */
+  /** 后台展示用:技能及其展示 ID(不含始终禁用的技能和远程插件的技能,后者不由 Profile 选择) */
   async skillInfos(settings: AppSettings): Promise<{ id: string; name: string; description: string; scope: string }[]> {
     const skills = await this.listSkills(settings, settings.paths.workspaceRoot)
-    return skills.filter(s => !ALWAYS_DISABLED.has(s.name))
+    return skills.filter(s => !ALWAYS_DISABLED.has(s.name) && !s.pluginId)
       .map(s => ({ id: skillDisplayId(s, codexHome()), name: s.name, description: s.shortDescription ?? s.description, scope: s.scope }))
   }
 
+  /** 直接调用 app-server(插件目录等后台功能使用) */
+  async request<T>(settings: AppSettings, method: string, params: unknown, timeoutMs = 60_000): Promise<T> {
+    const client = await this.ensure(settings)
+    return client.request<T>(method, params, timeoutMs)
+  }
+
   // ---- 账户与登录(设备码方式:在 OpenAI 官方页面输入短码完成登录,AgentServer 不接触凭证) ----
 
   async startLogin(settings: AppSettings): Promise<CodexLoginState> {
@@ -474,8 +480,23 @@ class ThreadRun {
         }
         return { answers }
       }
-      case 'mcpServer/elicitation/request':
-        return { action: 'decline', content: null, _meta: null }
+      case 'mcpServer/elicitation/request': {
+        // 应用连接器的调用确认以 elicitation 形式到达:只在开放插件的会话(Playground)中,
+        // 且不需要填写内容时转成审批卡片;需要填表或跳转网址的请求一律拒绝
+        const p = params as McpServerElicitationRequestParams
+        const schema = 'requestedSchema' in p ? p.requestedSchema as { properties?: Record<string, unknown> } | null : null
+        const needsInput = p.mode === 'url' || Object.keys(schema?.properties ?? {}).length > 0
+        if (!ctx.codexPlugins || p.serverName === GATEWAY_SERVER || needsInput) {
+          ctx.audit('approval', `拒绝 MCP 请求(${p.serverName},${p.mode}):${p.message}`.slice(0, 500))
+          return { action: 'decline', content: null, _meta: null }
+        }
+        const outcome = await ctx.interact({
+          kind: 'approval', tool: `mcp__${p.serverName}`, title: p.message.split('\n')[0]!.slice(0, 200),
+          input: { server: p.serverName }, detail: { reason: p.message }, options: ['allow_once', 'deny'],
+        })
+        ctx.audit('approval', `codex 应用调用(${p.serverName}):${outcome.resolution}`, { message: p.message })
+        return { action: outcome.resolution === 'allowed' ? 'accept' : 'decline', content: outcome.resolution === 'allowed' ? {} : null, _meta: null }
+      }
       default:
         throw new RpcRequestError(method, { code: -32601, message: `AgentServer 不支持:${method}` })
     }

+ 5 - 3
apps/server/src/providers/codex/permissions.ts

@@ -42,6 +42,8 @@ export function buildThreadConfig(opts: {
   settings: AppSettings
   codexBin: string
   codexHome: string
+  /** Playground 中开放远程插件与应用连接器(插件安装建议、子代理等仍然关闭) */
+  allowPlugins?: boolean
 }): CodexThreadConfig {
   const { access } = opts
   const fs: FsTable = {
@@ -79,9 +81,9 @@ export function buildThreadConfig(opts: {
   const py = opts.settings.paths.pythonRuntime
   const agentDir = path.join(access.workspaceDir, '.agent')
   return {
-    features: { ...DISABLED_FEATURES },
-    // 即使有应用被启用,也不暴露连接器工具
-    apps: { _default: { enabled: false, destructive_enabled: false, open_world_enabled: false } },
+    features: opts.allowPlugins ? { ...DISABLED_FEATURES, plugins: true, remote_plugin: true, apps: true } : { ...DISABLED_FEATURES },
+    // 未开放插件时,即使有应用被启用也不暴露连接器工具
+    apps: opts.allowPlugins ? { _default: { enabled: true } } : { _default: { enabled: false, destructive_enabled: false, open_world_enabled: false } },
     default_permissions: PROFILE_NAME,
     permissions: { [PROFILE_NAME]: { filesystem: fs, network: { enabled: false } } },
     // 只继承基础环境变量(PATH、HOME 等),不把服务进程中的密钥带进命令

+ 6 - 2
apps/server/src/providers/codex/skills.ts

@@ -25,12 +25,16 @@ export function skillDisplayId(s: Pick<SkillMetadata, 'name' | 'scope' | 'path'>
   return `codex-user:${s.name}`
 }
 
-/** 计算需要在本次对话中禁用的技能(按路径选择);选择"全部技能"时第三方技能仍需单独勾选 */
-export function skillsToDisable(all: SkillMetadata[], selected: string[] | 'all', codexHome: string, localPlugin: string): { path: string; enabled: false }[] {
+/**
+ * 计算需要在本次对话中禁用的技能(按路径选择);选择"全部技能"时第三方技能仍需单独勾选。
+ * 远程插件带来的技能(pluginId 非空)不由 Profile 选择:开放插件时(Playground)全部保留,否则全部禁用
+ */
+export function skillsToDisable(all: SkillMetadata[], selected: string[] | 'all', codexHome: string, localPlugin: string, allowPlugins = false): { path: string; enabled: false }[] {
   const wanted = selected === 'all' ? null : new Set(selected.map(s => (s.includes(':') ? s : `${localPlugin}:${s}`)))
   return all
     .filter(s => {
       if (ALWAYS_DISABLED.has(s.name)) return true
+      if (s.pluginId) return !allowPlugins
       const id = skillDisplayId(s, codexHome)
       return wanted === null ? id.startsWith(`${COMMUNITY_SKILL_PLUGIN}:`) : !wanted.has(id)
     })

+ 2 - 0
apps/server/src/providers/types.ts

@@ -32,6 +32,8 @@ export interface RunContext {
   interact(req: NewApproval | NewQuestion): Promise<InteractionOutcome>
   setProviderSessionId(id: string): void
   audit(kind: 'command' | 'file_access_denied' | 'network_denied' | 'approval', summary: string, detail?: unknown): void
+  /** 允许 Codex 的远程插件与应用连接器(只在后台 Playground 的会话中开启,ADR-0018) */
+  codexPlugins: boolean
   /** MCP Gateway 地址与本次 Run 的令牌(两个引擎共用的工具) */
   gateway: { url: string; token: string }
   log: { info(obj: object, msg?: string): void; warn(obj: object, msg?: string): void; error(obj: object, msg?: string): void }

+ 136 - 0
apps/server/src/services/codex-plugins.ts

@@ -0,0 +1,136 @@
+// 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'
+
+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<T>(settings: AppSettings, method: string, params: unknown, timeoutMs?: number): Promise<T>
+}
+
+export class CodexPluginService {
+  private cache: { at: number; items: CodexPluginDto[] } | null = null
+
+  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
+  }
+
+  private call<T>(method: string, params: unknown, timeoutMs = 60_000): Promise<T> {
+    return this.rpc().request<T>(this.settings(), method, params, timeoutMs)
+  }
+
+  async catalog(q: { query?: string; category?: string; installed?: boolean; offset?: number; limit?: number; refresh?: boolean }): Promise<CodexPluginCatalogDto> {
+    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<CodexPluginDetailDto> {
+    const p = await this.find(name)
+    const r = await this.call<PluginReadResponse>('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<CodexPluginInstallResultDto> {
+    const p = await this.find(name)
+    if (p.availability !== 'AVAILABLE' || p.installPolicy === 'NOT_AVAILABLE') throw badRequest(`插件 ${p.displayName} 当前不可安装`)
+    const r = await this.call<PluginInstallResponse>('plugin/install', { remoteMarketplaceName: p.marketplace, pluginName: p.name }, 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 }
+  }
+
+  private async find(name: string): Promise<CodexPluginDto> {
+    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<CodexPluginDto[]> {
+    if (!refresh && this.cache && Date.now() - this.cache.at < CACHE_MS) return this.cache.items
+    const [list, installed] = await Promise.all([
+      this.call<PluginListResponse>('plugin/list', { forceRefetch: refresh }, 120_000),
+      this.call<PluginInstalledResponse>('plugin/installed', {}),
+    ])
+    const installedIds = new Set(curated(installed.marketplaces).filter(x => x.p.installed).map(x => x.p.id))
+    const items = new Map<string, CodexPluginDto>()
+    // 已安装但不在目录中的插件也要列出,便于卸载
+    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,
+  }
+}

+ 2 - 0
apps/server/src/services/runs.ts

@@ -199,6 +199,8 @@ export class RunManager {
         skills: profile.skills,
         // 插件只在 Claude 中加载;专有许可的插件不能提供给其他引擎(ADR-0013)。第三方技能 Codex 经 extraRoots 加载(ADR-0017)
         plugins: session.provider === 'claude' ? await this.claudePlugins() : [],
+        // Codex 插件与应用连接器使用账号绑定的数据,只在后台 Playground(source = admin)中开放
+        codexPlugins: session.provider === 'codex' && session.source === 'admin',
         input: await this.buildInput(session, text, attachmentIds),
         access,
         settings,

+ 42 - 4
apps/server/test/codex.test.ts

@@ -14,7 +14,7 @@ const settings = defaultSettings()
 settings.providers.codex = { ...settings.providers.codex, enabled: true, cliPath: fake }
 const silent = { info: () => {}, warn: () => {}, error: () => {} }
 
-function makeCtx(text: string, opts: { providerSessionId?: string; mode?: 'ask' | 'auto' | 'readonly'; answer?: (r: NewApproval | NewQuestion) => InteractionOutcome; signal?: AbortSignal } = {}) {
+function makeCtx(text: string, opts: { providerSessionId?: string; mode?: 'ask' | 'auto' | 'readonly'; answer?: (r: NewApproval | NewQuestion) => InteractionOutcome; signal?: AbortSignal; codexPlugins?: boolean } = {}) {
   const ws = path.join(config.dataDir, 'workspaces', `ses_codex_${Math.random().toString(36).slice(2)}`)
   fs.mkdirSync(ws, { recursive: true })
   const events: { type: AgentEventType; payload: unknown }[] = []
@@ -23,7 +23,7 @@ function makeCtx(text: string, opts: { providerSessionId?: string; mode?: 'ask'
   let sessionId: string | null = null
   const ctx: RunContext = {
     sessionId: 's', runId: 'r', model: 'gpt-test', permissionMode: opts.mode ?? 'ask', providerSessionId: opts.providerSessionId ?? null,
-    workspaceDir: ws, systemPromptAppend: '说明', allowedTools: [], disallowedTools: [], skills: 'all', plugins: [],
+    workspaceDir: ws, systemPromptAppend: '说明', allowedTools: [], disallowedTools: [], skills: 'all', plugins: [], codexPlugins: opts.codexPlugins ?? false,
     gateway: { url: 'http://127.0.0.1:9/mcp', token: 'rt_test' },
     input: { text, images: [], attachments: [] },
     access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: ['**/*.env'] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
@@ -59,8 +59,10 @@ describe('CodexProvider(模拟 app-server)', () => {
     expect(fsTable[path.join(config.home, '.ssh')]).toBe('deny')
     expect(cfg.permissions.agent_server.network.enabled).toBe(false)
     expect(cfg.shell_environment_policy.inherit).toBe('core')
-    // Profile 选择 all:只禁用始终禁用的系统技能
-    expect(cfg.skills).toEqual({ config: [{ path: '/sys/skill-installer/SKILL.md', enabled: false }] })
+    // Profile 选择 all:禁用始终禁用的系统技能;没有开放插件时远程插件的技能也禁用
+    expect(cfg.skills).toEqual({ config: [
+      { path: '/sys/skill-installer/SKILL.md', enabled: false }, { path: '/plugins/google-drive/skills/google-docs/SKILL.md', enabled: false },
+    ] })
     // 插件、应用连接器、插件安装建议与子代理在服务中关闭
     expect(cfg.features).toMatchObject({ plugins: false, remote_plugin: false, apps: false, tool_suggest: false, multi_agent: false })
     expect(cfg.apps._default.enabled).toBe(false)
@@ -68,6 +70,42 @@ describe('CodexProvider(模拟 app-server)', () => {
     expect(cfg.mcp_servers.gateway).toMatchObject({ url: 'http://127.0.0.1:9/mcp', http_headers: { Authorization: 'Bearer rt_test' }, default_tools_approval_mode: 'approve' })
   })
 
+  it('Playground:开放插件与应用连接器,应用调用确认转为审批卡片', async () => {
+    const t = makeCtx('调用应用', { codexPlugins: true })
+    await provider.run(t.ctx)
+    const cfg = JSON.parse((t.events.find(e => (e.payload as { messageId?: string }).messageId === 'cfg')!.payload as { text: string }).text)
+    expect(cfg.features).toMatchObject({ plugins: true, remote_plugin: true, apps: true, tool_suggest: false, multi_agent: false })
+    expect(cfg.apps._default.enabled).toBe(true)
+    expect(cfg.skills).toEqual({ config: [{ path: '/sys/skill-installer/SKILL.md', enabled: false }] })
+    expect(t.interactions[0]).toMatchObject({ kind: 'approval', tool: 'mcp__codex_apps', title: '允许调用 Gmail 的 search_emails?' })
+    expect(t.events.some(e => (e.payload as { text?: string }).text === 'app:accept')).toBe(true)
+  })
+
+  it('API 会话:应用调用确认直接拒绝', async () => {
+    const t = makeCtx('调用应用')
+    await provider.run(t.ctx)
+    expect(t.interactions).toHaveLength(0)
+    expect(t.events.some(e => (e.payload as { text?: string }).text === 'app:decline')).toBe(true)
+  })
+
+  it('插件目录:只列出 openai-curated,搜索、安装、卸载', async () => {
+    const { CodexPluginService } = await import('../src/services/codex-plugins')
+    const audits: string[] = []
+    const svc = new CodexPluginService(() => provider, () => settings, s => audits.push(s))
+    const all = await svc.catalog({})
+    expect(all.items.map(p => [p.name, p.installed])).toEqual([['gmail', true], ['notion', false], ['blocked', false]])
+    expect(all.categories).toEqual(['Docs', 'Productivity'])
+    expect((await svc.catalog({ query: 'notion' })).items.map(p => p.displayName)).toEqual(['Notion'])
+    expect((await svc.catalog({ installed: true })).total).toBe(1)
+    expect(await svc.install('notion')).toEqual({ appsNeedingAuth: [{ id: 'connector_n', name: 'Notion', installUrl: 'https://chatgpt.com/apps/notion' }] })
+    await expect(svc.install('blocked')).rejects.toThrow(/不可安装/)
+    await expect(svc.install('other-plugin')).rejects.toThrow(/不存在/)
+    await expect(svc.install('../x')).rejects.toThrow(/格式/)
+    await svc.uninstall('gmail')
+    expect((await svc.catalog({})).items.find(p => p.name === 'gmail')!.installed).toBe(false)
+    expect(audits).toEqual(['安装 Codex 插件 Notion(notion@openai-curated-remote)', '卸载 Codex 插件 GMAIL(gmail@openai-curated-remote)'])
+  })
+
   it('拒绝审批时工具标记为 denied', async () => {
     const t = makeCtx('请执行命令', { answer: () => ({ resolution: 'denied' }) })
     await provider.run(t.ctx)

+ 12 - 0
apps/server/test/fixtures-fake-codex.mjs

@@ -34,7 +34,15 @@ readline.createInterface({ input: process.stdin }).on('line', async line => {
     case 'skills/list': return reply({ data: [{ cwd: params.cwds[0], errors: [], skills: [
       { name: 'imagegen', description: '', scope: 'system', path: '/sys/imagegen/SKILL.md', enabled: true, pluginId: null },
       { name: 'skill-installer', description: '', scope: 'system', path: '/sys/skill-installer/SKILL.md', enabled: true, pluginId: null },
+      { name: 'google-drive:google-docs', description: '', scope: 'user', path: '/plugins/google-drive/skills/google-docs/SKILL.md', enabled: true, pluginId: 'google-drive@openai-curated-remote' },
     ] }] })
+    case 'plugin/list': case 'plugin/installed': {
+      const p = (name, installed, extra = {}) => ({ id: `${name}@openai-curated-remote`, name, installed, enabled: installed, installPolicy: 'AVAILABLE', availability: 'AVAILABLE', source: { type: 'remote' }, interface: { displayName: name.toUpperCase(), shortDescription: `${name} desc`, category: 'Productivity', developerName: 'OpenAI' }, ...extra })
+      const plugins = method === 'plugin/installed' ? [p('gmail', globalThis.gmail ?? true)] : [p('gmail', globalThis.gmail ?? true), p('notion', false, { interface: { displayName: 'Notion', shortDescription: 'docs', category: 'Docs' } }), p('blocked', false, { availability: 'DISABLED_BY_ADMIN' })]
+      return reply({ marketplaces: [{ name: 'openai-curated-remote', path: null, interface: null, plugins }, { name: 'other', path: '/x', interface: null, plugins: [p('other-plugin', false)] }], marketplaceLoadErrors: [], featuredPluginIds: [] })
+    }
+    case 'plugin/install': globalThis.installed = params; return reply({ authPolicy: 'ON_USE', appsNeedingAuth: [{ id: 'connector_n', name: 'Notion', description: null, installUrl: 'https://chatgpt.com/apps/notion', category: null }] })
+    case 'plugin/uninstall': globalThis.gmail = false; return reply({})
     case 'turn/start': {
       const threadId = params.threadId, turnId = 'turn-1'
       reply({ turn: { id: turnId, status: 'inProgress' } })
@@ -55,6 +63,10 @@ readline.createInterface({ input: process.stdin }).on('line', async line => {
         const r = await ask('item/tool/requestUserInput', { threadId, turnId, itemId: 'q1', isBlocking: true, autoResolutionMs: null, questions: [{ id: 'chart', header: '图表', question: '用哪种图?', isOther: true, isSecret: false, options: [{ label: '柱状图', description: '' }, { label: '折线图', description: '' }] }] })
         notify('item/completed', { threadId, turnId, item: { type: 'agentMessage', id: 'ans', text: 'answer:' + JSON.stringify(r.answers) } })
       }
+      if (text.includes('应用')) {
+        const r = await ask('mcpServer/elicitation/request', { threadId, turnId, serverName: 'codex_apps', mode: 'form', _meta: null, message: '允许调用 Gmail 的 search_emails?', requestedSchema: { type: 'object', properties: {} } })
+        notify('item/completed', { threadId, turnId, item: { type: 'agentMessage', id: 'app', text: 'app:' + r.action } })
+      }
       if (text.includes('等待')) return // 不结束,等中断
       notify('item/agentMessage/delta', { threadId, turnId, itemId: 'm1', delta: '完' })
       notify('item/agentMessage/delta', { threadId, turnId, itemId: 'm1', delta: '成' })

+ 1 - 0
docs/README.md

@@ -46,6 +46,7 @@ docs/
 | [0015](adr/0015-api-key-profiles.md) | API Key 只能使用指定的 Profile;权限模式由 Profile 决定 | Accepted |
 | [0016](adr/0016-official-skill-repos-per-engine.md) | 每个引擎接入各自厂商的官方技能仓库 | Accepted |
 | [0017](adr/0017-community-skills.md) | 第三方技能:skills.sh 目录 + GitHub 固定提交,两个引擎共用 | Accepted |
+| [0018](adr/0018-codex-curated-plugins-playground-only.md) | Codex 插件(openai-curated)只在 Playground 中使用 | Accepted |
 
 ## 调研
 

+ 31 - 0
docs/adr/0018-codex-curated-plugins-playground-only.md

@@ -0,0 +1,31 @@
+# ADR-0018 Codex 插件(openai-curated)只在 Playground 中使用
+
+- 状态:Accepted · 日期:2026-10-23 · 补充 ADR-0016
+
+## 背景
+服务账号登录 ChatGPT 后,Codex 会从 OpenAI 官方插件目录 `openai-curated-remote` 自动安装一批插件,例如 Google Drive、Gmail、Outlook、Teams、Pages、Sites。目录共 7700 多项,绝大多数是 ChatGPT 应用连接器。
+
+- 连接器由 OpenAI 远程执行,使用登录账号的授权访问对应数据
+- 插件还会带来技能,以及"安装插件"的建议
+
+这些能力只适合管理员本人使用,不能开放给经 API 接入的平台(家人使用的各个前端)。
+
+## 决策
+- **后台目录**:新增"技能与插件 › Codex · OpenAI 插件"页,只管理 `openai-curated*` 目录。
+  - 通过 app-server 的 `plugin/list`、`plugin/installed`、`plugin/read`、`plugin/install`、`plugin/uninstall` 实现
+  - 目录缓存 10 分钟,服务端搜索、分类筛选、分页
+  - 安装后若有需要授权的应用(`appsNeedingAuth`),提示到 ChatGPT 中完成连接
+  - 插件文件由 Codex 自己下载和存放
+- **只在 Playground 开放**:会话的 `source = admin`(在后台 Playground 中创建)且引擎为 Codex 时,对话 config 才打开 `features.plugins` / `remote_plugin` / `apps` 和 `apps._default.enabled`。
+  - 以下功能仍然关闭:`tool_suggest`(对话中不能安装插件)、`multi_agent`、`browser_use`、`computer_use`、`skill_mcp_dependency_install`
+  - 经 API Key 创建的会话始终全部关闭,后台"在 Playground 中继续"一个 API 会话也不会开放
+- **插件技能**:远程插件带来的技能(`pluginId` 非空)不在 Profile 的技能列表中出现,也不受 Profile 选择约束。开放插件的会话全部保留,其他会话全部禁用。
+- **调用确认**:应用连接器的调用确认以 `mcpServer/elicitation/request` 到达。
+  - 开放插件的会话中,不需要填写内容的确认转成审批卡片,并写入审计
+  - 需要填表或跳转网址的请求一律拒绝
+  - 其他会话一律拒绝
+- **Playground 提示**:Codex 会话的工具栏显示"插件已开放"。
+
+## 后果
+- Claude 侧不受影响(Claude 的插件机制见 ADR-0013)
+- 已加载的 Codex 会话沿用创建时的配置(03 §3.4 已知限制),因此 Playground 会话与 API 会话的配置互不影响

+ 1 - 0
docs/design/03-providers.md

@@ -134,6 +134,7 @@ Thread 参数:`approvalPolicy = on-request`、`cwd = workspaceDir`,沙箱使
   - `features`:`plugins` / `remote_plugin` / `apps` / `tool_suggest` / `skill_mcp_dependency_install` / `multi_agent` / `browser_use` / `computer_use` 全部为 false
   - `apps._default.enabled = false`
   - 已验证:覆盖后对话中只剩 AgentServer 提供的技能与 MCP Gateway
+  - 例外:后台 Playground 创建的 Codex 会话开放插件与应用连接器(ADR-0018)
   - 遗留问题:`collaboration.*`(子代理)工具在关闭 `multi_agent` 后仍然出现,子代理继承同一沙箱,待确认对应开关
 - **网关令牌**:Codex 的会话在 app-server 中保持加载,`thread/resume` 不会更新已建立的 MCP 连接的请求头,所以 Codex 每个会话使用固定的令牌字符串:
   - 每次 Run 开始时绑定到当前 Run,结束后失效

+ 1 - 1
docs/design/11-roadmap.md

@@ -17,7 +17,7 @@
 2. 让 Agent 读取 `~/.ssh` 和 `~/.claude/.credentials.json`,通过 Read 工具和 Bash 两条路径都被拦截,并写入审计日志。
 3. 在后台新增一个资料库并在 Profile 中启用,Agent 能检索其中的内容;未启用的资料库读取会被拒绝。
 
-### M2:Codex 与客户端包(进行中:① Codex 接入 ✅、② MCP Gateway ✅、第三方技能(skills.sh,ADR-0017)✅;之后:Codex openai-curated 插件目录、④ 用量统计、③ 业务平台接入)
+### M2:Codex 与客户端包(进行中:① Codex 接入 ✅、② MCP Gateway ✅、第三方技能(skills.sh,ADR-0017)✅、Codex openai-curated 插件目录(仅 Playground,ADR-0018)✅;之后:④ 用量统计、③ 业务平台接入)
 - CodexAdapter:app-server 进程管理、个人配置覆盖与自检、协议类型生成、事件映射、审批桥接、权限配置映射
 - MCP Gateway 上线(内置 `web_fetch`、`kb_list_roots`、`ask_user`)
 - Profile 管理(另存为、版本历史)

+ 44 - 0
packages/shared/src/api.ts

@@ -284,3 +284,47 @@ export interface CommunitySkillDto {
   license: string | null
   installedAt: string
 }
+
+// ---- Codex 插件(OpenAI 官方 openai-curated 目录,只在 Playground 中使用,ADR-0018)----
+
+export interface CodexPluginDto {
+  /** 插件 ID(plugin/uninstall 使用),形如 gmail@openai-curated-remote */
+  id: string
+  name: string
+  displayName: string
+  description: string
+  category: string | null
+  developer: string | null
+  marketplace: string
+  installed: boolean
+  enabled: boolean
+  /** NOT_AVAILABLE / AVAILABLE / INSTALLED_BY_DEFAULT */
+  installPolicy: string
+  /** AVAILABLE / DISABLED_BY_ADMIN */
+  availability: string
+  logoUrl: string | null
+}
+
+export interface CodexPluginCatalogDto {
+  items: CodexPluginDto[]
+  /** 符合筛选条件的总数 */
+  total: number
+  categories: string[]
+  fetchedAt: string | null
+  error: string | null
+}
+
+export interface CodexPluginDetailDto extends CodexPluginDto {
+  longDescription: string | null
+  skills: { name: string; description: string }[]
+  apps: { id: string; name: string; description: string | null; installUrl: string | null }[]
+  mcpServers: string[]
+  websiteUrl: string | null
+  privacyPolicyUrl: string | null
+  termsOfServiceUrl: string | null
+}
+
+export interface CodexPluginInstallResultDto {
+  /** 需要在 ChatGPT 中授权的应用(打开 installUrl 完成连接) */
+  appsNeedingAuth: { id: string; name: string; installUrl: string | null }[]
+}