青雲的博客
拆开 Codex 第四部:外部能力怎样进入同一套治理 第 24 章

一个 Plugin 怎样变成一组可安装能力

从固定版本的 PluginManifest、执行环境绑定的 ResolvedPlugin,到本地安装、能力加载和 PluginsManager 的 PluginLoadOutcome,拆开一个 Plugin 包怎样被承认、投影和限制;再追踪 executor-selected Plugin 怎样经由已注册的 McpServerContributor 进入 ExtensionRegistry。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

第 23 章把 Code Mode 收回同一张 ToolRouter。Plugin 看起来像是另一个开关:配置里写一个 name@marketplace,目录里放一份 plugin.json,然后就多出 Skills、MCP、应用连接器和 hooks。但源码并没有一个“读取 manifest,立即获得全部能力”的函数。

固定版本里至少有两条装配线:

  1. 执行环境装配线SelectedCapabilityRoot 交给 ExecutorPluginProvider,通过指定环境的 ExecutorFileSystem 读取 manifest,得到保留资源权威的 ResolvedPlugin。它是惰性描述;宿主注册的 MCP contributor 会在 step projection 时消费它,不表示 manifest 自己变成 executor。
  2. 本地安装装配线PluginsManagerPluginStore 找到已安装版本,调用 core-plugins::loader,按每个 configured package 构造一份 LoadedPlugin,再形成 PluginLoadOutcome。Skills、MCP、apps 和 hooks 都填进这一个 package 级结果;它是运行时投影,不是 ResolvedPlugin 的自动升级版。

两条线共享 codex-plugin 的 manifest 数据模型,却不共享同一个“激活”动作。执行环境这条线还有一个明确的 host bridge:typed contributor 进入 ExtensionRegistry 后,按 thread-selected roots 解析并贡献 MCP/package state。这个区别是本章的主线:先确定包由谁拥有,再谈包里声明了什么。

flowchart TB
  accTitle: Plugin 从来源到能力投影的两条装配线
  accDescr: host 先安装 executor-plugin contributor 并冻结 ExtensionRegistry;step projection 再从 registry 调 contributor,读取 thread-selected roots,经 ExecutorPluginProvider 解析为带环境权威的 ResolvedPlugin,最后产生 selected MCP 和 package contributions;本地安装路径独立形成 LoadedPlugin 与 PluginLoadOutcome
  HOST_INSTALL["app-server host"] --> CONTRIBUTOR_REGISTRATION["install_executor_plugins"]
  CONTRIBUTOR_REGISTRATION --> REGISTRY["ExtensionRegistry"]
  REGISTRY --> CONTRIBUTOR_CALL["McpServerContributor::contribute"]
  CONTRIBUTOR_CALL --> SELECTED_ROOTS["thread-selected roots"]
  SELECTED_ROOTS --> PROVIDER["ExecutorPluginProvider::resolve_bound"]
  PROVIDER --> RESOLVED["ResolvedPlugin + ExecutorFileSystem"]
  RESOLVED --> CONTRIBUTIONS["SelectedPlugin / SelectedPluginPackage"]
  SOURCE["marketplace source"] --> ADMIT["MarketplacePolicy\nadmission"]
  ADMIT --> STORE["PluginStore\ncache/<marketplace>/<plugin>/<version>"]
  STORE --> CONFIG["config.toml\nenabled = true/false"]
  CONFIG --> MANAGER["PluginsManager"]
  MANAGER --> LOADER["load_plugin + capability loaders"]
  LOADER --> LOADED["LoadedPlugin\nresources + disabled + error"]
  LOADED --> OUTCOME["PluginLoadOutcome\neffective projections"]
  OUTCOME --> PLAN["Skills / MCP / apps / hooks\n各自的后续 owner"]

先确认资源由谁读取

SelectedCapabilityRoot 只授权一个根

协议层的 SelectedCapabilityRoot 只有一个稳定 id 和一个 location。固定版本的 location 是 Environment { environment_id, path }:路径属于某个执行环境,而不是调用方当前进程可以随便打开的本机路径。

本节源码依据(1 处)

ExecutorPluginProvider 先用 environment_idEnvironmentManager,再拿这个环境提供的 ExecutorFileSystem。它检查根是目录,按固定顺序寻找 .codex-plugin/plugin.json.claude-plugin/plugin.json,通过该文件系统读内容,最后才构造 descriptor。环境不存在时直接返回 UnavailableEnvironment,不会退回宿主机文件系统;没有 manifest 的普通 capability root 返回 Ok(None),它不被强行当成 Plugin。

本节源码依据(2 处)

这条边界有两个实际后果:

  • 一个只有 skills/、没有 manifest 的目录可以是 standalone capability,但不是 Plugin;
  • 如果首选 .codex-plugin/plugin.json 存在却是坏 JSON,provider 会报告 parse error,不会悄悄跳到备用 .claude-plugin/plugin.json
本节源码依据(1 处)

ResolvedPlugin 是惰性 descriptor

codex-plugin 把 manifest 的资源字段做成泛型 PluginManifest<Resource>skills 是多个资源根,mcp_servers 可以是一个路径或内联 JSON,apps 是一个路径,hooks 可以是路径列表或内联 hooks;interface 里的图标、截图和默认 prompt 属于展示/模型元数据,不是执行器。

本节源码依据(1 处)

ResolvedPlugin 把这个泛型参数换成 PluginResourceLocator::Environment { environment_id, path },并保存 selected_root_id、package root、manifest path 和已映射 manifest。它没有 startinvokeregister 方法。真正的安全动作发生在 from_environment:manifest path 和每个路径资源都必须 starts_with(root),越界直接是 ResourceOutsideRoot;内联 MCP JSON 和内联 hooks 没有路径,因此保持原值。

本节源码依据(1 处)

这里的“authority-bound”不是给文件路径加一个字符串前缀。后续读取方拿到 ResolvedExecutorPlugin 时还会同时拿到同一个 ExecutorFileSystem;descriptor 只保留“这项资源属于哪个环境、位于哪里”的事实。这样,MCP provider 或 connector provider 可以按自己的协议读取资源,却不能把它换成未经授权的宿主路径。

安装、启用、加载不是一个状态

package key 先成为安全的 cache key

本地安装使用 <plugin>@<marketplace> 作为 PluginId。解析要求恰好从最后一个 @ 分出两段,两个 path segment 只允许 ASCII 字母、数字、_-。这个检查同时保护配置键和 cache 目录,避免 marketplace 或 plugin 名称把目录层级带出去。

本节源码依据(1 处)

安装前还有一层 marketplace admission。PluginsManager::resolve_installable_plugin 先从 marketplace 找到可安装项,再让 MarketplacePolicy::validate_install 检查受限环境中的来源、配置名称和实际 marketplace root 是否一致。对普通 user marketplace,限制开启时未加入配置、来源不在 allowlist 或路径不匹配的 marketplace 到不了 PluginStore;managed / curated marketplace 走固定的受管身份校验,不能用普通 user marketplace 的条件反推它。

本节源码依据(2 处)

install_resolved_plugin 随后 materialize source,把包放进 plugins/cache/<marketplace>/<plugin>/<version>,完成后再把同一个 key 写成 enabled = true。这两个动作的顺序很重要:配置里的 enabled 不是下载动作;它是在本地包成功写入后才持久化的。

本节源码依据(1 处)

PluginStore 的 active version 也不是一个模糊的“最新目录”:它过滤非法 version segment,优先选择 local,否则按版本排序取最后一个。卸载则删除该 plugin id 的 base root;配置和 cache 的清理由上层 manager 分别负责。

本节源码依据(2 处)

因此要把四个状态分开:

状态谁拥有证明什么不证明什么
admittedMarketplacePolicy / marketplace resolver来源和 marketplace 通过当前策略包已经下载或可解析
installedPluginStorecache 里存在一个可选 active version配置已启用、manifest 可加载
enabledconfig.toml 的 plugin entry下一次 load 会尝试这个 package当前 turn 已经暴露工具
activeLoadedPlugin::is_active()enabled && error.is_none()每项声明都已被模型看到或执行

manifest 只声明包,不替每个能力执行

解析器先把字符串变成 root 内资源

host loader 在 .codex-plugin/plugin.json 和兼容路径中找到文件后,先读 JSON,再把资源字符串解析成 PathUri。固定版本只接受以 ./ 开头的相对路径;./ 本身、..、绝对路径和 Windows drive/root 都被丢弃并记录 warning。解析结果还要再次确认位于 plugin root 之下。

本节源码依据(2 处)

被丢弃的字段在后续看起来和“没有声明”相同,这里还有一个不太直觉的结果:Skills、MCP、apps 或 hooks loader 可能转而检查 package 内的默认 skills/.mcp.json.app.jsonhooks/hooks.json。路径校验阻止越界,但“坏声明”不一定意味着“这一类能力完全为空”。

这一步只建立“可以去哪里读”的事实。它没有打开 SKILL.md、启动 MCP process、连接 app connector,也没有把 hook command 交给 shell。后面的 loader 必须逐项成功,才会把资源放进 runtime projection。

四类 runtime resource 各有自己的 loader

core-plugins::loader::load_plugin 在 manifest 成功后按 scope 装载能力。AllCapabilities 会读 Skills、MCP 和 apps;hooks 在 scope 处理完后单独解析。HooksOnly 是一个有意的窄路径:PluginsManager::plugin_hooks_for_layer_stack 可以只拿启用插件的 hook sources,而不触碰其他能力。

本节源码依据(1 处)
manifest 字段固定版本的 loader得到的中间结果后续 owner
skillsload_plugin_skillsskill metadata、plugin roots、disabled paths、snapshot errors第 20 章:Skills 不是工具:从目录发现到渐进注入 的 Skills service
mcpServersload_plugin_mcp_servers_from_manifestHashMap<String, McpServerConfig>,应用 plugin-level policy第 21 章:外部 MCP Server 的工具怎样进入 Codex 的 MCP runtime
appsload_plugin_apps去掉空 connector id 后的 AppDeclarationapps/connectors 的 auth 路由
hooksload_plugin_hooks带 source path、data root 的 PluginHookSource 或 warninghooks runtime

Skills 只返回发现结果

manifest 没写 skills 时,loader 只在 package root 下寻找默认 skills/;写了路径则排序、去重后使用这些 roots。它把 root 交给统一 Skills loader,附带 plugin id、namespace 和 plugin root,并根据 product restriction 与 SkillConfigRules 计算 disabled paths。这里不会把正文直接注入当前 prompt;第 20 章已经追完 metadata catalog 与 explicit body injection。

这里还藏着一个保守信号。load_plugin_skill_inventory 会保留 Skills loader 的 had_errors;解析成 ResolvedPluginSkills 后,ResolvedPluginSkills::has_enabled_skills 实际计算的是 had_errors || contains_enabled_skill(...)。一旦扫描出错,系统就不能确认 package 里没有 enabled Skill,于是把 has_enabled_skills 留为 true。这个 true 表示 inventory 不完整,需要保守对待;它不代表某个 Skill 已经加载成功,更不证明正文进入了 prompt。

本节源码依据(1 处)

MCP 可以是内联对象,也可以是文件

mcpServers 如果是 object,会在 manifest 中解析;如果是 path,或者字段不存在,则按 package root 读取声明文件,默认文件名是 .mcp.json。每个 server 还能应用 config 中的 enabled、disabled tools 和 approval policy。解析失败只记录 warning 并跳过坏 server;它不会把整个 PluginLoadOutcome 变成全局 error。

本节源码依据(2 处)

apps 是 connector declaration,不是本地 handler

这里有两条名字相近、去重时机不同的入口。文件路径走 load_apps_from_paths:它读取 .app.json、解析成 AppDeclaration { name, connector_id, category },过滤空 connector id,但不去重,同一文件里的重复 declaration 会原样留下。内存 JSON 走 plugin_app_declarations_from_value:它同样过滤空 id,随后用 seen_connector_ids 当场去重,只保留第一次出现的 connector id。不能用 value helper 的行为解释 file loader。

本节源码依据(2 处)

离开单个 loader 后,公共的 app_connector_ids_from_declarations 才按 connector id 去重,并保留第一次出现的顺序。PluginLoadOutcome::effective_apps 用的就是这个 helper,所以它能同时消掉同一插件和跨插件的重复 id。这个投影只告诉后续路由“有哪些 connector declaration”;具体 app service 是否可用,仍取决于 auth mode 和应用路由。

本节源码依据(1 处)

hooks 会保留来源和 warning

hooks 可以来自 manifest path、path array、inline object,或没有字段时的默认 hooks/hooks.json。每个 source 同时保存 plugin root、plugin data root、相对来源路径和解析后的 hook events;读取或 JSON 解析失败只进入 hook_load_warnings。这让启动阶段可以报告“哪个包的哪个文件坏了”,而不是只给一个笼统的 Plugin error。

本节源码依据(1 处)

上面四个 loader 的共同点是“声明到中间结果”,不是“声明到执行”。例如 Skill script 仍要经过 Skills injection 的选择,MCP server 仍要经过 MCP connection、exposure 和 tool call,app declaration 仍要经过 connector route,hook source 还要进入 hooks runtime。Plugin 只负责把包边界和来源带到这些 owner 手里。

LoadedPlugin 是一份可失败的局部结果

单个插件失败,不拖垮整批加载

load_plugins_from_layer_stack_with_scope 先把配置与 remote-installed entries 合并,再按 config_name 排序逐个调用 load_plugin。每个 LoadedPlugin 都预先有 enabled、资源列表、hook load warnings 和 error: Option<String>。disabled 插件在最早的分支返回;启用但未安装、id 非法、root 不是目录、manifest 缺失或无效,都会写入 error 并返回这个插件的局部结果。

本节源码依据(2 处)

这就是第一个失败边界:error 不是“这个插件的某个工具失败”,而是“这份 package projection 没能建立”。相反,MCP 某个 server 的 parse warning 或 hooks 某个文件的 warning 仍然可以和同一个 Plugin 的其他能力一起留下。

PluginLoadOutcome 只暴露 active projection

PluginLoadOutcome 接收完整 Vec<LoadedPlugin<M>>,然后计算一份 capability summary。LoadedPlugin::is_active() 的条件只有 enabled && error.is_none();summary、effective skill roots、MCP servers、apps、hooks 都只遍历 active 插件。disabled 或 error 的插件仍可通过 plugins() 被诊断,但不会进入 effective projection。

本节源码依据(1 处)

capability summary 的能力字段只列 has_skills、MCP server names 与 app connector ids;同时还携带 config_name、解析后的 display_name 和经过 prompt-safe 处理的 description。只有 hooks 的 active 插件仍能提供 effective hook sources,却不会只因 hooks 存在而生成一条模型侧 capability summary。summary 是发现视图,不是 LoadedPlugin 的完整副本。

投影的细节也有确定的所有权:

  • effective_skill_roots 排序并去重;effective_plugin_skill_roots 还保留 plugin id、namespace 和 package root,供 Skills loader 追溯来源;
  • effective_mcp_servers 对重复名字使用第一次插入的配置,和前面的 duplicate warning 配合形成确定结果;
  • effective_apps 只抽 connector ids,再由后续 app route 决定可用性;
  • effective_plugin_hook_sources 和 warnings 只收 active 插件的结果。
本节源码依据(1 处)

PluginsManager 管的是 snapshot,不是一个全局开关

cache key 不含 auth,projection 才含 auth

PluginsManager 持有 PluginStore、loaded plugin cache、load semaphore、remote-installed cache 和当前 auth_mode。loaded cache 的 key 不含完整 AuthMode,但包含 configured_pluginsskill_config_rulesremote_global_catalog_active。后一个布尔值由 remote_plugin_enabled && AuthMode::uses_codex_backend 得到,所以“与 auth 无关”只能理解为:同一 backend 分类内可以复用已加载快照;remote plugin 开启时跨越 uses_codex_backend 边界会翻转 key,触发 reload。

本节源码依据(2 处)

plugins_for_config 的流程是:插件 feature 关闭就返回空 outcome;命中同一个 key 就复用已加载的 Vec<LoadedPlugin>;否则拿 semaphore,创建 PluginSkillSnapshots,调用 loader,记录每个插件的 load error,按 generation 写回 cache,最后调用 resolve_loaded_plugins_for_auth

本节源码依据(1 处)

auth projection 是运行时政策,不是 manifest 重写,而且发生在构造 PluginLoadOutcome 之前。apps_route_available 只有使用 Codex backend 的 auth 才为真;否则 apps 清空。如果插件 active 且有 app declarations,与 app 同名的 MCP server 会被删掉,以避免两条 route 同时暴露同一个 surface。cache 命中时复用的是加载快照,manager 仍会在返回 outcome 前重新做这一步;跨 backend 类别且 remote plugin 开启时则先 reload,再做 projection。

本节源码依据(1 处)

cache invalidation 也有 owner。clear_loaded_plugins_cache 增加 generation 并清掉 entry;加载完成时只有 generation 仍匹配才允许写回。这样 marketplace refresh 或配置变化不会让一个旧的异步加载结果覆盖新 snapshot。

本节源码依据(2 处)

disable 和 error 都停在 outcome 边界

plugins_enabled 是 manager 的总入口开关;它为 false 时直接返回 PluginLoadOutcome::default()。单个插件的 enabled = false 则只让该 LoadedPlugin 不 active,不会删除其他插件。error 同样只污染该 package 的 projection;manager 会记录 warning,其他配置项继续加载。

这意味着调用者需要先选择自己要的视图:

调用适合回答的问题是否包含 disabled/error 插件
PluginLoadOutcome::plugins()为什么这个 package 没有能力、root 在哪里、error 是什么是,诊断用
effective_skill_roots() / effective_plugin_skill_roots()哪些 Skills root 可以交给 Skills service
effective_mcp_servers()哪些 MCP config 能继续进入 MCP runtime
effective_apps()哪些 connector ids 通过 package 声明否,仍需 auth route
effective_plugin_hook_sources()哪些 hook source 可交给 hooks runtime
capability_summaries()给模型或 discoverable UI 的 active 能力摘要

Plugin 不是 ExtensionRegistry

这个名字最容易造成架构误读。ExtensionRegistryBuilder 是宿主启动时收集 typed contributors 的可变 registry:它分别保存 lifecycle、config、prompt、MCP server、tool 和 approval contributors,build() 后变成不可变 ExtensionRegistry。它接收的是 Rust trait object,不是某个 package root 上的 JSON 声明。

本节源码依据(3 处)

固定版本里的 Memories 和 Goals 正是这条 contributor 路径的例子:它们的 extension crate 实现 ContextContributorToolContributor 或 lifecycle contributors,再主动把实例放进 registry。Plugin manifest 没有 memorygoal 字段,也不会因为包里有一个同名目录就获得这些 contributor trait。

本节源码依据(2 处)

但 Extension 在这里不只是一条对照路径。App Server host 创建 builder 时会调用 codex_mcp_extension::install_executor_plugins;这个安装函数把 SelectedExecutorPluginMcpContributor 注册为 McpServerContributor,随后随 builder 一起构造成 ExtensionRegistry。因此 executor-selected Plugin 确实有一条正向入口:宿主注册的 McpServerContributor 消费 selected roots,并通过 ExtensionRegistry 参与 step projection;入口 owner 是 Rust contributor,不是 package 自行注册。

本节源码依据(2 处)

运行时,contributor 从 thread init 读取 Vec<SelectedCapabilityRoot>,用 ExecutorPluginProvider::resolve_bound 得到同时保留 ResolvedPlugin 与原 ExecutorFileSystem 的 descriptor,再加载 MCP server 和 connector metadata。它返回 McpServerContribution::SelectedPluginSelectedPluginPackage;即使 package 只有 Skills,后者也会保留 package 可见状态。这些 contribution 由 ExtensionRegistry 中已注册的 contributor 集合被逐一调用和投影。

本节源码依据(2 处)

边界仍然不能混。Plugin manifest 不是 ToolContributor,也不等于 native ToolExecutor;它只是这个特定 contributor 可以消费的数据来源之一。Memories、Goals 的 native tools 走 ToolContributor,executor-selected package 走 McpServerContributor,本地安装包则先形成 PluginLoadOutcome。三者共享 registry 或 manifest 词汇,却没有合并成一个通用 Plugin executor。

可复现实验:资源绑定,而不是能力执行

以下命令只在第四部“源码工作台”完成校准的 disposable archive 副本中运行,不在源码 checkout 上直接跑测试。先在同一个 shell 保留第四部脚本导出的 ARCHIVE_DIRARCHIVE_CODEX_RSrun_checked_test,再切到副本目录;否则 just 可能落到博客仓库或未校准的源码目录。最小的真实测试是:

: "${ARCHIVE_DIR:?run the Part 4 shared preparation first in this shell}"
: "${ARCHIVE_CODEX_RS:?the Part 4 preparation must export ARCHIVE_CODEX_RS}"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"
cd "$ARCHIVE_CODEX_RS"

just test --locked -p codex-plugin environment_descriptor_binds_every_manifest_resource

本次在由 rust-v0.144.6 archive 校准出的副本中的结果:

PASS ... codex-plugin::lib provider::tests::environment_descriptor_binds_every_manifest_resource
Summary ... 1 test run: 1 passed

这个测试构造一个同时声明 skills、MCP 文件、apps、hooks、composer icon、logo 和 screenshot 的 manifest,再调用 ResolvedPlugin::from_environment。断言检查的是每个资源都保留 environment_id 和对应路径;它没有启动 MCP,也没有读取 Skill 正文。这正好验证本章最容易被跳过的层:资源绑定先于能力执行。

本节源码依据(1 处)

同一个测试文件还有越界资源的拒绝用例:把 .mcp.json 放到 package root 之外,from_environment 返回 ResourceOutsideRoot。如果运行命令只得到 running 0 tests,那是过滤器或 target 选择错了,不能把零测试当成通过;本次运行确实执行了 1 个测试。

本节源码依据(1 处)

第四部在这里收束

到这里,PluginLoadOutcome 已经给当前运行时一份有 owner 的能力视图:active plugin 的 effective Skill roots、带 policy 的 MCP configs、connector ids、hook sources,以及每个 package 的 error 和 warnings。沿这些能力继续追执行路径时不能跳过它:

  • Skills service 接的是 root 和 snapshot,不是整个 Plugin object;
  • MCP runtime 接的是 server config,再自己建立连接和 exposure;
  • apps route 接的是 connector declaration,再按 auth 决定是否可用;
  • hooks runtime 接的是带来源的 hook source;
  • Memory、Goal 等 contributor 仍由 ExtensionRegistry 的显式安装路径拥有。

executor-selected package 是另一份 thread-scoped 投影:已注册的 McpServerContributor 从 selected roots 形成带 package attribution 的 server config 与 connector state,不先经过本地安装线的 PluginLoadOutcome。这两份投影都不能被重新解释成“整个 Plugin 就是 executor”。

最短的判断标准是:manifest 写了什么,只能说明包声明了什么;PluginLoadOutcome 暴露了什么,才说明当前配置和 auth 投影允许什么;真正执行什么,还要再看下一层的 executor。

第四部到这里结束。第 25 章:会话状态的三层边界会换掉观察对象,只检查真正进入 live History、rollout JSONL 与 SQLite metadata 的事实。本章得到的 roots、server configs、connector ids、hook sources 和 error state,不会因为属于当前 runtime 就自动成为会话记录。