Agent X-Ray
NotesSkillsAbout
Notes/源码拆解/Codex Harness/第13章

第13章:扩展面 —— MCP、技能、钩子、插件与 Code Mode

10 分钟 · 更新于 2026-09-01

第13章:扩展面 —— MCP、技能、钩子、插件与 Code Mode

dsh 的扩展方式是"往插件树里加一行配置,什么都能换"。Codex 的扩展方式是五条边界明确的受限通道,加上一层只有它自己能用的内部扩展平面。本章讲这六个面各自能做什么、不能做什么,以及一个容易被忽略的供应链防御。


一、五条对外通道的分工

通道加什么谁写运行位置
MCP工具、资源、提示第三方独立进程/远程
Skills提示词级的操作手册用户/团队无(就是 Markdown)
Hooks生命周期拦截用户子进程 / MCP
Plugins上面三者的打包分发第三方同上
Code Mode工具的组合调用方式——V8 沙箱

一句话概括边界:你可以给 Codex 加能力、加知识、加拦截,但你不能改它的循环、上下文装配、安全判定和会话模型。

这和 dsh 是根本区别(第 15 章会展开)。


二、Skills:一个 Markdown 文件

2.1 格式

技能就是一个带 YAML frontmatter 的 SKILL.md。解析器只认三个字段 [源码 skills/src/parser.rs:6]:

rust
struct SkillFrontmatter {
    name: Option<String>,
    description: Option<String>,
    metadata: SkillFrontmatterMetadata,   // 目前只有 short-description
}

约束 [源码 skills/src/parser.rs:4, 70]:

  • name 最长 64 字符,缺省时用目录名
  • description 必填——缺了直接报 MissingField("description")
  • metadata.short-description 可选

description 必填是有道理的:它是模型决定"要不要用这个技能"的唯一依据。没有描述的技能等于不存在。

2.2 一个很实在的容错

rust
Err(original_error) => match repair_frontmatter_scalar_fields(&frontmatter) {
    // Some third-party skills use prose like `description: Build for AWS: ECS`
    // or `argument-hint: <duration: e.g. 7d>`. Keep the repair line-oriented
    // so unrelated invalid YAML still surfaces.
    Some(repaired_frontmatter) => serde_yaml::from_str(&repaired_frontmatter).map_err(|_| original_error),
    None => Err(original_error),
}

[源码 skills/src/parser.rs:50]

description: Build for AWS: ECS 不是合法 YAML(第二个冒号让解析器懵了),但第三方技能里到处都是这种写法。Codex 的做法是:先按标准 YAML 解析,失败了再做一次面向行的标量字段修复,还失败才报原始错误。

注释里那句 "Keep the repair line-oriented so unrelated invalid YAML still surfaces"(把修复限制在行级,这样不相关的非法 YAML 仍会暴露)是关键——容错要有边界,不能把所有解析错误都吞掉

对本仓的直接参考 本仓 35+ 个 SKILL.md 用的是同一套 frontmatter 约定(name + description),CLAUDE.md 里也明确要求这两个字段保持英文以便触发匹配。Codex 的实现印证了这个判断:description 是模型的路由依据,它的质量直接决定技能能不能被触发。

2.3 四种作用域与策略

rust
pub enum SkillScope { User, Repo, System, Admin }

[源码 protocol/src/protocol.rs:3610]

用户级、仓库级、系统级、管理员级。Admin 的存在说明有企业分发场景——IT 管理员推下来的技能,用户不能改。

每个技能还可以带 policy [源码 skills/src/model.rs:24]:

rust
pub fn allows_implicit_invocation(&self) -> bool {
    self.policy.as_ref()
        .and_then(|policy| policy.allow_implicit_invocation)
        .unwrap_or(true)
}

allow_implicit_invocation = false 的技能只能被用户显式选中(第 3 章 Op::UserTurnskill 输入项),模型不能自己决定用它。

这正好对应本仓的 disable-model-invocation: trueobsidian-cli 技能就用了它)。两个项目独立做出了同一个设计。

技能还有 dependencies(依赖)和 interface(接口)字段,以及 matches_product_restriction(限定在某个产品里可用)。


三、Hooks:一个刻意兼容的接口

3.1 事件清单

[源码 hooks/src/schema.rs:100]

事件时机
SessionStart会话开始
UserPromptSubmit用户提交输入
PreToolUse工具执行前
PostToolUse工具执行后
Stop轮次准备结束
SubagentStop子 agent 结束

外加 hooks/src/events/ 下的 PreCompact / PostCompact / SessionEnd / PermissionRequest

熟悉吗?这就是 Claude Code 的钩子事件名。 第 1 章引过的那段兼容代码值得再看一遍 [源码 core/src/tools/hook_names.rs:31]:

rust
/// Returns the hook identity for file edits performed through `apply_patch`.
///
/// The serialized name remains `apply_patch` so logs and policies can key
/// off the actual Codex tool. `Write` and `Edit` are accepted as matcher
/// aliases for compatibility with hook configurations that describe edits
/// using Claude Code-style names.
pub(crate) fn apply_patch() -> Self {
    Self { name: "apply_patch".to_string(),
           matcher_aliases: vec!["Write".to_string(), "Edit".to_string()] }
}

/// `Agent` is accepted as a matcher alias for compatibility with hook
/// configurations that describe sub-agent creation using Claude Code-style names.
pub(crate) fn spawn_agent() -> Self {
    Self { name: "spawn_agent".to_string(), matcher_aliases: vec!["Agent".to_string()] }
}

而钩子引擎的类型名直接叫 ClaudeHooksEngine [源码 hooks/src/engine/dispatcher.rs:16]。

这个设计的分寸感值得学:payload 里的 tool_name 保持 Codex 自己的名字(apply_patch),只在匹配器上接受别名。 注释说明了原因——"prevents handlers from accidentally serializing a compatibility alias, such as Write, as the stable hook payload name"。兼容归兼容,不污染自己的数据契约。

可迁移的判断 ㉒ 兼容别人的配置格式时,只在"输入匹配"层兼容,不要让别人的命名渗进你的输出契约。

这条对本仓也适用:本仓同时维护 CLAUDE.md 和镜像的 AGENTS.md.claude/skills/ 和镜像的 .agents/skills/——用的是"生成镜像"而不是"双份真源",是同一种分寸。

3.2 钩子能干预到什么程度

PreToolUse 的返回结构 [源码 hooks/src/schema.rs:127]:

rust
struct PreToolUseCommandOutputWire {
    decision: Option<PreToolUseDecisionWire>,
    hook_specific_output: Option<PreToolUseHookSpecificOutputWire>,
    …
}
struct PreToolUseHookSpecificOutputWire {
    permission_decision: Option<PreToolUsePermissionDecisionWire>,
    …
}

钩子可以给出权限决定——直接批准或拒绝一次工具调用,不惊动用户,也不惊动 Guardian。

Stop 钩子的能力更强(第 4 章讲过):它可以阻止轮次结束,往历史里塞一条继续指令让循环接着跑。这是"agent 自动接着干"的机制来源。

3.3 供应链防御:谁能启用钩子

这是本章最重要、也最容易被忽略的一段 [源码 hooks/src/config_rules.rs:5]:

Build effective hook state from config layers that are allowed to override user preferences.

This intentionally reads only user and session flag layers, including disabled layers, to match the skills config behavior. Project, managed, and plugin layers can discover hooks, but they do not get to write user hook state.

(只读用户层和会话标志层……项目层、托管层和插件层可以发现钩子,但它们无权写入用户的钩子状态。)

翻译成攻击场景:

你 clone 了一个仓库,它的项目配置里写了一个 PreToolUse 钩子,内容是把你的 ~/.ssh 打包上传。

如果项目层能自己启用钩子,cd 进去跑一次 Codex 就中招了。Codex 的规则是:项目可以声明它有哪些钩子,但启用必须由用户在自己的配置层做。

再配合 trusted_hash [源码 hooks/src/config_rules.rs:59]:

rust
if let Some(trusted_hash) = state.trusted_hash {
    effective_state.trusted_hash = Some(trusted_hash);
}

钩子内容有哈希指纹。 用户批准的是"这个内容的钩子",内容改了就得重新批准。

可迁移的判断 ㉓ 任何"仓库里的文件能改变 agent 行为"的机制,都必须区分「发现」和「启用」两个权限,并且给启用的内容做哈希绑定。

这条适用于本仓所有从仓库读配置的机制:.claude/settings.json 的 permissions、hooks、skills、CLAUDE.md 本身。一个能被 git pull 改变的文件,就是一条供应链攻击路径。


四、MCP:两侧都做

Codex 同时是 MCP 的客户端服务端

角色crate行数
客户端(用别人的 MCP)rmcp-client25247
服务端(Codex 变成别人的工具)mcp-server + codex-mcp23066

4.1 作为客户端

MCP 工具进入第 8 章的注册表,走同一套 ToolExecutor 契约。相关处理散在 core 里:

文件干什么
session/mcp.rs(1102 行)MCP 会话管理
session/mcp_runtime.rs运行时
session/mcp_prewarm.rs预热——提前连上
session/mcp_refresh.rs热刷新(Op::RefreshMcpServers
mcp_tool_call.rs(2269 行)工具调用
mcp_tool_exposure.rs决定哪些 MCP 工具进入模型视野
mcp_tool_approval_templates.rs审批文案
mcp_skill_dependencies.rs技能可以声明依赖某个 MCP 服务器
hook_mcp_executor.rs钩子可以由 MCP 实现

两个交叉很有意思:

① 技能声明 MCP 依赖。 第 4 章主循环里那个 required_mcp_servers_for_input 就是干这个的——用户提到某个技能,才去拉起它依赖的 MCP 服务器。不是启动时全连上。

② 钩子可以是 MCP 而不是子进程。 传统钩子是"跑一个命令",Codex 允许钩子由一个 MCP 服务器实现——更结构化,也不用每次 fork 进程。

4.2 作为服务端

codex mcp-server 把整个 Codex 变成 MCP 服务器(第 3 章讲过)。这意味着 Claude Code 可以把 Codex 当工具调用,反之亦然。


五、Plugins:把三者打包 + 一个商店

插件的清单结构 [源码 plugin/src/manifest.rs:8]:

rust
pub struct PluginManifest<Resource> {
    pub name: String,
    pub version: Option<String>,
    pub description: Option<String>,
    pub keywords: Vec<String>,
    pub paths: PluginManifestPaths<Resource>,
    pub interface: Option<PluginManifestInterface<Resource>>,
}

pub struct PluginManifestPaths<Resource> {
    pub skills: Vec<Resource>,
    pub mcp_servers: Option<PluginManifestMcpServers<Resource>>,
    pub apps: Option<Resource>,
    pub hooks: Option<PluginManifestHooks<Resource>>,
}

插件 = 技能 + MCP 服务器 + 应用 + 钩子的打包。 它本身不是一种新的扩展能力,是前面几种的分发单元。

真正说明方向的是 interface 那部分 [源码 plugin/src/manifest.rs:42]:

rust
pub struct PluginManifestInterface<Resource> {
    pub display_name: Option<String>,
    pub short_description: Option<String>,
    pub long_description: Option<String>,
    pub developer_name: Option<String>,
    pub category: Option<String>,
    pub capabilities: Vec<String>,
    pub website_url: Option<String>,
    pub privacy_policy_url: Option<String>,      // ← 隐私政策
    pub terms_of_service_url: Option<String>,    // ← 服务条款
    pub default_prompt: Option<Vec<String>>,
    pub brand_color: Option<String>,
    pub composer_icon: Option<Resource>,
    pub logo: Option<Resource>,
    pub logo_dark: Option<Resource>,             // ← 暗色版 logo
    pub screenshots: Vec<Resource>,              // ← 截图
}

分类、能力标签、开发者名、官网、隐私政策、服务条款、品牌色、明暗两套 logo、截图列表。

这是一个应用商店的元数据 schema。 core-plugins/src/installed_marketplaces.rs 更是直接点名了 marketplace。

配合第 8 章讲的那两个工具——list_available_plugins_to_installrequest_plugin_install——闭环是这样的:

text
模型发现自己缺某个能力
    ↓
list_available_plugins_to_install  ← 查商店
    ↓
request_plugin_install             ← 请求安装
    ↓
用户批准
    ↓
新的技能 / MCP 工具 / 钩子进入下一轮

agent 可以在运行中给自己装能力。 这是本教程认为 Codex 最激进的一个方向。

它也带来一个明显的安全问题——所以才有前面那套"发现 vs 启用"的权限分离和 trusted_hash


六、Connectors:应用层的工具治理

connectors crate(4961 行)在 MCP 之上又加了一层,模块名很说明问题 [源码 connectors/src/lib.rs]:

rust
pub mod accessible;
mod app_info;          // AppBranding / AppMetadata / AppReview / AppScreenshot
mod app_tool_policy;   // AppToolPolicy / AppToolPolicyEvaluator
mod connector_runtime;
mod directory_cache;
pub mod filter;
pub mod merge;
pub mod metadata;
mod metadata_store;
mod plugin_config;
mod runtime_projection;
mod snapshot;

AppReview(评价)、AppScreenshot(截图)、AppToolPolicy(应用级工具策略)——"connector"是面向终端用户的"应用"概念,MCP 是它的实现机制。

AppToolPolicyEvaluator 尤其重要:同一个 app 的不同工具可以有不同策略(这个只读、那个要审批)。这比"整个 MCP 服务器一刀切"细得多。

第 8 章讲的 tool_search 那份描述模板里的 {{app_descriptions}},填的就是这里的数据。


七、内部扩展平面:ext/ 与 12 个 Contributor

前面五条是给外部用的。Codex 自己扩展自己走的是另一条路。

ext/ 下有 14 个内部扩展 [源码,实测]:

扩展行数干什么
skills21687技能系统的主体实现
guardian-v26836第 11 章 Guardian 的下一代
goal4556目标跟踪
extension-api2645平面本身
memories2489记忆
mcp1660MCP 接入
queue1606队列
image-generation1309图片生成
history-notes1049历史笔记
web-search874网页搜索
git-attribution441git 归属
items / agent / connectors~600其它

extension-api 定义了 12 个 Contributor trait [源码 ext/extension-api/src/contributors.rs]:

trait贡献什么
McpServerContributorMCP 服务器
ContextContributor上下文片段
ThreadLifecycleContributor线程生命周期
TurnLifecycleContributor轮次生命周期
TurnInputContributor轮次输入
ConfigContributor配置
TokenUsageContributortoken 用量
SkillInvocationContributor技能调用
ToolContributor工具
ToolLifecycleContributor工具生命周期
ApprovalReviewContributor审批复核
TurnItemContributor轮次条目

加上 capabilities/ 下的能力 trait:ExtensionMetricsInternalSessionSpawnerConversationHistorySnapshotResponseItemInjectorAgentSpawnerExtensionEventSink

这就是 Codex 版的 seam(能力接缝) —— 但和 dsh 有两个根本差别:

dsh 的 seamCodex 的 Contributor
数量20+12
谁能实现任何人(写个 cordis 插件)只有仓库内的 ext/
装配方式YAML 配置树Rust 编译期注册
能换掉核心吗能(连 agent loop 都能换)不能(只能"贡献",不能"替换")

"Contributor"这个词选得很准确:它们只能往里加东西,不能改变已有的东西。

ext/skills 有 21687 行——技能系统的主体是作为内部扩展实现的,而不是写在 core 里。这说明这套平面不是玩具,是真的在承载大功能。

可迁移的判断 ㉔ 可以给自己留一套比对外扩展点更强的内部扩展平面,但要给它一个诚实的名字。

"Contributor"(贡献者)而不是"Plugin"(插件)或"Provider"(提供者)——这个命名准确传达了"只能加、不能换"的语义。命名上的诚实能省下大量后续的期待管理。


八、Code Mode:第五条通道

第 8 章讲过它对工具可见性的影响,这里补它的定位。

四个 crate(code-mode / -host / -runtime / -protocol,共 30258 行)+ 独立二进制 codex-code-mode-host + V8 运行时(code-mode-runtime/src/v8_init.rs)。

它不给 agent 加新能力,而是改变已有能力的调用方式:从"一次调一个工具、等结果、再调下一个"变成"写一段 JS,在里面循环、条件、组合地调用工具"。

独立进程 + 独立协议的选择很关键:在主进程里嵌 V8 会把 JS 的崩溃、内存、超时问题带进 agent 内核。放进独立进程后,最坏情况是那个进程被杀掉。


九、六个面的边界总结

text
┌─────────────────────────────────────────────────────────┐
│ 对外五条通道(受限)                                        │
│                                                          │
│  MCP ────────► 工具/资源(进注册表,走同一套 ToolExecutor)  │
│  Skills ─────► 提示词级手册(Markdown,description 必填)   │
│  Hooks ──────► 生命周期拦截(发现≠启用 + trusted_hash)     │
│  Plugins ────► 前三者的打包 + 商店元数据                    │
│  Code Mode ──► 工具的组合方式(独立进程 + V8)              │
├─────────────────────────────────────────────────────────┤
│ 你改不了的:                                               │
│  agent 循环 · 上下文装配 · 压缩策略 · 安全判定 ·            │
│  会话模型 · 协议                                           │
├─────────────────────────────────────────────────────────┤
│ 内部平面(仓库内专用)                                      │
│  ext/ 14 个扩展 × 12 个 Contributor trait                 │
│  只能"贡献",不能"替换"                                    │
└─────────────────────────────────────────────────────────┘

十、动手复核

bash
cd codex/codex-rs

# 1. 技能 frontmatter 的三个字段与那个 YAML 修复
sed -n '1,30p' skills/src/parser.rs
sed -n '43,80p' skills/src/parser.rs

# 2. 钩子事件名(对照 Claude Code)
sed -n '98,125p' hooks/src/schema.rs

# 3. Claude Code 风格别名
cat core/src/tools/hook_names.rs

# 4. 供应链防御:谁能启用钩子
sed -n '1,25p' hooks/src/config_rules.rs

# 5. 插件清单 = 商店元数据
sed -n '1,60p' plugin/src/manifest.rs

# 6. 12 个 Contributor trait
grep -n 'pub trait' ext/extension-api/src/contributors.rs
grep -rn 'pub trait' ext/extension-api/src/capabilities/

# 7. 内部扩展的规模
for d in ext/*/; do
  echo -n "$(basename $d): "; find "$d" -name '*.rs' -print0 | xargs -0 cat | wc -l
done | sort -t: -k2 -rn

自己的机器上:

bash
codex plugin --help
codex mcp --help
ls ~/.codex/skills/ 2>/dev/null

十一、总结

  1. 五条对外通道边界明确:MCP(工具)、Skills(知识)、Hooks(拦截)、Plugins(分发)、Code Mode(组合)。加能力可以,改核心不行
  2. 技能就是一个 SKILL.mddescription 必填——它是模型的路由依据。解析器对第三方常见的非法 YAML 做行级修复,但明确保留边界
  3. 钩子接口刻意兼容 Claude Code(事件名 + Write/Edit/Agent 别名),引擎类型直接叫 ClaudeHooksEngine。但兼容只在匹配层,不污染自己的 payload 契约
  4. 最重要的一条:项目层可以发现钩子,但无权启用,启用只能在用户层,且内容有 trusted_hash 绑定。这是对"git pull 即中招"的直接防御
  5. 插件清单是应用商店 schema(隐私政策、服务条款、截图、明暗 logo),配合 request_plugin_install 让 agent 能在运行中给自己装能力
  6. Connectors 是 MCP 之上的应用层治理,能做到"同一个 app 的不同工具不同策略"
  7. ext/ + 12 个 Contributor 是内部专用平面——只能贡献不能替换,命名上很诚实

下一章讲最后一块:主 agent 变成协调者之后,多 agent 怎么工作。


  • 第12章-会话持久化-rollout与线程恢复
  • 第14章-多Agent委派-从子进程到协作图
  • 第8章-工具系统-注册路由与延迟加载 —— MCP/插件工具如何进入注册表
  • Pi 教程第 11 章
  • dsh 教程第 12 章
  • dsh 教程第 4 章 —— seam 与 Contributor 的对照

本章目录
一、五条对外通道的分工二、Skills:一个 Markdown 文件三、Hooks:一个刻意兼容的接口四、MCP:两侧都做五、Plugins:把三者打包 + 一个商店六、Connectors:应用层的工具治理七、内部扩展平面:ext/ 与 12 个 Contributor八、Code Mode:第五条通道九、六个面的边界总结十、动手复核十一、总结Related Documents
苏ICP备2025204887号-2