Explorar el Código

feat: Codex 接入 OpenAI 官方技能仓库 openai/skills

- 新增 CodexSkillService:获取 openai/skills 在某个提交上的快照(缓存在数据目录),
  安装时把技能复制到服务专用 CODEX_HOME/skills 并写入标记文件(来源、提交、许可证),
  支持更新到新快照与卸载;只能卸载由 AgentServer 安装的技能
- 只允许安装开源许可(Apache-2.0 / MIT)的技能,专有许可、第三方条款
  (如 Figma 技能)与无法识别的许可均拒绝
- Codex 适配:启动时用 skills/extraRoots/set 加入本地技能目录(与 Claude 共用的
  自写技能);每次对话按 Profile 的技能选择,在 config 中用 skills.config 禁用
  未选中的技能;skill-installer、skill-creator、plugin-creator 始终禁用;
  已安装技能在沙箱中只读可见
- 管理接口:/admin/v1/codex-skills(目录、更新、安装、卸载);
  /admin/v1/skills?engine=codex 返回 Codex 实际可见的技能
- 后台"技能与插件"页按引擎分为 Claude(anthropics/skills)与 Codex
  (openai/skills)两个页签;Profile 编辑页按所选引擎列出技能
- 文档:新增 ADR-0016(每个引擎接入各自厂商的官方技能仓库)

测试:56 个通过(新增技能目录获取与许可识别、安装/更新/卸载、只能卸载自己安装的
技能、技能 ID 与禁用规则、对话 config 中的技能禁用)
验证:开发环境从 GitHub 获取 openai/skills@49f948fa(39 个技能),安装 pdf 后
Codex 可见 openai-skills:pdf、本地 report-conventions 与系统技能

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang hace 1 semana
padre
commit
2dceccead2

+ 8 - 2
apps/admin/src/lib/api.ts

@@ -1,7 +1,7 @@
 // 后台 API:同源 Cookie 鉴权,复用 AgentClient 的请求与错误处理
 import { AgentClient } from '@agent-server/client'
 import type {
-  ApiKeyDto, ApiKeyProfiles, AppSettings, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
+  ApiKeyDto, ApiKeyProfiles, AppSettings, CodexSkillCatalogDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
   ProviderStatusDto, ResolvedAccessDto, SkillInfo,
 } from '@agent-server/shared'
 
@@ -33,7 +33,13 @@ export const admin = {
   settings: () => req<{ settings: AppSettings; defaults: AppSettings; builtinGlobalDeny: string[] }>('GET', '/settings'),
   updateSettings: (patch: DeepPartial<AppSettings>) => req<{ settings: AppSettings }>('PATCH', '/settings', patch),
   checkPath: (path: string, purpose: 'knowledge' | 'generic' = 'knowledge') => req<PathCheck>('POST', '/paths/check', { path, purpose }),
-  skills: () => req<SkillInfo[]>('GET', '/skills'),
+  skills: (engine: 'claude' | 'codex' = 'claude') => req<SkillInfo[]>('GET', `/skills?engine=${engine}`),
+  codexSkills: {
+    catalog: () => req<CodexSkillCatalogDto>('GET', '/codex-skills'),
+    refresh: () => req<CodexSkillCatalogDto>('POST', '/codex-skills/refresh'),
+    install: (name: string) => req<CodexSkillCatalogDto>('POST', `/codex-skills/${encodeURIComponent(name)}`),
+    uninstall: (name: string) => req<CodexSkillCatalogDto>('DELETE', `/codex-skills/${encodeURIComponent(name)}`),
+  },
   plugins: (refresh = false) => req<PluginStateDto>('GET', `/plugins${refresh ? '?refresh=1' : ''}`),
   pluginAction: (a: PluginAction) => req<{ message: string; state: PluginStateDto }>('POST', '/plugins/actions', a),
   profiles: () => req<ProfileDto[]>('GET', '/profiles'),

+ 80 - 4
apps/admin/src/views/PluginsView.vue

@@ -1,12 +1,37 @@
 <script setup lang="ts">
 import { computed, onMounted, ref } from 'vue'
-import type { AvailablePluginDto, PluginAction, PluginStateDto } from '@agent-server/shared'
+import type { AvailablePluginDto, CodexSkillCatalogDto, PluginAction, PluginStateDto } from '@agent-server/shared'
 import PageHeader from '../components/PageHeader.vue'
 import { admin, errorMessage } from '../lib/api'
 import { formatTime, toast } from '../lib/ui'
 
 const OFFICIAL_SOURCE = 'anthropics/skills'
 
+// 每个引擎接入各自厂商的官方技能仓库
+const tab = ref<'claude' | 'codex'>('claude')
+const codex = ref<CodexSkillCatalogDto | null>(null)
+const codexBusy = ref<string | null>(null)
+
+async function loadCodex() {
+  codex.value = await admin.codexSkills.catalog().catch(err => (toast(errorMessage(err), 'error'), null))
+}
+
+const refreshCodex = () => codexAct('refresh', () => admin.codexSkills.refresh(), '技能目录已更新')
+const installCodex = (name: string, update = false) => codexAct(name, () => admin.codexSkills.install(name), `${name} 已${update ? '更新' : '安装'}`)
+const uninstallCodex = (name: string) => codexAct(name, () => admin.codexSkills.uninstall(name), `${name} 已卸载`)
+
+async function codexAct(key: string, fn: () => Promise<CodexSkillCatalogDto>, done: string) {
+  codexBusy.value = key
+  try {
+    codex.value = await fn()
+    toast(done, 'success')
+  } catch (err) {
+    toast(errorMessage(err), 'error')
+  } finally {
+    codexBusy.value = null
+  }
+}
+
 const state = ref<PluginStateDto | null>(null)
 const loading = ref(false)
 const busy = ref<string | null>(null)
@@ -61,16 +86,67 @@ function addMarketplace(source: string) {
   void act({ action: 'marketplace-add', source: source.trim() }, `mkt:${source}`).then(() => (newSource.value = ''))
 }
 
-onMounted(() => load())
+onMounted(() => {
+  void load()
+  void loadCodex()
+})
 </script>
 
 <template>
   <div class="h-full flex flex-col">
     <PageHeader title="技能与插件" subtitle="通过 Claude Code 官方插件机制安装和管理,AgentServer 不复制、不修改插件内容">
-      <button type="button" class="btn-secondary" :disabled="loading" @click="load(true)"><span class="codicon codicon-refresh" />刷新</button>
+      <button type="button" class="btn-secondary" :disabled="loading" @click="load(true); loadCodex()"><span class="codicon codicon-refresh" />刷新</button>
     </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 官方技能']] 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">
-      <div class="max-w-240 p-6 flex flex-col gap-8">
+      <!-- Codex:OpenAI 官方技能仓库 openai/skills -->
+      <div v-if="tab === 'codex'" class="max-w-240 p-6 flex flex-col gap-6">
+        <section class="card p-4 flex gap-3">
+          <span class="codicon codicon-info text-link text-[18px] mt-0.5" />
+          <div class="flex flex-col gap-1.5 text-[12.5px]">
+            <div class="font-600 text-[13px]">来源:OpenAI 官方仓库 <a href="https://github.com/openai/skills" target="_blank" rel="noopener noreferrer" class="text-link">github.com/openai/skills</a></div>
+            <div>技能按固定的提交版本下载,安装到服务专用的 Codex 技能目录,<b>只提供给 Codex 引擎</b>。每个技能单独附带许可证,<b>只允许安装开源许可(Apache-2.0 / MIT)的技能</b>;专有许可、第三方条款(如 Figma)或无法识别的许可不能安装。</div>
+            <div class="hint">Codex 自带的系统技能(imagegen、openai-docs 等)无需安装;skill-installer、skill-creator 在服务中始终禁用。本地技能目录中的自写技能两个引擎共用。</div>
+          </div>
+        </section>
+        <div class="flex items-center gap-3">
+          <span class="hint flex-1">
+            当前目录:<template v-if="codex?.sha"><span class="font-mono">{{ codex.repo }}@{{ codex.sha.slice(0, 8) }}</span>({{ formatTime(codex.fetchedAt) }} 获取)</template><template v-else>尚未获取</template>
+          </span>
+          <button type="button" class="btn-secondary" :disabled="!!codexBusy" @click="refreshCodex">
+            <span class="codicon" :class="codexBusy === 'refresh' ? 'codicon-loading codicon-modifier-spin' : 'codicon-cloud-download'" />{{ codex?.sha ? '更新目录' : '获取技能目录' }}
+          </button>
+        </div>
+        <div class="card divide-y divide-solid divide-border">
+          <div v-for="s in codex?.skills" :key="s.name" class="flex items-start gap-3 px-4 py-2.5">
+            <div class="flex-1 min-w-0">
+              <div class="flex items-center gap-2">
+                <span class="font-500">{{ s.name }}</span>
+                <span class="badge bg-code" :class="s.license === 'open' ? 'text-success' : 'text-warning'">{{ s.licenseName ?? '许可证未知' }}</span>
+                <span v-if="s.installed" class="badge bg-code text-accent">已安装 {{ s.installedSha?.slice(0, 8) }}</span>
+              </div>
+              <div class="hint line-clamp-2">{{ s.description }}</div>
+            </div>
+            <template v-if="s.installed">
+              <button v-if="s.updatable" type="button" class="btn-ghost flex-none" :disabled="!!codexBusy" @click="installCodex(s.name, true)">更新</button>
+              <button type="button" class="icon-btn flex-none" title="卸载" :disabled="!!codexBusy" @click="uninstallCodex(s.name)"><span class="codicon codicon-trash" /></button>
+            </template>
+            <button v-else type="button" class="btn-secondary flex-none" :disabled="!!codexBusy || s.license !== 'open'" :title="s.license !== 'open' ? '只能安装开源许可(Apache-2.0 / MIT)的技能' : ''" @click="installCodex(s.name)">
+              <span class="codicon" :class="codexBusy === s.name ? 'codicon-loading codicon-modifier-spin' : 'codicon-cloud-download'" />安装
+            </button>
+          </div>
+          <div v-if="codex && !codex.skills.length" class="px-4 py-3 hint">点击"获取技能目录"从官方仓库读取可用技能</div>
+        </div>
+        <div v-if="codex?.unmanaged.length" class="hint">Codex 技能目录中还有不是由 AgentServer 安装的技能:{{ codex.unmanaged.join('、') }}</div>
+      </div>
+
+      <div v-else class="max-w-240 p-6 flex flex-col gap-8">
         <!-- 合规说明 -->
         <section class="card p-4 flex gap-3 border-[color-mix(in_srgb,var(--app-warning)_45%,transparent)]">
           <span class="codicon codicon-law text-warning text-[18px] mt-0.5" />

+ 7 - 0
apps/admin/src/views/ProfilesView.vue

@@ -50,6 +50,11 @@ watch(id, () => {
 }, { immediate: true })
 watch(profiles, resetDraft)
 
+// 技能列表随引擎切换
+watch(() => draft.value?.provider, async p => {
+  if (p) skills.value = await admin.skills(p).catch(() => [])
+})
+
 // 文件访问策略生效预览
 let previewTimer: ReturnType<typeof setTimeout> | undefined
 watch(() => draft.value && JSON.stringify([draft.value.fileAccess, draft.value.knowledgeRootIds]), () => {
@@ -173,6 +178,8 @@ onMounted(() => load().catch(err => toast(errorMessage(err), 'error')))
                 <span class="min-w-0">
                   <span class="font-mono text-[12px]">{{ s.id }}</span>
                   <span v-if="s.license === 'restricted'" class="badge bg-code text-warning ml-1">专有许可 · 仅 Claude</span>
+                  <span v-if="s.source === 'official'" class="badge bg-code ml-1">OpenAI 官方</span>
+                  <span v-if="s.source === 'system'" class="badge bg-code ml-1">Codex 自带</span>
                   <span class="block hint truncate">{{ s.description }}</span>
                 </span>
               </label>

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

@@ -12,6 +12,7 @@ import { FileService } from './services/files'
 import { InteractionService } from './services/interactions'
 import { PluginService } from './services/plugins'
 import { ClaudeLoginManager } from './services/claude-login'
+import { CodexSkillService } from './services/codex-skills'
 import { RunManager } from './services/runs'
 import { SessionService } from './services/sessions'
 import { SettingsService } from './services/settings'
@@ -38,12 +39,13 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const sessions = new SessionService(db, settings, profiles, apiKeys, files, events)
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
   const plugins = new PluginService(settings, audit)
+  const codexSkills = new CodexSkillService(summary => audit.log({ actor: 'admin', kind: 'admin', summary }))
   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) }
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
   const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, plugins, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, claudeLogin, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, claudeLogin, codexSkills, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

+ 18 - 1
apps/server/src/http/routes/admin.ts

@@ -117,7 +117,24 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
       app.audit.log({ actor: 'admin', kind: 'admin', summary: '修改系统设置', detail: req.body })
       return { settings: next }
     })
-    g.get('/skills', async () => app.plugins.skills())
+    // 某个引擎可选的技能:Claude = 本地技能 + 已启用插件;Codex = Codex 实际可见的技能
+    g.get<{ Querystring: { engine?: string } }>('/skills', async req => {
+      if (req.query.engine !== 'codex') return app.plugins.skills()
+      const codex = app.providers.codex
+      if (!(codex instanceof CodexProvider)) return []
+      const infos = await codex.skillInfos(app.settings.get()).catch(() => [])
+      return infos.map(s => {
+        const [prefix] = s.id.split(':')
+        const source = s.scope === 'system' ? 'system' : prefix === 'openai-skills' ? 'official' : prefix === 'codex-user' ? 'plugin' : 'local'
+        return { id: s.id, name: s.name.split(':').pop()!, description: s.description, plugin: prefix!, source, license: source === 'official' || source === 'local' ? 'open' : 'unknown' }
+      })
+    })
+
+    // Codex 官方技能(openai/skills)
+    g.get('/codex-skills', async () => app.codexSkills.catalog())
+    g.post('/codex-skills/refresh', async () => app.codexSkills.refresh())
+    g.post<{ Params: { name: string } }>('/codex-skills/:name', async req => app.codexSkills.install(req.params.name))
+    g.delete<{ Params: { name: string } }>('/codex-skills/:name', async req => app.codexSkills.uninstall(req.params.name))
 
     // 技能与插件(调用 Claude Code 自带的插件命令)
     g.get<{ Querystring: { refresh?: string } }>('/plugins', async req => app.plugins.state(req.query.refresh === '1'))

+ 31 - 1
apps/server/src/providers/codex/adapter.ts

@@ -6,9 +6,10 @@ import { checkAccess } from '../../services/access-policy'
 import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
 import { bundledCodexPath, codexHome } from './cli'
 import { buildThreadConfig } from './permissions'
+import { ALWAYS_DISABLED, skillDisplayId, skillsToDisable } from './skills'
 import type {
   AskForApproval, CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, GetAccountResponse,
-  ItemCompletedNotification, ItemStartedNotification, LoginAccountResponse, ModelListResponse,
+  ItemCompletedNotification, ItemStartedNotification, LoginAccountResponse, ModelListResponse, SkillMetadata, SkillsListResponse,
   PermissionsRequestApprovalParams, ThreadItem, ThreadStartResponse, ThreadTokenUsageUpdatedNotification,
   ToolRequestUserInputParams, TurnCompletedNotification, TurnStartResponse,
 } from './protocol/v2'
@@ -73,6 +74,9 @@ export class CodexProvider implements AgentProvider {
         for (const run of this.runs.values()) run.fail(new ProviderRunError('codex_exited', `codex app-server 意外退出(${code})`))
       })
       const init = await client.start(CLIENT_INFO) as { userAgent?: string }
+      // 本地技能目录(与 Claude 共用,自写技能)作为额外技能根目录
+      const localSkills = path.join(settings.paths.skillsDir, 'skills')
+      if (fs.existsSync(localSkills)) await client.request('skills/extraRoots/set', { extraRoots: [localSkills] }).catch(err => this.log.warn({ err: (err as Error).message }, 'codex extraRoots 设置失败'))
       // userAgent 形如 "agent_server/0.164.0 (...)",斜杠后是 codex 版本
       this.version = /\/([\d.]+)/.exec(init.userAgent ?? '')?.[1] ?? null
       this.client = client
@@ -92,6 +96,10 @@ export class CodexProvider implements AgentProvider {
       access: ctx.access, permissionMode: ctx.permissionMode, settings: ctx.settings,
       codexBin: this.bin(ctx.settings), codexHome: codexHome(),
     })
+    // 按 Profile 的技能选择禁用其余技能(只作用于本次对话)
+    const skills = await this.listSkills(ctx.settings, ctx.workspaceDir).catch(() => [])
+    const disabled = skillsToDisable(skills, ctx.skills, codexHome(), localPluginName(ctx.settings))
+    if (disabled.length) config.skills = { config: disabled }
     // 需审批:沙箱内的命令自动执行,越过沙箱边界时请求审批;自动/只读:从不请求,越界直接失败
     const approvalPolicy: AskForApproval = ctx.permissionMode === 'ask' ? 'on-request' : 'never'
     const threadParams = {
@@ -150,6 +158,20 @@ export class CodexProvider implements AgentProvider {
     return run.onServerRequest(method, params)
   }
 
+  /** Codex 在某个工作目录下可见的全部技能 */
+  async listSkills(settings: AppSettings, cwd: string): Promise<SkillMetadata[]> {
+    const client = await this.ensure(settings)
+    const r = await client.request<SkillsListResponse>('skills/list', { cwds: [cwd], forceReload: true }, 30_000)
+    return r.data.flatMap(e => e.skills)
+  }
+
+  /** 后台展示用:技能及其展示 ID(不含始终禁用的技能) */
+  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))
+      .map(s => ({ id: skillDisplayId(s, codexHome()), name: s.name, description: s.shortDescription ?? s.description, scope: s.scope }))
+  }
+
   // ---- 账户与登录(设备码方式:在 OpenAI 官方页面输入短码完成登录,AgentServer 不接触凭证) ----
 
   async startLogin(settings: AppSettings): Promise<CodexLoginState> {
@@ -478,3 +500,11 @@ function describeTool(item: ThreadItem, cwd: string): { name: string; title: str
       return null
   }
 }
+
+function localPluginName(settings: AppSettings): string {
+  try {
+    return (JSON.parse(fs.readFileSync(path.join(settings.paths.skillsDir, '.claude-plugin/plugin.json'), 'utf8')) as { name: string }).name
+  } catch {
+    return 'agent-skills'
+  }
+}

+ 2 - 0
apps/server/src/providers/codex/permissions.ts

@@ -32,6 +32,8 @@ export function buildThreadConfig(opts: {
     // Codex 沙箱辅助程序及其指向的二进制
     [realpathLoose(path.join(opts.codexHome, 'tmp', 'arg0-linux'))]: 'read',
     [path.dirname(realpathLoose(opts.codexBin))]: 'read',
+    // 已安装的 Codex 技能(脚本与资源需要在沙箱中可读)
+    [realpathLoose(path.join(opts.codexHome, 'skills'))]: 'read',
   }
   const workspace = realpathLoose(access.workspaceDir)
   for (const p of access.readable) if (p !== '/' && p !== workspace) fs[p] = 'read'

+ 31 - 0
apps/server/src/providers/codex/skills.ts

@@ -0,0 +1,31 @@
+// Codex 技能的展示 ID 与按 Profile 选择(与 Claude 侧的 providers/claude/skills.ts 对应)
+import fs from 'node:fs'
+import path from 'node:path'
+import type { SkillMetadata } from './protocol/v2'
+
+/** 服务场景下始终禁用的系统技能:会联网下载或写入 Codex 自身目录 */
+export const ALWAYS_DISABLED = new Set(['skill-installer', 'skill-creator', 'plugin-creator'])
+
+/**
+ * 展示 ID:
+ * - 系统技能 → codex-system:<name>
+ * - 从 openai/skills 安装的技能(CODEX_HOME/skills 下有标记文件)→ openai-skills:<name>
+ * - 本地技能目录(extraRoots,名称已带插件前缀)→ 原样,如 agent-skills:report-conventions
+ * - 其他用户技能 → codex-user:<name>
+ */
+export function skillDisplayId(s: Pick<SkillMetadata, 'name' | 'scope' | 'path'>, codexHome: string): string {
+  if (s.scope === 'system') return `codex-system:${s.name}`
+  if (s.name.includes(':')) return s.name
+  const managedRoot = path.join(codexHome, 'skills')
+  const dir = path.dirname(s.path)
+  if (path.dirname(dir) === managedRoot && fs.existsSync(path.join(dir, '.agent-server.json'))) return `openai-skills:${s.name}`
+  return `codex-user:${s.name}`
+}
+
+/** 计算需要在本次对话中禁用的技能(按路径选择) */
+export function skillsToDisable(all: SkillMetadata[], selected: string[] | 'all', codexHome: string, localPlugin: string): { path: string; enabled: false }[] {
+  const wanted = selected === 'all' ? null : new Set(selected.map(s => (s.includes(':') ? s : `${localPlugin}:${s}`)))
+  return all
+    .filter(s => ALWAYS_DISABLED.has(s.name) || (wanted !== null && !wanted.has(skillDisplayId(s, codexHome))))
+    .map(s => ({ path: s.path, enabled: false as const }))
+}

+ 208 - 0
apps/server/src/services/codex-skills.ts

@@ -0,0 +1,208 @@
+// Codex 官方技能:来自 OpenAI 官方仓库 github.com/openai/skills(ADR-0016)。
+// 目录 = 仓库在某个提交上的快照(缓存在数据目录);安装 = 把技能复制到服务专用 CODEX_HOME/skills/<name>,
+// 并写入标记文件记录来源、提交与许可证。Codex 会自动加载 CODEX_HOME/skills 下的技能。
+import { execFile } from 'node:child_process'
+import fs from 'node:fs'
+import path from 'node:path'
+import { promisify } from 'node:util'
+import type { LicenseKind } from '@agent-server/shared'
+import { config } from '../config'
+import { AppError, badRequest, notFound } from '../lib/errors'
+import { codexHome } from '../providers/codex/cli'
+
+const execFileP = promisify(execFile)
+
+export const OPENAI_SKILLS_REPO = 'openai/skills'
+const MARKER = '.agent-server.json'
+const CURATED = 'skills/.curated'
+
+export interface CodexCatalogSkill {
+  name: string
+  description: string
+  license: LicenseKind
+  licenseName: string | null
+  installed: boolean
+  installedSha: string | null
+  /** 已安装版本与当前目录快照不同 */
+  updatable: boolean
+}
+
+export interface CodexSkillCatalog {
+  repo: string
+  sha: string | null
+  fetchedAt: string | null
+  skills: CodexCatalogSkill[]
+  /** CODEX_HOME/skills 下不是由 AgentServer 安装的技能(只展示,不管理) */
+  unmanaged: string[]
+}
+
+interface Marker { source: string; path: string; sha: string; installedAt: string; license: string | null }
+
+type Fetcher = (url: string) => Promise<Response>
+
+export class CodexSkillService {
+  constructor(
+    private audit: (summary: string) => void,
+    private fetcher: Fetcher = (url: string) => fetch(url, { headers: { 'user-agent': 'agent-server', accept: 'application/vnd.github+json' } }),
+    private roots = { catalog: path.join(config.dataDir, 'data', 'skill-catalogs', 'openai-skills'), skills: path.join(codexHome(), 'skills') },
+  ) {}
+
+  private get currentFile() {
+    return path.join(this.roots.catalog, 'current.json')
+  }
+
+  private current(): { sha: string; fetchedAt: string } | null {
+    try {
+      return JSON.parse(fs.readFileSync(this.currentFile, 'utf8')) as { sha: string; fetchedAt: string }
+    } catch {
+      return null
+    }
+  }
+
+  /** 拉取官方仓库 main 分支的最新提交并缓存快照 */
+  async refresh(): Promise<CodexSkillCatalog> {
+    const r = await this.fetcher(`https://api.github.com/repos/${OPENAI_SKILLS_REPO}/commits/main`)
+    if (!r.ok) throw new AppError(502, 'catalog_fetch_failed', `无法获取 ${OPENAI_SKILLS_REPO} 的最新版本(HTTP ${r.status})`)
+    const sha = ((await r.json()) as { sha: string }).sha
+    if (!/^[0-9a-f]{40}$/.test(sha)) throw new AppError(502, 'catalog_fetch_failed', '官方仓库返回的提交号格式不正确')
+    const dir = path.join(this.roots.catalog, sha)
+    if (!fs.existsSync(path.join(dir, CURATED))) {
+      const tar = await this.fetcher(`https://codeload.github.com/${OPENAI_SKILLS_REPO}/tar.gz/${sha}`)
+      if (!tar.ok) throw new AppError(502, 'catalog_fetch_failed', `下载技能仓库失败(HTTP ${tar.status})`)
+      fs.mkdirSync(dir, { recursive: true })
+      const file = path.join(this.roots.catalog, `${sha}.tar.gz`)
+      fs.writeFileSync(file, Buffer.from(await tar.arrayBuffer()))
+      try {
+        // 只解压技能目录;--strip-components 去掉 GitHub 打包时加的顶层目录
+        await execFileP('tar', ['-xzf', file, '-C', dir, '--strip-components=1', '--no-same-owner', '--wildcards', '*/skills/.curated/*', '*/skills/.system/*'])
+      } finally {
+        fs.rmSync(file, { force: true })
+      }
+    }
+    fs.writeFileSync(this.currentFile, JSON.stringify({ sha, fetchedAt: new Date().toISOString() }))
+    this.audit(`更新 Codex 官方技能目录:${OPENAI_SKILLS_REPO}@${sha.slice(0, 8)}`)
+    return this.catalog()
+  }
+
+  catalog(): CodexSkillCatalog {
+    const cur = this.current()
+    const installed = this.installedMarkers()
+    const skills: CodexCatalogSkill[] = []
+    if (cur) {
+      const base = path.join(this.roots.catalog, cur.sha, CURATED)
+      for (const name of safeDirs(base)) {
+        const md = readText(path.join(base, name, 'SKILL.md'))
+        if (!md) continue
+        const fm = frontmatter(md)
+        const lic = detectLicense(path.join(base, name))
+        const m = installed.get(name)
+        skills.push({
+          name, description: fm.description ?? '', license: lic.kind, licenseName: lic.name,
+          installed: !!m, installedSha: m?.sha ?? null, updatable: !!m && m.sha !== cur.sha,
+        })
+      }
+    }
+    // 已安装但不在当前目录快照中的技能也要列出,便于卸载
+    for (const [name, m] of installed) {
+      if (!skills.some(s => s.name === name)) {
+        skills.push({ name, description: '(当前目录中已不存在)', license: 'unknown', licenseName: m.license, installed: true, installedSha: m.sha, updatable: false })
+      }
+    }
+    const unmanaged = safeDirs(this.roots.skills).filter(n => !installed.has(n))
+    return { repo: OPENAI_SKILLS_REPO, sha: cur?.sha ?? null, fetchedAt: cur?.fetchedAt ?? null, skills: skills.sort((a, b) => a.name.localeCompare(b.name)), unmanaged }
+  }
+
+  install(name: string): CodexSkillCatalog {
+    const cur = this.current()
+    if (!cur) throw badRequest('请先更新技能目录')
+    if (!/^[\w.-]+$/.test(name)) throw badRequest('技能名称格式不正确')
+    const src = path.join(this.roots.catalog, cur.sha, CURATED, name)
+    if (!fs.existsSync(path.join(src, 'SKILL.md'))) throw notFound(`官方技能 ${name}`)
+    const dest = path.join(this.roots.skills, name)
+    if (fs.existsSync(dest) && !fs.existsSync(path.join(dest, MARKER))) throw badRequest(`CODEX_HOME/skills/${name} 已存在且不是由 AgentServer 安装的,请先手动处理`)
+    // 只允许安装开源许可(Apache-2.0 / MIT)的技能;专有许可、第三方条款、无法识别的许可都拒绝
+    const lic = detectLicense(src)
+    if (lic.kind !== 'open') throw badRequest(`技能 ${name} 的许可为"${lic.name ?? '无法识别'}",只能安装开源许可(Apache-2.0 / MIT)的技能`)
+    fs.mkdirSync(this.roots.skills, { recursive: true, mode: 0o700 })
+    const tmp = `${dest}.installing`
+    fs.rmSync(tmp, { recursive: true, force: true })
+    fs.cpSync(src, tmp, { recursive: true })
+    const marker: Marker = { source: OPENAI_SKILLS_REPO, path: `${CURATED}/${name}`, sha: cur.sha, installedAt: new Date().toISOString(), license: lic.name }
+    fs.writeFileSync(path.join(tmp, MARKER), JSON.stringify(marker, null, 2))
+    fs.rmSync(dest, { recursive: true, force: true })
+    fs.renameSync(tmp, dest)
+    this.audit(`安装 Codex 官方技能 ${name}(${OPENAI_SKILLS_REPO}@${cur.sha.slice(0, 8)},${lic.name ?? '许可证未知'})`)
+    return this.catalog()
+  }
+
+  uninstall(name: string): CodexSkillCatalog {
+    const dest = path.join(this.roots.skills, name)
+    if (!/^[\w.-]+$/.test(name) || !fs.existsSync(path.join(dest, MARKER))) throw notFound(`由 AgentServer 安装的技能 ${name}`)
+    fs.rmSync(dest, { recursive: true, force: true })
+    this.audit(`卸载 Codex 官方技能 ${name}`)
+    return this.catalog()
+  }
+
+  /** 已安装技能的名称(用于 Profile 的技能 ID:openai-skills:<name>) */
+  installedNames(): string[] {
+    return [...this.installedMarkers().keys()]
+  }
+
+  private installedMarkers(): Map<string, Marker> {
+    const out = new Map<string, Marker>()
+    for (const name of safeDirs(this.roots.skills)) {
+      try {
+        out.set(name, JSON.parse(fs.readFileSync(path.join(this.roots.skills, name, MARKER), 'utf8')) as Marker)
+      } catch {
+        // 不是由 AgentServer 安装的
+      }
+    }
+    return out
+  }
+}
+
+function safeDirs(dir: string): string[] {
+  try {
+    return fs.readdirSync(dir, { withFileTypes: true }).filter(d => d.isDirectory() && !d.name.startsWith('.') && !d.name.endsWith('.installing')).map(d => d.name)
+  } catch {
+    return []
+  }
+}
+
+function readText(f: string): string | null {
+  try {
+    return fs.readFileSync(f, 'utf8')
+  } catch {
+    return null
+  }
+}
+
+function frontmatter(md: string): Record<string, string> {
+  const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(md)
+  const out: Record<string, string> = {}
+  for (const line of m?.[1]?.split(/\r?\n/) ?? []) {
+    const kv = /^([A-Za-z_-]+):\s*(.*)$/.exec(line)
+    if (kv) out[kv[1]!] = kv[2]!.replace(/^["']|["']$/g, '').trim()
+  }
+  return out
+}
+
+function detectLicense(dir: string): { kind: LicenseKind; name: string | null } {
+  const f = safeFiles(dir).find(n => /^licen[cs]e/i.test(n))
+  const text = f ? readText(path.join(dir, f))?.slice(0, 4000) ?? '' : ''
+  if (!text) return { kind: 'unknown', name: null }
+  if (/Apache License/i.test(text)) return { kind: 'open', name: 'Apache-2.0' }
+  if (/MIT License|Permission is hereby granted, free of charge/i.test(text)) return { kind: 'open', name: 'MIT' }
+  if (/proprietary|All rights reserved|internal evaluation/i.test(text)) return { kind: 'restricted', name: '专有许可' }
+  // 例如 Figma 技能:"governed by the Figma Developer Terms"
+  if (/governed by/i.test(text)) return { kind: 'restricted', name: '第三方条款' }
+  return { kind: 'unknown', name: null }
+}
+
+function safeFiles(dir: string): string[] {
+  try {
+    return fs.readdirSync(dir)
+  } catch {
+    return []
+  }
+}

+ 107 - 0
apps/server/test/codex-skills.test.ts

@@ -0,0 +1,107 @@
+import { execFileSync } from 'node:child_process'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { skillDisplayId, skillsToDisable } from '../src/providers/codex/skills'
+import { CodexSkillService } from '../src/services/codex-skills'
+
+const SHA1 = 'a'.repeat(40)
+const SHA2 = 'b'.repeat(40)
+
+/** 构造一个与 GitHub codeload 格式相同的仓库快照(顶层目录 skills-<sha>/) */
+function tarball(sha: string, skills: Record<string, string>): Buffer {
+  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'repo-'))
+  const top = path.join(root, `skills-${sha}`)
+  for (const [name, license] of Object.entries(skills)) {
+    const d = path.join(top, 'skills/.curated', name)
+    fs.mkdirSync(d, { recursive: true })
+    fs.writeFileSync(path.join(d, 'SKILL.md'), `---\nname: "${name}"\ndescription: "${name} skill"\n---\nbody\n`)
+    fs.writeFileSync(path.join(d, 'LICENSE.txt'), license)
+  }
+  fs.mkdirSync(path.join(top, 'skills/.system/imagegen'), { recursive: true })
+  fs.writeFileSync(path.join(top, 'README.md'), 'x')
+  const out = path.join(root, 'repo.tar.gz')
+  execFileSync('tar', ['-czf', out, '-C', root, `skills-${sha}`])
+  return fs.readFileSync(out)
+}
+
+describe('CodexSkillService(openai/skills)', () => {
+  const base = fs.mkdtempSync(path.join(os.tmpdir(), 'cxskills-'))
+  const roots = { catalog: path.join(base, 'catalog'), skills: path.join(base, 'codex-home/skills') }
+  let sha = SHA1
+  const repos: Record<string, Buffer> = {
+    [SHA1]: tarball(SHA1, { pdf: 'Apache License\nVersion 2.0', secret: 'PROPRIETARY AND CONFIDENTIAL. All rights reserved.', figma: 'Use of these Figma skills is governed by the Figma Developer Terms' }),
+    [SHA2]: tarball(SHA2, { pdf: 'Apache License\nVersion 2.0', screenshot: 'MIT License\nPermission is hereby granted, free of charge' }),
+  }
+  const audits: string[] = []
+  const fetcher = async (url: string) => {
+    if (url.endsWith('/commits/main')) return new Response(JSON.stringify({ sha }))
+    const s = url.split('/').pop()!
+    return repos[s] ? new Response(repos[s]) : new Response('nf', { status: 404 })
+  }
+  const svc = new CodexSkillService(s => audits.push(s), fetcher, roots)
+
+  it('获取目录、识别许可证;安装后写入标记,专有许可拒绝安装', async () => {
+    expect(svc.catalog().sha).toBeNull()
+    const cat = await svc.refresh()
+    expect(cat.sha).toBe(SHA1)
+    expect(cat.skills.map(s => [s.name, s.license, s.licenseName])).toEqual([['figma', 'restricted', '第三方条款'], ['pdf', 'open', 'Apache-2.0'], ['secret', 'restricted', '专有许可']])
+    expect(() => svc.install('secret')).toThrow(/专有许可/)
+    expect(() => svc.install('figma')).toThrow(/第三方条款/)
+
+    const after = svc.install('pdf')
+    expect(after.skills.find(s => s.name === 'pdf')).toMatchObject({ installed: true, installedSha: SHA1, updatable: false })
+    expect(fs.existsSync(path.join(roots.skills, 'pdf/SKILL.md'))).toBe(true)
+    const marker = JSON.parse(fs.readFileSync(path.join(roots.skills, 'pdf/.agent-server.json'), 'utf8'))
+    expect(marker).toMatchObject({ source: 'openai/skills', sha: SHA1, license: 'Apache-2.0' })
+    expect(svc.installedNames()).toEqual(['pdf'])
+  })
+
+  it('目录更新后可以升级已安装技能;只能卸载自己安装的技能', async () => {
+    sha = SHA2
+    const cat = await svc.refresh()
+    expect(cat.skills.find(s => s.name === 'pdf')).toMatchObject({ installed: true, updatable: true })
+    expect(svc.install('pdf').skills.find(s => s.name === 'pdf')).toMatchObject({ installedSha: SHA2, updatable: false })
+
+    fs.mkdirSync(path.join(roots.skills, 'mine'), { recursive: true })
+    fs.writeFileSync(path.join(roots.skills, 'mine/SKILL.md'), '---\nname: mine\n---\n')
+    expect(svc.catalog().unmanaged).toEqual(['mine'])
+    expect(() => svc.uninstall('mine')).toThrow()
+    expect(() => svc.install('mine')).toThrow()
+
+    svc.uninstall('pdf')
+    expect(fs.existsSync(path.join(roots.skills, 'pdf'))).toBe(false)
+    expect(audits.some(a => a.includes('安装 Codex 官方技能 pdf'))).toBe(true)
+  })
+
+  it('拒绝不安全的名称', () => {
+    expect(() => svc.install('../etc')).toThrow(/格式不正确/)
+  })
+})
+
+describe('Codex 技能 ID 与禁用规则', () => {
+  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cxhome-'))
+  fs.mkdirSync(path.join(home, 'skills/pdf'), { recursive: true })
+  fs.writeFileSync(path.join(home, 'skills/pdf/.agent-server.json'), '{}')
+  fs.mkdirSync(path.join(home, 'skills/mine'), { recursive: true })
+  const all = [
+    { name: 'imagegen', scope: 'system' as const, path: '/sys/imagegen/SKILL.md' },
+    { name: 'skill-installer', scope: 'system' as const, path: '/sys/skill-installer/SKILL.md' },
+    { name: 'pdf', scope: 'user' as const, path: path.join(home, 'skills/pdf/SKILL.md') },
+    { name: 'mine', scope: 'user' as const, path: path.join(home, 'skills/mine/SKILL.md') },
+    { name: 'agent-skills:report-conventions', scope: 'user' as const, path: '/srv/skills/report-conventions/SKILL.md' },
+  ].map(s => ({ ...s, description: '', enabled: true, pluginId: null }))
+
+  it('展示 ID', () => {
+    expect(all.map(s => skillDisplayId(s, home))).toEqual([
+      'codex-system:imagegen', 'codex-system:skill-installer', 'openai-skills:pdf', 'codex-user:mine', 'agent-skills:report-conventions',
+    ])
+  })
+
+  it('all 只禁用始终禁用的技能;列表模式禁用未选中的技能', () => {
+    expect(skillsToDisable(all, 'all', home, 'agent-skills').map(d => d.path)).toEqual(['/sys/skill-installer/SKILL.md'])
+    const off = skillsToDisable(all, ['openai-skills:pdf', 'report-conventions'], home, 'agent-skills').map(d => path.basename(path.dirname(d.path)))
+    expect(off).toEqual(['imagegen', 'skill-installer', 'mine'])
+  })
+})

+ 2 - 0
apps/server/test/codex.test.ts

@@ -58,6 +58,8 @@ 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 }] })
   })
 
   it('拒绝审批时工具标记为 denied', async () => {

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

@@ -30,6 +30,11 @@ readline.createInterface({ input: process.stdin }).on('line', async line => {
       if (!threads.has(params.threadId)) return send({ id, error: { code: -32000, message: 'thread not found' } })
       return reply({ thread: { id: params.threadId } })
     case 'turn/interrupt': return reply({})
+    case 'skills/extraRoots/set': globalThis.extraRoots = params.extraRoots; return reply({})
+    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 },
+    ] }] })
     case 'turn/start': {
       const threadId = params.threadId, turnId = 'turn-1'
       reply({ turn: { id: turnId, status: 'inProgress' } })

+ 1 - 0
docs/README.md

@@ -44,6 +44,7 @@ docs/
 | [0013](adr/0013-official-skills-via-plugins.md) | 官方技能通过 Claude Code 插件机制使用 | Accepted |
 | [0014](adr/0014-admin-claude-login.md) | 在后台完成 Claude 登录 | Accepted |
 | [0015](adr/0015-api-key-profiles.md) | API Key 只能使用指定的 Profile;权限模式由 Profile 决定 | Accepted |
+| [0016](adr/0016-official-skill-repos-per-engine.md) | 每个引擎接入各自厂商的官方技能仓库 | Accepted |
 
 ## 调研
 

+ 25 - 0
docs/adr/0016-official-skill-repos-per-engine.md

@@ -0,0 +1,25 @@
+# ADR-0016 每个引擎接入各自厂商的官方技能仓库
+
+- 状态:Accepted · 日期:2026-10-23 · 补充 ADR-0013
+
+## 决策
+官方技能不交叉使用,每个引擎只用自己厂商的官方仓库,在后台"技能与插件"页面按引擎分页管理:
+
+| 引擎 | 官方仓库 | 接入方式 |
+|---|---|---|
+| Claude | [anthropics/skills](https://github.com/anthropics/skills) | Claude Code 插件机制(`claude plugin`),按路径加载已启用插件声明的技能(ADR-0013) |
+| Codex | [openai/skills](https://github.com/openai/skills) | 服务端获取仓库在某个提交上的快照(缓存在数据目录),把选中的技能复制到服务专用 `CODEX_HOME/skills/<name>` 并写标记文件(来源、提交、许可证);Codex 自动加载 |
+
+- 本地技能目录(自写技能,如 report-conventions)两个引擎共用:Claude 以插件方式加载,Codex 通过 `skills/extraRoots/set` 加入
+- 按 Profile 选择技能:Claude 用 SDK 的 `skills` 列表;Codex 在每次对话的 config 中用 `skills.config` 按路径禁用未选中的技能
+- Codex 系统技能 `skill-installer`、`skill-creator`、`plugin-creator` 始终禁用(会联网下载或写入 Codex 自身目录)
+- 已安装技能的脚本与资源在 Codex 沙箱中只读可见
+
+## 许可约束
+- Codex 侧只允许安装**开源许可(Apache-2.0 / MIT)**的技能;专有许可、第三方条款(如 Figma 技能"governed by the Figma Developer Terms")、无法识别的许可都拒绝安装
+- 不使用 Codex 桌面版运行时中的插件:其中 spreadsheets、presentations 依赖 `@oai/artifact-tool`,该组件为专有许可且"仅限内部评估测试、禁止生产使用";documents、pdf 插件虽为 MIT,但只能通过桌面版(code-server 中的扩展)获取,不作为服务依赖
+- openai/skills 中的 Word(doc)技能已于 2026-05-01 被 OpenAI 移出公开仓库,Codex 处理 Word 使用自写技能 + python-docx
+
+## 后果
+- Codex 目前可用的官方文档技能为 pdf(Apache-2.0,依赖 reportlab / pdfplumber / pypdf / poppler,运行环境已具备)
+- 官方仓库更新后,在后台"更新目录"即可看到并升级已安装技能(固定提交号,可追溯)

+ 20 - 1
packages/shared/src/api.ts

@@ -165,7 +165,8 @@ export interface SkillInfo {
   name: string
   description: string
   plugin: string
-  source: 'local' | 'plugin'
+  /** local 本地技能目录;plugin Claude 插件;official 从官方仓库安装(Codex);system 引擎自带 */
+  source: 'local' | 'plugin' | 'official' | 'system'
   license: LicenseKind
 }
 
@@ -215,3 +216,21 @@ export interface ApiKeyProfiles {
   allowedProfileIds: string[]
   defaultProfileId: string
 }
+
+export interface CodexCatalogSkillDto {
+  name: string
+  description: string
+  license: LicenseKind
+  licenseName: string | null
+  installed: boolean
+  installedSha: string | null
+  updatable: boolean
+}
+
+export interface CodexSkillCatalogDto {
+  repo: string
+  sha: string | null
+  fetchedAt: string | null
+  skills: CodexCatalogSkillDto[]
+  unmanaged: string[]
+}