dsh 的扩展方式是"往插件树里加一行配置,什么都能换"。Codex 的扩展方式是五条边界明确的受限通道,加上一层只有它自己能用的内部扩展平面。本章讲这六个面各自能做什么、不能做什么,以及一个容易被忽略的供应链防御。
| 通道 | 加什么 | 谁写 | 运行位置 |
|---|---|---|---|
| MCP | 工具、资源、提示 | 第三方 | 独立进程/远程 |
| Skills | 提示词级的操作手册 | 用户/团队 | 无(就是 Markdown) |
| Hooks | 生命周期拦截 | 用户 | 子进程 / MCP |
| Plugins | 上面三者的打包分发 | 第三方 | 同上 |
| Code Mode | 工具的组合调用方式 | —— | V8 沙箱 |
一句话概括边界:你可以给 Codex 加能力、加知识、加拦截,但你不能改它的循环、上下文装配、安全判定和会话模型。
这和 dsh 是根本区别(第 15 章会展开)。
技能就是一个带 YAML frontmatter 的 SKILL.md。解析器只认三个字段 [源码 skills/src/parser.rs:6]:
约束 [源码 skills/src/parser.rs:4, 70]:
description 必填是有道理的:它是模型决定"要不要用这个技能"的唯一依据。没有描述的技能等于不存在。
[源码 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 是模型的路由依据,它的质量直接决定技能能不能被触发。
[源码 protocol/src/protocol.rs:3610]
用户级、仓库级、系统级、管理员级。Admin 的存在说明有企业分发场景——IT 管理员推下来的技能,用户不能改。
每个技能还可以带 policy [源码 skills/src/model.rs:24]:
allow_implicit_invocation = false 的技能只能被用户显式选中(第 3 章 Op::UserTurn 的 skill 输入项),模型不能自己决定用它。
这正好对应本仓的 disable-model-invocation: true(obsidian-cli 技能就用了它)。两个项目独立做出了同一个设计。
技能还有 dependencies(依赖)和 interface(接口)字段,以及 matches_product_restriction(限定在某个产品里可用)。
[源码 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]:
而钩子引擎的类型名直接叫 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/——用的是"生成镜像"而不是"双份真源",是同一种分寸。
PreToolUse 的返回结构 [源码 hooks/src/schema.rs:127]:
钩子可以给出权限决定——直接批准或拒绝一次工具调用,不惊动用户,也不惊动 Guardian。
Stop 钩子的能力更强(第 4 章讲过):它可以阻止轮次结束,往历史里塞一条继续指令让循环接着跑。这是"agent 自动接着干"的机制来源。
这是本章最重要、也最容易被忽略的一段 [源码 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]:
钩子内容有哈希指纹。 用户批准的是"这个内容的钩子",内容改了就得重新批准。
可迁移的判断 ㉓ 任何"仓库里的文件能改变 agent 行为"的机制,都必须区分「发现」和「启用」两个权限,并且给启用的内容做哈希绑定。
这条适用于本仓所有从仓库读配置的机制:.claude/settings.json 的 permissions、hooks、skills、CLAUDE.md 本身。一个能被 git pull 改变的文件,就是一条供应链攻击路径。
Codex 同时是 MCP 的客户端和服务端:
| 角色 | crate | 行数 |
|---|---|---|
| 客户端(用别人的 MCP) | rmcp-client | 25247 |
| 服务端(Codex 变成别人的工具) | mcp-server + codex-mcp | 23066 |
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 进程。
codex mcp-server 把整个 Codex 变成 MCP 服务器(第 3 章讲过)。这意味着 Claude Code 可以把 Codex 当工具调用,反之亦然。
插件的清单结构 [源码 plugin/src/manifest.rs:8]:
插件 = 技能 + MCP 服务器 + 应用 + 钩子的打包。 它本身不是一种新的扩展能力,是前面几种的分发单元。
真正说明方向的是 interface 那部分 [源码 plugin/src/manifest.rs:42]:
分类、能力标签、开发者名、官网、隐私政策、服务条款、品牌色、明暗两套 logo、截图列表。
这是一个应用商店的元数据 schema。 core-plugins/src/installed_marketplaces.rs 更是直接点名了 marketplace。
配合第 8 章讲的那两个工具——list_available_plugins_to_install 和 request_plugin_install——闭环是这样的:
agent 可以在运行中给自己装能力。 这是本教程认为 Codex 最激进的一个方向。
它也带来一个明显的安全问题——所以才有前面那套"发现 vs 启用"的权限分离和 trusted_hash。
connectors crate(4961 行)在 MCP 之上又加了一层,模块名很说明问题 [源码 connectors/src/lib.rs]:
AppReview(评价)、AppScreenshot(截图)、AppToolPolicy(应用级工具策略)——"connector"是面向终端用户的"应用"概念,MCP 是它的实现机制。
AppToolPolicyEvaluator 尤其重要:同一个 app 的不同工具可以有不同策略(这个只读、那个要审批)。这比"整个 MCP 服务器一刀切"细得多。
第 8 章讲的 tool_search 那份描述模板里的 {{app_descriptions}},填的就是这里的数据。
前面五条是给外部用的。Codex 自己扩展自己走的是另一条路。
ext/ 下有 14 个内部扩展 [源码,实测]:
| 扩展 | 行数 | 干什么 |
|---|---|---|
| skills | 21687 | 技能系统的主体实现 |
| guardian-v2 | 6836 | 第 11 章 Guardian 的下一代 |
| goal | 4556 | 目标跟踪 |
| extension-api | 2645 | 平面本身 |
| memories | 2489 | 记忆 |
| mcp | 1660 | MCP 接入 |
| queue | 1606 | 队列 |
| image-generation | 1309 | 图片生成 |
| history-notes | 1049 | 历史笔记 |
| web-search | 874 | 网页搜索 |
| git-attribution | 441 | git 归属 |
| items / agent / connectors | ~600 | 其它 |
extension-api 定义了 12 个 Contributor trait [源码 ext/extension-api/src/contributors.rs]:
| trait | 贡献什么 |
|---|---|
| McpServerContributor | MCP 服务器 |
| ContextContributor | 上下文片段 |
| ThreadLifecycleContributor | 线程生命周期 |
| TurnLifecycleContributor | 轮次生命周期 |
| TurnInputContributor | 轮次输入 |
| ConfigContributor | 配置 |
| TokenUsageContributor | token 用量 |
| SkillInvocationContributor | 技能调用 |
| ToolContributor | 工具 |
| ToolLifecycleContributor | 工具生命周期 |
| ApprovalReviewContributor | 审批复核 |
| TurnItemContributor | 轮次条目 |
加上 capabilities/ 下的能力 trait:ExtensionMetrics、InternalSessionSpawner、ConversationHistorySnapshot、ResponseItemInjector、AgentSpawner、ExtensionEventSink。
这就是 Codex 版的 seam(能力接缝) —— 但和 dsh 有两个根本差别:
| dsh 的 seam | Codex 的 Contributor | |
|---|---|---|
| 数量 | 20+ | 12 |
| 谁能实现 | 任何人(写个 cordis 插件) | 只有仓库内的 ext/ |
| 装配方式 | YAML 配置树 | Rust 编译期注册 |
| 能换掉核心吗 | 能(连 agent loop 都能换) | 不能(只能"贡献",不能"替换") |
"Contributor"这个词选得很准确:它们只能往里加东西,不能改变已有的东西。
ext/skills 有 21687 行——技能系统的主体是作为内部扩展实现的,而不是写在 core 里。这说明这套平面不是玩具,是真的在承载大功能。
可迁移的判断 ㉔ 可以给自己留一套比对外扩展点更强的内部扩展平面,但要给它一个诚实的名字。
"Contributor"(贡献者)而不是"Plugin"(插件)或"Provider"(提供者)——这个命名准确传达了"只能加、不能换"的语义。命名上的诚实能省下大量后续的期待管理。
第 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 内核。放进独立进程后,最坏情况是那个进程被杀掉。
自己的机器上:
下一章讲最后一块:主 agent 变成协调者之后,多 agent 怎么工作。