上一篇分析了命令执行、审批策略和跨平台沙箱。

那套机制解决的是:

模型已经决定调用某个能力之后,
Codex 如何安全地执行真实副作用。

本篇向前追问一步:

除了修改 Codex Core,
外部能力如何进入一条 Thread 和 Turn?

Codex 当前有四类最容易混淆的扩展机制:

MCP
Skill
Plugin
Hook

它们会在同一个 Turn 中相遇,但不是四种等价的“插件”:

机制 核心职责 是否直接执行能力 是否进入模型上下文
MCP 连接外部服务并暴露 Tool、Resource、Template Tool Spec 或 Resource 结果进入
Skill 提供可发现、按需读取的操作说明 元数据常驻,正文按需进入
Plugin 打包、安装并归属多类能力 能力摘要或其子能力进入
Hook 拦截生命周期事件并返回策略结果 是,执行 Hook Command 只把上下文、反馈或续写片段注入

一个 Plugin 可以同时携带:

Skills
MCP Servers
Apps
Hooks
Interface Metadata

但 Plugin 并不会创建一套新的工具执行协议。

它只是把包内资源投影到既有子系统:

Plugin Skill
  -> SkillsService

Plugin MCP Server
  -> McpManager

Plugin App
  -> ConnectorSnapshot

Plugin Hook
  -> ClaudeHooksEngine

这一区分会贯穿全文。

本篇目标

阅读完成后,你应该能够:

  1. 判断一个新能力应该使用 MCP、Skill、Plugin 还是 Hook。
  2. 跟踪 MCP 配置从多来源合并到连接管理器的完整调用链。
  3. 解释 MCP Tool、Resource、OAuth、Elicitation 和 Environment 的边界。
  4. 说明为什么 MCP Runtime 必须使用请求级快照。
  5. 解释 Skill 元数据与 SKILL.md 正文的两阶段加载。
  6. 说清 Skill 显式选择、模型隐式选择与命令使用检测的差异。
  7. 跟踪 Plugin 从 Marketplace 到 Store,再到各能力子系统的过程。
  8. 区分 App、Connector、Codex Apps MCP 和普通 MCP Server。
  9. 解释 Hook 的发现、信任、Matcher、并发执行与结果聚合。
  10. 说明 PreToolUsePermissionRequestPostToolUseStop 的不同能力。
  11. 跟踪 Skills 文件监听、Plugin 缓存失效和 MCP Runtime 刷新。
  12. 实现一个只读 MCP Server 和一个最小本地 Skill。

1. 本篇源码地图

MCP 配置与运行时

文件 职责
core/src/mcp.rs 合并配置、插件、扩展与 Codex Apps MCP
core/src/session/mcp.rs Session 初始化、按 Step 投影和 Runtime 刷新
core/src/session/mcp_runtime.rs 请求级 McpRuntimeSnapshot
codex-mcp/src/connection_manager.rs MCP Client 启动、聚合、调用和关闭
codex-mcp/src/rmcp_client.rs 单 Server Client 生命周期与缓存重连
codex-mcp/src/runtime.rs Environment、HTTP Client 与 Sandbox State
codex-mcp/src/catalog.rs 多来源 Server 注册、优先级和冲突解析
codex-mcp/src/mcp/auth.rs OAuth 发现、Scope 和 Auth Status
codex-mcp/src/elicitation.rs Server 主动请求用户输入的路由
config/src/mcp_types.rs MCP 配置类型和传输互斥校验
cli/src/mcp_cmd.rs codex mcp 管理命令

Skills

文件 职责
core/src/skills.rs Core 兼容导出与隐式使用埋点
core-skills/src/loader.rs Skill Root、Frontmatter 和 Metadata 加载
core-skills/src/loader/discovery.rs 有界目录扫描和 SKILL.md 发现
core-skills/src/service.rs 快照、缓存、禁用规则与额外 Root
core-skills/src/model.rs SkillMetadataSkillLoadOutcome
core-skills/src/render.rs 可见 Skill 列表与上下文预算
core-skills/src/injection.rs 显式选择和正文注入
core-skills/src/invocation_utils.rs 命令对 Skill 文件或脚本的使用检测
ext/skills/src Host、Executor、Orchestrator 多来源 Skill 扩展

Plugins 与 Apps

文件 职责
plugin/src/manifest.rs 通用 Plugin Manifest 模型
plugin/src/load_outcome.rs 已加载 Plugin 与能力摘要
core-plugins/src/manifest.rs Host Manifest 解析与安全路径解析
core-plugins/src/loader.rs Skill、MCP、App 和 Hook 能力加载
core-plugins/src/manager.rs 缓存、安装、远端同步与能力解析
core-plugins/src/marketplace.rs Marketplace 清单与安装策略
core-plugins/src/store.rs Plugin 版本目录和持久数据目录
core-plugins/src/app_mcp_routing.rs App 与同名 MCP Server 的路由选择
connectors/src Connector 元数据、策略和快照
core/src/mcp_tool_exposure.rs MCP 与 App Tool 的模型可见性

Hooks 与刷新

文件 职责
hooks/src/lib.rs Hook 公开 API 与事件集合
hooks/src/engine/discovery.rs 多来源发现、信任和 Handler 构建
hooks/src/engine/dispatcher.rs Matcher、并发执行和稳定排序
hooks/src/engine/command_runner.rs Hook Command 子进程和超时
hooks/src/engine/output_parser.rs 事件专属 JSON 输出解析
hooks/src/output_spill.rs 大输出落盘和上下文预览
core/src/hook_runtime.rs Hook 与 Session、Turn、Tool 的集成
app-server/src/skills_watcher.rs Skill Root 文件监听
app-server/src/mcp_refresh.rs 所有活跃 Thread 的 MCP 刷新排队

先把完整关系压缩为一张图:

Config Layer Stack
Marketplace / Plugin Store
Selected Capability Roots
Environment Registry
        |
        +-----------------------------+
        |                             |
        v                             v
PluginsManager                   SkillsService
        |                             |
        | capabilities                | HostSkillsSnapshot
        v                             v
McpManager --------------------> Turn Skill Catalog
        |                             |
        v                             v
McpRuntimeSnapshot              Skill Metadata Prompt
        |                             |
        v                             v
ToolRouter <-------------------- Selected SKILL.md Body
        |
        v
PreToolUse Hook
        |
        v
PermissionRequest Hook
        |
        v
Tool / MCP Call
        |
        v
PostToolUse Hook
        |
        v
Stop Hook -> Optional Continuation

2. 四种扩展机制不是同一抽象层

最先要避免的错误是:

MCP、Skill、Plugin、Hook 都可以扩展 Codex,
所以它们只是四种插件格式。

实际分层如下:

安装与分发层
  -> Plugin

知识与流程层
  -> Skill

远程能力协议层
  -> MCP

生命周期策略层
  -> Hook

Plugin 位于最外层。

它解决:

一个能力包从哪里发现?
是否允许安装?
安装了哪个版本?
包内有哪些能力?
这些能力属于哪个包?

MCP 解决:

如何启动或连接服务?
如何初始化协议?
服务暴露哪些 Tool 和 Resource?
如何发起调用并接收结构化结果?

Skill 解决:

模型何时需要一组专门说明?
如何先只看到摘要,再按需读完整正文?
说明附带的脚本和模板在哪里?

Hook 解决:

某个生命周期事件发生时,
是否需要运行外部策略、注入上下文、阻断或续写?

因此不存在一个统一的:

trait Extension {
    async fn run(&self);
}

它们有不同的加载时机、执行模型和安全边界。

3. 扩展机制选择指南

新增能力时,可以先用下面的决策表。

需求 首选机制 原因
让模型调用外部 API 或数据库 MCP 需要结构化 Tool 协议与运行时连接
教模型遵循项目工作流 Skill 主要交付说明,不需要新执行协议
在工具执行前做组织级检查 Hook 需要拦截生命周期并允许阻断
分发一组 Skill、MCP 与 Hook Plugin 需要安装、版本和能力归属
给模型提供只读文档资源 MCP Resource 内容由外部服务按 URI 提供
给一个 Skill 附带脚本 Skill Resource 脚本服务于该工作流,不是通用 Tool
执行后向模型反馈审计结果 PostToolUse Hook 需要观察工具输入和结果
在模型准备结束时要求补做检查 Stop Hook 需要阻止结束并生成续写 Prompt
为 ChatGPT 连接器开放工具 App + Codex Apps MCP 需要 Connector 身份和可访问性策略

还有四条实用规则:

只有说明,没有新协议
  -> Skill

只有远端能力,不需要打包
  -> MCP

需要多个能力一起安装
  -> Plugin

需要影响已有执行链的某个时点
  -> Hook

如果一个需求同时满足多项,可以组合,而不是强行选择唯一机制。

例如:

Plugin
  + Skill:告诉模型何时使用工单系统
  + MCP:真正查询和修改工单
  + Hook:阻止关闭未填写审计字段的工单

4. 先区分发现、加载、注入和执行

扩展系统中常见的另一个混淆,是把“发现”都叫作“加载”。

本篇使用四个严格术语:

阶段 含义
Discover 找到候选配置或资源
Load 解析并形成内部结构
Inject 把信息放进模型上下文
Execute 启动进程、请求服务或运行 Hook

不同机制的生命周期如下:

MCP
  Discover Config
  -> Load Runtime Projection
  -> Execute Server Startup
  -> Inject Tool Spec
  -> Execute Tool Call

Skill
  Discover SKILL.md
  -> Load Frontmatter Metadata
  -> Inject Metadata List
  -> Select Skill
  -> Load Body
  -> Inject Body

Plugin
  Discover Marketplace Entry
  -> Install Bundle
  -> Load Manifest
  -> Project Capabilities

Hook
  Discover Config
  -> Validate Trust
  -> Load Handler
  -> Match Event
  -> Execute Command
  -> Inject or Block

这也解释了为什么:

Skill 被发现

不等于:

Skill 正文已经占用上下文。

同样:

Plugin 已安装

也不等于:

Plugin 的所有 MCP Server 都已连接。

5. McpManager 负责构造运行时投影

MCP 的 Core 入口是:

#[derive(Clone)]
pub struct McpManager {
    plugins_manager: Arc<PluginsManager>,
    extensions: Arc<ExtensionRegistry<Config>>,
    codex_apps_tools_cache: CodexAppsToolsCache,
}

注意这个类型不持有具体连接。

它持有的是:

Plugin 能力来源
Extension Contributor
Codex Apps 工具缓存

并负责生成:

pub(crate) struct McpRuntimeProjection {
    pub(crate) config: McpConfig,
    pub(crate) plugins_available: bool,
}

这里的 Projection 表示:

给定当前 Config、Thread Extension Data、
Selected Capability Roots 和可用 Environment,
此时应该存在哪些 MCP Server 与 Connector。

主入口是:

pub(crate) async fn runtime_config_for_step(
    &self,
    config: &Config,
    thread_init: &ExtensionDataInit,
    thread_store: &ExtensionData,
    available_environment_ids: &[String],
) -> McpRuntimeProjection

它不是只在进程启动时运行。

当 Step 可用 Environment 改变,或 Plugin、Config 发生刷新时,Session 可以重新计算投影。

6. MCP Server 来自多个来源

一个 Runtime Projection 中的 Server 至少可能来自:

用户或项目 Config
已安装 Plugin
当前 Thread 显式选择的 Plugin Package
Compatibility Codex Apps
Extension Contributor

对应源码类型:

pub enum McpServerSource {
    Plugin(McpPluginAttribution),
    SelectedPlugin(McpPluginAttribution),
    Config,
    Compatibility { id: String },
    Extension { id: String },
}

扩展 Contributor 还可以返回:

Set
Remove
SelectedPlugin
SelectedPluginPackage

这意味着扩展不仅能新增 Server,也能针对当前 Step 移除一个运行时注册。

但所有来源不会直接写入同一个 HashMap

它们先进入:

McpCatalogBuilder

再由 Catalog 做稳定解析。

7. MCP Catalog 明确定义来源优先级

Catalog 的优先级从低到高可以概括为:

Plugin
SelectedPlugin
Config
Compatibility
Extension

源码中的表示是:

enum RegistrationPrecedence {
    Plugin(Reverse<usize>),
    SelectedPlugin(Reverse<usize>),
    Config,
    Compatibility,
    Extension(usize),
}

同一层级内仍保留稳定动作顺序。

构建时:

1. 按优先级稳定排序
2. 对同名 Server 保留最终 Winner
3. 收集同 Tier 冲突
4. 应用 Disabled Name Veto
5. 生成不可变 ResolvedMcpCatalog

McpManager 会记录冲突:

server
outcome
contenders

因此同名 MCP Server 不是依赖 HashMap 的随机覆盖顺序。

8. Selected Plugin 的禁用只约束自己的注册

Catalog 中有一个细节:

fn disabled_registration_is_name_veto(&self) -> bool {
    !matches!(self, Self::SelectedPlugin(_))
}

普通配置或 Plugin 的 Disabled Winner 可以形成名称级 Veto。

但 Thread 临时选择的 Plugin 被禁用时,只影响该注册本身,不应该顺带压制更高来源中同名的 Server。

这体现了两个不同语义:

名称级策略
  -> 这个逻辑 Server 不应运行

选择项级策略
  -> 本次选中的 Package 注册不应运行

9. McpConfig 不保存请求级认证状态

McpConfig 保存长期运行设置:

Codex Home
OAuth Store Mode
Keyring Backend
Approval Policy
Linux Sandbox Helper
Apps Feature
MCP Server Catalog
Connector Snapshot
Tool Name Prefix Policy

源码明确要求:

请求级或认证级状态不应放进 McpConfig。

原因是认证可能在 Session 活跃期间变化。

如果把当前 Token 或 Auth Status 固化进长期配置:

登录或登出后
  -> 旧 Config 仍携带过期状态
  -> Runtime Projection 与真实认证不一致

所以 effective_mcp_servers 显式接收:

auth: Option<&CodexAuth>

10. Configured Server 与 Effective Server 不同

MCP 配置有三层视图:

Configured
  -> 配置和 Plugin 声明了什么

Projected
  -> Compatibility 与 Extension Overlay 合并后是什么

Effective
  -> 应用认证门禁后真正启动什么

例如保留名:

codex_apps

只有同时满足:

Apps Feature Enabled
Codex Backend Auth Available

才会留在 Effective Server 集合。

因此:

Catalog 中存在

不保证:

ConnectionManager 中有 Client。

11. McpRuntimeSnapshot 绑定一次请求的精确世界

真正交给 Step 使用的是:

pub struct McpRuntimeSnapshot {
    config: Arc<McpConfig>,
    plugins_available: bool,
    manager: Arc<McpConnectionManager>,
    runtime_context: McpRuntimeContext,
    available_environment_ids: Vec<String>,
}

它同时绑定:

解析后的 MCP Config
Plugin 可用性
确切的 ConnectionManager
Environment Runtime Context
参与投影的 Environment ID

为什么不能只在 Session 上放一个可变:

current_manager

因为刷新可能发生在一次 Tool Call 执行期间。

正确语义应是:

Step A 取得 Snapshot A
  -> 始终调用 Manager A

刷新发布 Snapshot B

Step B 取得 Snapshot B
  -> 调用 Manager B

Snapshot A 最后一个引用释放
  -> Manager A 才进入关闭

这样不会出现:

Tool Spec 来自旧 Server,
Tool Call 却被路由到新 Manager。

12. Environment ID 是 MCP Runtime 的一部分

MCP Server Config 默认绑定:

environment_id = "local"

但 Server 也可以绑定显式远端 Environment。

McpRuntimeContext 的解析规则是:

已注册 Environment
  -> 使用 Environment 自己的 Exec 或 HTTP 能力

local + stdio
  -> 必须存在 Local Environment

local + Streamable HTTP
  -> 可以使用进程内 Reqwest HTTP Client

未知的非 local Environment
  -> 拒绝

对应核心代码:

pub(crate) fn resolve_server_environment(
    &self,
    server_name: &str,
    config: &McpServerConfig,
) -> Result<Option<Arc<Environment>>, String>

因此远端 MCP 不是简单地:

在本地启动一个进程,
把 cwd 字符串换成远端路径。

它必须通过目标 Environment 拥有的执行和网络能力。

13. Step 的 Environment 变化不一定重启 MCP

Session::mcp_runtime_for_step 会比较:

旧 available_environment_ids
新 available_environment_ids

如果输入集合变化,但投影后的:

Server Catalog
Connector Snapshot

都没有变化,而且受影响 Environment 没有被启用的 MCP Server 使用,那么 Session 只发布一个新 Snapshot Key:

复用旧 Manager
更新 available_environment_ids

这避免了无意义的:

停止进程
重新初始化协议
重新列举工具

属于典型的延迟重建优化。

14. MCP 配置严格区分 Stdio 与 Streamable HTTP

传输枚举是:

pub enum McpServerTransportConfig {
    Stdio {
        command: String,
        args: Vec<String>,
        env: Option<HashMap<String, String>>,
        env_vars: Vec<McpServerEnvVar>,
        cwd: Option<LegacyAppPathString>,
    },
    StreamableHttp {
        url: String,
        bearer_token_env_var: Option<String>,
        http_headers: Option<HashMap<String, String>>,
        env_http_headers: Option<HashMap<String, String>>,
    },
}

反序列化不是“哪个字段有值就尽量猜”。

TryFrom<RawMcpServerConfig> 会拒绝非法组合。

例如 Stdio 不允许:

url
bearer_token_env_var
http_headers
oauth
oauth_resource

Streamable HTTP 不允许:

args
env
env_vars
cwd

这保证传输配置在进入 Runtime 前已经完成互斥校验。

15. MCP Server Config 还控制运行策略

除传输外,每个 Server 还有:

enabled
required
supports_parallel_tool_calls
startup_timeout_sec
tool_timeout_sec
default_tools_approval_mode
enabled_tools
disabled_tools
scopes
oauth
oauth_resource
tools.<name>.approval_mode

几个字段容易误解。

required 表示:

初始化失败时,
等待 Required Server 的调用方应把 Session 初始化视为错误。

它不是:

这个 Server 的每个 Tool 都必须被模型调用。

supports_parallel_tool_calls 表示:

这个 Server 的所有 Tool 都可被声明为支持并行调用。

它是 Server 级能力承诺,配置者必须保证线程安全。

enabled_toolsdisabled_tools 是 Tool Filter:

先应用 Allow List
再应用 Deny List

而且 Filter 不只影响模型可见列表。

call_tool 在真正调用前还会再次检查:

if !client.tool_filter.allows(tool) {
    return Err(...);
}

因此不能靠伪造 Tool Name 绕过可见性过滤。

16. McpConnectionManager 并发启动所有 Server

连接管理器的核心字段是:

pub struct McpConnectionManager {
    clients: HashMap<String, AsyncManagedClient>,
    server_metadata: HashMap<String, McpServerMetadata>,
    required_servers: Vec<String>,
    tool_plugin_provenance: Arc<ToolPluginProvenance>,
    prefix_mcp_tool_names: bool,
    elicitation_requests: ElicitationRequestManager,
    startup_cancellation_token: CancellationToken,
}

构造时对每个启用 Server:

1. 记录 Server Metadata
2. 派生 Cancellation Token
3. 发布 Starting
4. 构造 AsyncManagedClient
5. 放入 clients
6. 在 JoinSet 中并发等待启动结果
7. 发布 Ready、Failed 或 Cancelled

全部结束后再发布:

McpStartupComplete
  ready[]
  failed[]
  cancelled[]

这里的关键是:

Manager 先持有 AsyncManagedClient,
具体 Client 可以仍在初始化。

调用方不需要串行等完第一个 Server 才开始第二个。

17. 默认超时属于不同阶段

当前默认值是:

pub(crate) const DEFAULT_STARTUP_TIMEOUT: Duration =
    Duration::from_secs(30);

pub(crate) const DEFAULT_TOOL_TIMEOUT: Duration =
    Duration::from_secs(300);

两者分别约束:

Startup Timeout
  -> 启动传输、Initialize、初次 List Tools

Tool Timeout
  -> 一次 Tool、Resource 或 Template 请求

不能因为某个业务 Tool 允许运行五分钟,就把 Server 启动也等待五分钟。

反过来,Server 能在 30 秒内启动,也不代表每个 Tool 都必须在 30 秒内完成。

18. Required Server 要在 Manager 发布后验证

validate_required_servers 的注释给出了一个重要顺序:

Manager 必须先对请求处理器可达,
之后才能等待 Required Server。

原因是 MCP 初始化本身可能发起:

Elicitation

如果 Session 还没有安装 Manager:

Server 初始化等待用户响应
Session 又无法把响应路由回该 Server

就会形成启动死锁。

因此 Session 使用:

pub(crate) async fn install_mcp_connection_manager(
    &self,
    config: Arc<McpConfig>,
    plugins_available: bool,
    runtime_context: McpRuntimeContext,
    available_environment_ids: Vec<String>,
    manager: McpConnectionManager,
) -> Result<()>

内部顺序是:

publish_mcp_runtime
-> validate_required_servers

19. Tool、Resource 与 Template 是三种协议面

连接成功后,Manager 聚合三类能力:

Tools
Resources
Resource Templates

它们的语义不同:

能力 寻址方式 典型用途
Tool Server + Tool Name 执行结构化动作
Resource Server + URI 读取已有内容
Resource Template Server + URI Template 描述参数化资源

list_all_tools 按 Server 遍历,并把:

Server Name
Connector Metadata
Plugin Provenance
Canonical Model Name

附加到每个 Tool。

Resource 和 Template 聚合使用 JoinSet 并发分页。

分页还有一个防御:

如果 next_cursor 与当前 cursor 相同,
立即报 duplicate cursor,
避免无限循环。

20. MCP Tool Name 同时保留协议身份和模型身份

协议调用需要:

server
raw tool name

模型侧则需要合法、稳定、无冲突的调用名。

传统前缀形态是:

mcp__<server>__<tool>

配置可以控制是否保留 Legacy Prefix。

无论展示名如何规范化,真正调用仍回到:

manager.call_tool(
    &invocation.server,
    &invocation.tool,
    arguments,
    meta,
)

所以模型名不是协议路由的唯一真相。

ToolInfo 中保留的 Server 与原始 Tool 身份才负责实际调用。

21. Tool 可见性和 Tool 可调用性是两道门

MCP Apps 规范允许在 Tool Meta 中声明:

{
  "ui": {
    "visibility": ["model"]
  }
}

Codex 的规则是:

没有 visibility 元数据
  -> 默认对模型可见

有 visibility 元数据
  -> 必须显式包含 "model"

对应函数:

pub fn tool_is_model_visible(tool: &ToolInfo) -> bool

这只决定:

是否进入模型 Tool Declaration。

真正调用还会经过:

Tool Filter
Approval Mode
PreToolUse Hook
PermissionRequest Hook
MCP Call Runtime

因此:

Model Hidden

和:

Runtime Disabled

不是同一状态。

22. Tool Search 会把 MCP Tool 从 Direct 变成 Deferred

build_mcp_tool_exposure 根据 Tool Search 是否启用返回:

pub(crate) struct McpToolExposure {
    pub(crate) direct_tools: Vec<McpToolInfo>,
    pub(crate) deferred_tools: Option<Vec<McpToolInfo>>,
}

如果 Tool Search 未启用:

通过可见性过滤的 MCP Tool
  -> Direct

如果 Tool Search 已启用:

Direct = []
Deferred = 过滤后的 MCP Tool

这与上一篇的 ToolRouter 结论一致:

Runtime 可以已经注册,
Tool Spec 不必一开始全部进入 Prompt。

23. MCP Tool Call 仍进入 Core 的审批与 Hook 链

MCP 不是一条绕开 Codex 安全层的旁路。

一次调用大致经过:

Model Function Call
  -> ToolRouter
  -> MCP Tool Runtime
  -> PreToolUse Hook
  -> MCP Approval Policy
  -> PermissionRequest Hook
  -> McpConnectionManager::call_tool
  -> PostToolUse Hook
  -> Model Output

Manager 最终只负责协议调用:

pub async fn call_tool(
    &self,
    server: &str,
    tool: &str,
    arguments: Option<serde_json::Value>,
    meta: Option<serde_json::Value>,
) -> Result<CallToolResult>

是否需要用户批准,不应该由这个低层传输函数重新判断。

24. 支持的 Server 可以接收 Sandbox State

MCP Server 可以在 Capability 中声明:

codex/sandbox-state-meta

Codex 随后在 Tool Call _meta 中附加:

pub struct SandboxState {
    pub permission_profile: PermissionProfile,
    pub codex_linux_sandbox_exe: Option<PathBuf>,
    pub sandbox_cwd: PathUri,
    pub use_legacy_landlock: bool,
}

这不是把沙箱强制责任自动转移给 MCP。

它表示:

支持该扩展的 Server
可以根据当前 Turn 权限,
约束自己后续启动的命令或子任务。

如果 Server 不声明 Capability,Codex 不会注入这段私有 Meta。

25. OAuth 只适用于合格的 HTTP Transport

OAuth 候选必须满足:

Transport = Streamable HTTP
bearer_token_env_var 未配置

Stdio 不进入 OAuth 发现。

已经显式配置 Bearer Token Environment Variable 的 HTTP Server,也不会同时启动 OAuth。

这是为了避免两套认证来源竞争:

环境变量 Token
OAuth Credential Store

oauth_login_support 的结果有三种:

pub enum McpOAuthLoginSupport {
    Supported(McpOAuthLoginConfig),
    Unsupported,
    Unknown(anyhow::Error),
}

UnknownUnsupported 不同:

Unsupported
  -> 已确认不是可用 OAuth Server

Unknown
  -> 发现过程失败,无法判断

26. OAuth Scope 有严格优先级

Scope 来源按以下顺序解析:

CLI 显式 Scope
  -> Configured Scope
  -> Discovery Scope
  -> Empty

内部保留来源:

pub enum McpOAuthScopesSource {
    Explicit,
    Configured,
    Discovered,
    Empty,
}

保留来源的原因,是错误恢复策略不同。

如果 OAuth Provider 拒绝自动发现出的 Scope:

Source = Discovered
Error = OAuthProviderError

Codex 可以重试一次空 Scope。

但如果 Scope 是用户显式传入或配置指定:

不能静默丢弃用户意图。

27. Auth Status 对所有 Server 并发计算

compute_auth_statuses 为每个 Effective Server 创建 Future,再:

join_all(futures)

状态计算同时考虑:

Transport
Bearer Token Environment Variable
OAuth Credential Store
Keyring Backend
ChatGPT Runtime Auth
Server Environment 的 HTTP Client

远端 Environment 的 HTTP Server 不应绕回本地网络做 OAuth 探测。

它通过:

McpRuntimeContext::resolve_http_client

获得该 Environment 拥有的 HTTP 能力。

28. codex mcp 提供配置与认证管理面

CLI 子命令包括:

codex mcp list
codex mcp get
codex mcp add
codex mcp remove
codex mcp login
codex mcp logout

Stdio 示例:

codex mcp add readonly-demo -- \
  python3 /absolute/path/to/readonly_mcp.py

Streamable HTTP 示例:

codex mcp add issue-tracker \
  --url https://mcp.example.com/mcp

Bearer Token 只保存环境变量名:

codex mcp add issue-tracker \
  --url https://mcp.example.com/mcp \
  --bearer-token-env-var ISSUE_TRACKER_TOKEN

实际 Secret 不应写入 config.toml

29. Elicitation 是 Server 主动发起的用户交互

Tool Call 是:

Codex -> MCP Server

Elicitation 则是:

MCP Server -> Codex -> User or Reviewer

它可用于:

确认操作
填写表单
打开认证 URL
请求额外业务输入

ElicitationRequestManager 的决策顺序是:

auto_deny?
  -> Decline

当前审批和权限允许自动批准,
且表单没有必填属性?
  -> Accept {}

审批策略禁止 Elicitation?
  -> Decline

Reviewer 可以给出结果?
  -> 使用 Reviewer 结果

否则
  -> 发协议事件并等待客户端响应

AskForApproval::Never 会拒绝普通 Elicitation。

Granular 则检查:

allows_mcp_elicitations()

30. Elicitation 使用 Codex 生成的公开 ID

多个 MCP Runtime 可能在刷新前后并存。

不同连接完全可能重复使用同一个原始:

server request id

如果 Codex 直接把它暴露给客户端:

旧 Runtime 的请求
新 Runtime 的请求

可能碰撞。

所以 Router 生成:

codex-mcp-elicitation-<counter>

并保存映射:

(server_name, public_request_id)
  -> oneshot responder

客户端响应只会交给确切等待者。

31. Codex Apps 失败时可以先使用缓存

普通 MCP Server 启动失败,通常意味着该 Server 本次不可用。

保留的 Codex Apps Server 多一层共享 Tool Cache。

如果:

在线启动失败
但存在缓存 Tool 和 Server Info

Codex 可以:

先继续暴露缓存 Tool
后台重连真实 Server

重连退避:

初始 1 秒
指数增长
最大 30 秒

恢复后发布:

McpStartupStatus::Ready

这个优化只保证 Tool Catalog 在短暂故障时仍可构建。

真正调用仍可能因连接未恢复而失败。

32. MCP Refresh 是快照替换,不是原地改 Client

App Server 刷新流程是:

重新加载最新 Config
  -> 为每个活跃 Thread 构建 Refresh Config
  -> submit Op::RefreshMcpServers
  -> Session 保存 Pending Refresh
  -> 下一个安全处理点消费
  -> 构造新 Projection 和 Manager
  -> 发布新 Snapshot

有两种入口:

queue_strict_refresh
queue_best_effort_refresh

Strict 用于调用方需要明确失败的管理操作。

Best Effort 用于:

Plugin 安装或卸载
账号状态变化
后台同步

其中单个 Thread 失败不应阻断其他 Thread。

33. 旧 MCP Runtime 会保留到最后一个使用者释放

刷新时创建新的:

CancellationToken
McpConnectionManager
McpRuntimeSnapshot

旧 Runtime 仍可能服务正在执行的 Step。

源码注释强调:

旧 Runtime 拥有旧 Token,
最后一个 Runtime Handle 释放时才取消。

这提供了类似 Read-Copy-Update 的效果:

读取方无须停顿
写入方构造新快照
原子发布
旧版本延迟回收

34. codex mcp-server 是相反方向的能力

仓库中的:

codex-rs/codex-mcp

主要让 Codex 作为 MCP Client 连接外部 Server。

而:

codex-rs/mcp-server

让 Codex 自己作为 MCP Server 被其他 Client 调用。

方向分别是:

codex-mcp
  Codex -> External MCP Server

mcp-server
  External MCP Client -> Codex

两者都使用 MCP,但架构角色相反。

开发扩展时不要把:

给 Codex 新增一个 MCP Server

误解为:

修改 codex-rs/mcp-server。

前者通常只需配置或 Plugin Manifest。

35. Skills 的当前源码布局已经拆层

课程大纲中的概念路径是:

core/src/skills

当前仓库实际拆为:

codex-rs/core/src/skills.rs
codex-rs/core-skills/src
codex-rs/ext/skills/src
codex-rs/skills/src

职责分别是:

core/src/skills.rs
  -> Core 兼容导出和使用埋点

core-skills
  -> Host 文件系统发现、解析、缓存和渲染

ext/skills
  -> Host、Executor、Orchestrator 多 Authority 抽象

skills
  -> Bundled Skill 资源与安装资产

因此理解 Skills 时不应只读一个 Loader 文件。

36. Skill Root 来自配置层、用户目录、插件与仓库

SkillsService 最终把多类 Root 合并为:

pub struct SkillRoot {
    pub path: AbsolutePathBuf,
    pub scope: SkillScope,
    pub file_system: Arc<dyn ExecutorFileSystem>,
    pub plugin_id: Option<String>,
    pub plugin_namespace: Option<String>,
    pub plugin_root: Option<AbsolutePathBuf>,
}

Host Skill Root 可能来自:

Project Config Folder / skills
$CODEX_HOME/skills
$HOME/.agents/skills
$CODEX_HOME/skills/.system
/etc/codex/skills
Plugin Manifest Skill Roots
Runtime Extra Roots
Project Root 到 cwd 之间各级 .agents/skills

其中:

$CODEX_HOME/skills

是兼容旧位置。

用户安装位置是:

$HOME/.agents/skills

仓库级发现会先寻找 Project Root,再按:

Project Root
...
Current Working Directory

逐级加入存在的:

.agents/skills

这允许单体仓库的子目录提供更局部的 Skill。

37. Skill Scope 与 Config Layer 不是一一对应

Skill Scope 包括:

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

大致映射为:

Scope 典型来源
Repo 项目配置与仓库 .agents/skills
User 用户目录、额外 Root、Plugin Skill
System Codex 内置并缓存的 Bundled Skill
Admin 系统级 /etc/codex/skills

Plugin Skill 当前使用:

SkillScope::User

同时额外保存:

plugin_id
plugin_namespace
plugin_root

所以不能只看 Scope 判断 Skill 是否来自 Plugin。

38. Root 顺序和最终排序解决不同问题

Root 最初按配置优先级收集:

Highest Precedence First

路径去重保留第一个 Root。

各 Root 加载完成后,Skill 又按:

Scope Rank
Name
Path

排序。

当前 Scope Rank 是:

Repo = 0
User = 1
System = 2
Admin = 3

两次排序的目的不同:

Root 去重
  -> 决定同一路径由哪个来源拥有

Skill 排序
  -> 形成稳定的模型展示和选择顺序

39. Skill 发现是有界扫描

discover_skills 不会无界递归整个磁盘。

当前限制包括:

const MAX_SCAN_DEPTH: usize = 6;
const MAX_SKILLS_DIRS_PER_ROOT: usize = 2000;
const MAX_SKILLS_ENTRIES_PER_ROOT: usize = 20_000;
const MAX_CONCURRENT_SKILL_LOADS: usize = 64;

这同时控制:

目录深度
目录数量
总条目数量
并发解析数量

如果扫描被截断,会产生 Warning,而不是假装得到了完整目录。

这样可以防止:

错误地把仓库根目录当 Skill Root
  -> 遍历巨量 node_modules 或构建产物
  -> 启动时间和内存不可控

40. Hidden Directory 与 Symlink 策略依 Scope 而变

普通发现会跳过隐藏目录,除非它通过一个可见别名到达。

目录 Symlink 策略则是:

User / Repo / Admin
  -> Follow

System
  -> Ignore

原因可以从信任模型理解:

用户和仓库 Skill 常用 Symlink 组织共享内容
内置 System Skill 应保持固定、可复制的缓存边界

不过跟随 Symlink 后,Loader 会尝试 Canonicalize:

Skill 身份尽量使用规范路径

从而降低同一个文件通过多个别名重复出现的概率。

41. SKILL.md 必须有 YAML Frontmatter

最小格式是:

---
name: repo-reader
description: Read this repository and summarize its architecture.
---

# Instructions

Inspect the repository before answering.

Loader 首先提取由:

---
...
---

包围的 Frontmatter。

当前核心字段:

name: optional
description: required
metadata:
  short-description: optional

name 缺失时,默认使用:

SKILL.md 所在目录名

description 为空会导致该 Skill 加载失败。

42. Frontmatter 有明确长度限制

当前限制包括:

name <= 64 chars
qualified name <= 128 chars
description <= 1024 chars
short description <= 1024 chars
default prompt <= 1024 chars

Loader 还会:

把单行字段中的空白规范化
修复少量常见的未加引号 YAML 标量
对真正无效的 YAML 保留错误

它不会用宽松字符串切割替代 YAML Parser。

43. agents/openai.yaml 是可选扩展元数据

一个 Skill 可以附带:

<skill-dir>/
  SKILL.md
  agents/
    openai.yaml

该文件可声明:

interface:
  display_name: Repository Reader
  short_description: Inspect source before answering
  icon_small: ./assets/icon-small.png
  icon_large: ./assets/icon-large.png
  brand_color: "#4F46E5"
  default_prompt: Analyze this repository

dependencies:
  tools:
    - type: mcp
      value: source-index
      description: Search the source index
      transport: streamable_http
      url: https://example.com/mcp

policy:
  allow_implicit_invocation: true
  products:
    - codex

元数据文件解析采用 Fail Open:

openai.yaml 缺失或无效
  -> 忽略可选元数据
  -> 不阻止合法 SKILL.md 被发现

SKILL.md 本身无效则是 Skill Load Error。

44. SkillMetadata 不保存正文

核心模型是:

pub struct SkillMetadata {
    pub name: String,
    pub description: String,
    pub short_description: Option<String>,
    pub interface: Option<SkillInterface>,
    pub dependencies: Option<SkillDependencies>,
    pub policy: Option<SkillPolicy>,
    pub path_to_skills_md: AbsolutePathBuf,
    pub scope: SkillScope,
    pub plugin_id: Option<String>,
}

注意没有:

pub contents: String

虽然发现阶段为了解析 Frontmatter 会读一次文件,但长期快照只保存元数据和定位符,不把所有正文常驻内存。

真正注入某个 Skill 时再读取完整文件。

这减少了:

大批未使用 Skill 正文的常驻对象
每次 Prompt 构建的无效复制
模型上下文的固定成本

45. SkillsService 发布不可变快照

SkillsService 负责:

Host Skill Discovery
Immutable Snapshot
Cache Invalidation
Extra Roots
Bundled Skill 安装或移除

核心快照是:

pub struct HostSkillsSnapshot {
    outcome: Arc<SkillLoadOutcome>,
}

SkillLoadOutcome 保存:

skills
errors
disabled_paths
skill_roots
path -> root
path -> filesystem
implicit path indexes

它同时保存每个 Skill 对应的 ExecutorFileSystem

因此在 Environment 文件系统中发现的 Skill,正文读取仍通过同一个 Environment,而不是错误地使用 Host Local FS。

46. Skills Cache 不能只按 cwd

SkillsService 有两类缓存:

cache_by_cwd
cache_by_config

Config Cache Key 包含:

Root Path
Scope Rank
Plugin ID
Plugin Namespace
Skill Config Rules

原因是两个 Session 即使 cwd 相同,也可能有不同:

Session Flags
Role Local Override
Plugin Enablement
Skill Disable Rules

如果只按 cwd 缓存,会把一个 Session 的 Skill 策略泄漏到另一个 Session。

47. Skill Policy 控制 Prompt 可见性,不限制显式读取

SkillPolicy 中:

pub struct SkillPolicy {
    pub allow_implicit_invocation: Option<bool>,
    pub products: Vec<Product>,
}

allow_implicit_invocation 缺省为:

true

它影响:

是否出现在模型可见的 Available Skills 列表

但一个被隐藏于隐式列表的 Skill 仍可通过明确结构化选择被读取,前提是它没有被整体禁用。

这支持:

只允许用户显式触发的高成本或敏感工作流。

48. Skill 元数据受独立上下文预算约束

模型可见 Skill 列表不是无限长。

Core Host Skill Renderer 的默认预算是:

有 Context Window
  -> Context Window 的 2%

无 Context Window
  -> 8000 Characters

对应:

pub enum SkillMetadataBudget {
    Tokens(usize),
    Characters(usize),
}

预算不足时,Core Renderer 按阶段降级:

优先尝试绝对路径
必要时尝试路径别名
截短 Description
极端情况下移除 Description
最后省略额外 Skill

并生成 Warning:

Description 被缩短
或
有多少 Skill 未进入模型可见列表

ext/skills Catalog Renderer 当前使用另一组边界:

Available Skills <= 8000 Bytes
Selected Main Prompt <= 8000 Bytes
Skill Name <= 256 Bytes
Rendered Path <= 1024 Bytes

因此当前迁移态不能把所有 Skill 路径都概括为同一个 2% 算法。

共同原则仍然是:

目录元数据有独立上限
完整正文只对选中 Skill 注入
超限必须截断或省略并产生可观察结果

49. 两阶段上下文是 Skills 的核心设计

Skills 在模型上下文中分两阶段。

第一阶段是 Discovery:

name
description
source locator
usage instructions

第二阶段是 Selection:

完整 SKILL.md

显式选择路径可以表示为:

Thread / Turn Start
  -> render Available Skills Metadata

User explicitly mentions Skill
  -> read exact source
  -> inject full Skill Instructions

如果模型根据 Description 判断某个 Skill 适用,则它遵循 Usage Instructions:

Host File Skill
  -> 调用文件读取工具

Orchestrator Skill
  -> 调用 skills.read

Environment Skill
  -> 通过所属 Environment 读取

此时正文通过对应 Tool Result 进入上下文,不是 Host Selector 自动注入。

这种 Progressive Disclosure 避免把所有 Skill 正文放入每次请求。

50. 显式 Skill 选择优先使用结构化路径

用户输入可能包含:

UserInput::Skill { name, path, ... }

也可能是文本:

$repo-reader

或资源链接:

[$repo-reader](skill:///absolute/path/SKILL.md)

Legacy Core Selector 的解析顺序是:

1. 先处理结构化 UserInput::Skill
2. 再扫描文本 Mention
3. 显式路径按路径精确解析
4. 纯名称只在不歧义、且不与 Connector 冲突时解析
5. 按 Skills 原有顺序输出

路径比名称更稳定,因为多个 Scope 或 Plugin 可能提供同名 Skill。

新 Skills Extension 同样优先处理结构化路径和 skill:// 链接,但其纯名称路径当前使用:

Catalog 中第一个 enabled 且 name 相等的 Entry

它尚未复用 Legacy Selector 的名称计数和 Connector 冲突检查。

51. 过渡期内不要依赖重名 Skill 的纯名称选择

Legacy Selector 会统计:

enabled skill name counts
connector slug counts

Legacy 路径中,纯名称只有在唯一时才可解析。

如果:

Repo Skill = deploy
Plugin Skill = deploy

如果用户只写:

$deploy

Legacy Selector 会跳过该 Mention。

但当 Skills Extension 已接管正文注入时,当前实现可能选中 Catalog 顺序中的第一个启用项。

这不是一个适合调用方依赖的稳定冲突策略。

同样,如果名称同时像 Connector:

$github

Legacy 纯文本选择会避免把 App Mention 错当成 Skill。

跨两条实现路径都稳定的做法是使用:

UserInput::Skill
或
显式 skill:// Locator

后续如果两个 Selector 统一冲突语义,应增加覆盖重名 Skill 与 Connector Slug 的回归测试。

52. 被选中的 Skill 才读取完整正文

正文注入入口是:

pub async fn build_skill_injections(
    mentioned_skills: &[SkillMetadata],
    loaded_skills: Option<&SkillLoadOutcome>,
    ...
) -> SkillInjections

对每个选中 Skill:

根据 Outcome 找到原始 FileSystem
-> 读取 path_to_skills_md
-> 记录 Telemetry
-> 构造 SkillInjection

注入对象是:

pub struct SkillInjection {
    pub name: String,
    pub path: String,
    pub contents: String,
}

读取失败不会让整个 Turn Panic。

它会形成 Warning,并继续处理其他 Skill。

53. 新 Skills Extension 支持多 Authority

Host 文件并不是唯一 Skill 来源。

扩展层定义:

pub trait SkillProvider: Send + Sync {
    fn list(
        &self,
        query: SkillListQuery,
    ) -> SkillProviderFuture<'_, SkillCatalog>;

    fn read(
        &self,
        request: SkillReadRequest,
    ) -> SkillProviderFuture<'_, SkillReadResult>;

    fn search(
        &self,
        request: SkillSearchRequest,
    ) -> SkillProviderFuture<'_, SkillSearchResult>;
}

当前 Provider 包括:

HostSkillProvider
ExecutorSkillProvider
OrchestratorSkillProvider

对应 Source Kind:

pub enum SkillSourceKind {
    Host,
    Executor,
    Orchestrator,
    Custom(String),
}

54. Skill Resource 必须由原 Authority 读取

扩展层明确规定:

由某个 Provider 列出的 Resource,
必须通过同一个 Provider 和 Authority 读取。

不能把:

orchestrator://opaque/resource

当成本地路径交给:

std::fs::read_to_string

同样,远端 Environment 的 Skill 可能有:

environment_id
PathUri

它必须走该 Environment 的文件系统。

这是能力归属边界,不只是路径格式差异。

55. Extension 与 Legacy 注入会主动去重

迁移期间,Host Skill 可能同时经过:

Skills Extension
Legacy Core Injection

为避免同一正文重复进入 Prompt,扩展会记录:

pub struct InjectedHostSkillPrompts {
    paths: HashSet<String>,
}

Legacy 注入读取这个 Turn Extension Data。

如果路径已由 Extension 注入,则不应再发送一份。

这是过渡架构中重要的幂等控制。

56. “隐式调用”有两个不同含义

Skills 中容易混淆两种 Implicit。

第一种是模型选择:

模型看到 Skill Description
-> 判断任务匹配
-> 主动读取 SKILL.md

第二种是运行时使用检测:

模型已经运行 Skill 目录中的脚本
或读取 SKILL.md
-> Core 识别这是一次 Skill 使用
-> 记录 Telemetry

第二种由:

detect_implicit_skill_invocation_for_command

实现。

它不是在命令执行后补注入正文。

57. 命令使用检测建立两类路径索引

SkillLoadOutcome 为已启用 Skill 建立:

scripts directory -> SkillMetadata
SKILL.md path -> SkillMetadata

检测器解析 Shell Command:

python
python3
bash
zsh
sh
node
deno
ruby
perl
pwsh

并识别常见脚本扩展名:

.py
.sh
.js
.ts
.rb
.pl
.ps1

还会通过 Shell Command Parser 识别读文件命令。

同一 Turn 内使用:

scope:path:name

去重,避免一个脚本多次运行产生重复埋点。

58. Skill 可以声明 MCP 依赖

agents/openai.yaml 中的 Tool Dependency 可以声明:

type = mcp
value = logical server name
transport
command or url

当用户显式选择 Skill 时,Core 可以检查:

该 Skill 所需 MCP 是否已安装

如果缺失,并且:

skill_mcp_dependency_install_enabled = true

则进入受控安装或 Elicitation 流程。

这形成:

Skill
  -> 描述工作流
  -> 声明所需能力
  -> MCP 提供执行面

Skill 自己仍不是 Tool。

59. SkillsWatcher 只做失效通知

App Server 创建一个共享:

pub(crate) struct SkillsWatcher {
    subscriber: FileWatcherSubscriber,
    runtime_extra_roots_registration: Mutex<WatchRegistration>,
    shutdown_token: CancellationToken,
    _shutdown_drop_guard: DropGuard,
}

它监听:

Thread 对应的非 Plugin Skill Roots
Runtime Extra Roots

Plugin Root 被排除:

Plugin 生命周期操作负责使其缓存失效。

收到文件事件后,Watcher:

clear SkillsService cache
send skills/changed notification

它不会在文件系统回调线程里重新解析所有 Skill。

60. Skill 文件事件有节流窗口

生产环境节流间隔是:

Duration::from_secs(10)

测试中缩短为:

Duration::from_millis(50)

原因是编辑器保存一个文件可能产生:

临时文件创建
Rename
Write
Metadata Change

如果每个事件都全量清缓存并广播,会造成重复计算。

Watcher 聚合事件后只做一次失效。

61. Skill 热更新不会修改正在构建的 Prompt

Turn 使用的是:

HostSkillsSnapshot

文件变化只会:

清 Service Cache
通知客户端

已经取得旧 Snapshot 的 Turn 仍继续使用旧元数据视图。

后续重新加载时才看到新版本。

这与 MCP Runtime Snapshot 使用同一种原则:

读请求使用不可变快照
更新发布新快照
不原地改写读者正在使用的对象

62. Plugin 是安装单元,不是执行单元

通用 Manifest 类型是:

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>>,
}

Manifest 自身没有:

run()
execute()
call()

因为每项能力交给自己的 Runtime。

63. Plugin ID 由名称和 Marketplace 组成

配置中使用的 Plugin Key 表示:

<plugin-name>@<marketplace-name>

PluginId 负责解析和校验。

这样同名 Plugin 可以来自不同 Marketplace,而不会共享安装身份。

Plugin ID 还参与:

安装目录
数据目录
MCP Provenance
Hook Persisted Key
Skill Namespace
Telemetry

它比 Manifest Display Name 更稳定。

64. Marketplace 描述候选,不等于已安装状态

Marketplace Manifest 可位于:

.agents/plugins/marketplace.json
.agents/plugins/api_marketplace.json
.claude-plugin/marketplace.json

一个 Entry 包含:

name
source
policy
interface
keywords
manifest fallback

Source 可以是:

pub enum MarketplacePluginSource {
    Local { path: AbsolutePathBuf },
    Git {
        url: String,
        path: Option<String>,
        ref_name: Option<String>,
        sha: Option<String>,
    },
    Npm {
        package: String,
        version: Option<String>,
        registry: Option<String>,
    },
}

Marketplace 只说明:

可以从哪里获得这个 Plugin。

真正是否安装,要查询 PluginStore

65. Marketplace Policy 控制安装与认证时机

安装策略是:

pub enum MarketplacePluginInstallPolicy {
    NotAvailable,
    Available,
    InstalledByDefault,
}

认证策略是:

pub enum MarketplacePluginAuthPolicy {
    OnInstall,
    OnUse,
}

语义分别是:

NotAvailable
  -> 不允许从该 Marketplace 安装

Available
  -> 用户可选择安装

InstalledByDefault
  -> 同步流程可以默认安装

OnInstall
  -> 安装阶段完成认证

OnUse
  -> 首次真正使用能力时再认证

此外 Marketplace Entry 可以带 Product Restriction。

不匹配当前 Product 的 Entry 在安装准入阶段被拒绝。

66. Plugin Store 分离 Bundle 与持久数据

两个根目录是:

pub const PLUGINS_CACHE_DIR: &str = "plugins/cache";
pub const PLUGINS_DATA_DIR: &str = "plugins/data";

安装 Bundle 位于:

$CODEX_HOME/plugins/cache/
  <marketplace>/
    <plugin>/
      <version>/

Plugin 数据位于:

$CODEX_HOME/plugins/data/
  <plugin>-<marketplace>/

分离的原因是:

Bundle 可以升级、替换或卸载
持久数据不应随版本目录原子替换而丢失

Plugin Hook 可通过环境变量访问:

PLUGIN_ROOT
PLUGIN_DATA
CLAUDE_PLUGIN_ROOT
CLAUDE_PLUGIN_DATA

后两个用于兼容已有 Plugin。

67. Active Version 不是简单取目录遍历第一个

PluginStore::active_plugin_version 会:

1. 只保留合法版本目录
2. 按版本比较排序
3. 如果存在 local,优先 local
4. 否则选择最高版本

local 用于本地开发或无正式版本的 Bundle。

因此 Active Plugin Root 是一个派生状态:

plugin_base_root
  + active_plugin_version

不是额外维护一个容易失真的符号链接。

68. 安装使用原子目录替换

Store 安装前会校验:

Source 是目录
Manifest 可读取
Manifest Name 与 Marketplace Plugin Name 一致
Version Segment 合法

随后把 Bundle 安装到版本目录,并以原子替换方式发布。

这样调用方不会观察到:

已经出现目标目录
但文件只复制了一半

远端 Plugin Identity 另存为带 Schema Version 的 Metadata 文件,并通过临时文件持久化。

69. Manifest 有两个兼容位置

Plugin Manifest 可从以下位置发现:

.codex-plugin/plugin.json
.claude-plugin/plugin.json

优先顺序由:

DISCOVERABLE_PLUGIN_MANIFEST_PATHS

固定。

如果已启用的 Plugin:

没有合法 Manifest

LoadedPlugin.error 会记录:

missing or invalid plugin.json

它不会作为 Active Plugin 继续投影部分能力。

70. Manifest 路径必须留在 Plugin Root

Manifest 中的路径字段必须:

以 ./ 开头
不能只是 ./
不能包含 ..
不能是绝对路径
解析后仍以 Plugin Root 为前缀

例如:

{
  "skills": "./skills",
  "mcpServers": "./.mcp.json",
  "apps": "./.app.json",
  "hooks": "./hooks/hooks.json"
}

合法。

以下路径会被忽略:

../shared/skills
/etc/codex/hooks.json
C:\outside\config.json

这阻止一个已安装 Bundle 通过 Manifest 把任意 Host 文件伪装成自己的能力资源。

71. Interface Metadata 与 Runtime Capability 分离

Plugin Interface 可以包含:

display name
short and long description
developer name
category
capabilities
website
privacy policy
terms
default prompts
brand color
icons
logos
screenshots

这些字段主要服务:

Marketplace UI
Plugin Picker
Prompt 中的能力摘要
推荐入口

它们不能替代能力声明。

例如:

{
  "interface": {
    "capabilities": ["search"]
  }
}

不会自动注册一个 Search Tool。

Runtime 仍只读取 paths 中的真实能力。

72. Interface 默认 Prompt 也有严格限制

当前最多允许:

3 条 Default Prompt

每条最多:

128 Characters

解析器会:

压平多余空白
忽略空字符串
忽略类型错误
超过数量时截断并记录 Warning

这避免 Marketplace Metadata 变成无限大的隐式 Prompt 注入渠道。

73. Plugin Loader 先判断 Active,再加载能力

load_plugin 的基本顺序是:

解析 Plugin ID
-> 查 Active Root
-> 检查 enabled
-> 检查已安装
-> 检查目录
-> 加载 Manifest
-> 加载所需能力
-> 汇总 Hook Source 和 Warning

Active 的定义是:

pub fn is_active(&self) -> bool {
    self.enabled && self.error.is_none()
}

因此:

enabled = true

但未安装,仍不是 Active。

同样,已安装但配置禁用,也不会投影 Runtime 能力。

74. Plugin 默认能力路径有约定

Manifest 未显式指定时,Loader 使用:

Skills
  -> skills/

MCP
  -> .mcp.json

Apps
  -> .app.json

Hooks
  -> hooks/hooks.json

但有一个细节:

默认 Skills Root 只在 skills/ 目录真实存在时加入。

显式 Manifest 路径则按 Manifest 解析结果处理。

75. Plugin Skill 会获得 Namespace 和 Provenance

Plugin Skill Root 被转换为:

pub struct PluginSkillRoot {
    pub path: AbsolutePathBuf,
    pub plugin_id: String,
    pub plugin_namespace: String,
    pub plugin_root: AbsolutePathBuf,
}

其中 Namespace 默认来自:

Plugin Manifest Name

Skill Loader 会把基础名称限定到该 Namespace,生成稳定 Qualified Name。

同时保存 plugin_id,用于:

Telemetry
能力摘要
冲突诊断
Plugin 明确提及时的引导

76. Plugin MCP 可以内联或引用文件

Manifest 的 MCP 声明有两种:

pub enum PluginManifestMcpServers<Resource> {
    Path(Resource),
    Object(String),
}

例如引用文件:

{
  "name": "issue-tools",
  "mcpServers": "./.mcp.json"
}

也可以直接内联 Server Object。

无论哪种形式,最终都进入:

parse_plugin_mcp_config

并转换为普通 McpServerConfig

用户配置只能覆盖 Plugin MCP 的策略部分:

enabled
default tool approval
enabled tools
disabled tools
per-tool approval

Transport 仍由 Plugin Manifest 所有。

77. Plugin App 与同名 MCP Server 不能双重路由

当当前 Auth Mode 支持 Apps Route,且 Plugin 声明了 App:

App Declaration Name

与某个 Plugin MCP Server Name 相同,Loader 会移除同名 MCP Server。

原因是同一能力可能有两种后端:

普通 Plugin MCP
ChatGPT-hosted Codex Apps MCP

同时保留会导致:

重复 Tool
重复认证
来源归属不一致

如果当前 Auth Mode 不支持 Codex Backend:

Apps 被清空
普通 MCP Route 可以保留

78. Plugin Hook 可以来自路径或内联对象

Hook Manifest 支持:

pub enum PluginManifestHooks<Resource> {
    Paths(Vec<Resource>),
    Inline(Vec<HooksFile>),
}

路径可以是一个或多个 JSON 文件。

内联对象则直接放在 plugin.json

为了形成稳定 Persisted Key,内联 Hook 的 Source Relative Path 形如:

plugin.json#hooks[0]

外部文件则使用:

hooks/hooks.json

79. PluginLoadOutcome 是能力投影入口

加载结果同时提供:

effective_plugin_skill_roots()
effective_mcp_servers()
effective_apps()
effective_plugin_hook_sources()
effective_plugin_hook_warnings()
capability_summaries()

每个方法只遍历:

plugin.is_active()

Capability Summary 包含:

pub struct PluginCapabilitySummary {
    pub config_name: String,
    pub display_name: String,
    pub description: Option<String>,
    pub has_skills: bool,
    pub mcp_server_names: Vec<String>,
    pub app_connector_ids: Vec<AppConnectorId>,
}

它是模型和 UI 的摘要,不包含 Hook。

Hook 不应仅因模型看到 Plugin 摘要就自动触发,Hook 由事件系统独立驱动。

80. Plugin 缓存使用 Generation 防止过期回写

PluginsManager 维护:

Loaded Plugin Cache
Plugin Skill Snapshots
Remote Installed Cache
Recommended Plugin Cache
Marketplace Refresh State

Plugin Load 可能异步执行。

如果加载期间发生缓存失效:

旧加载任务完成后
不能把过期结果重新写回已清空缓存。

所以 Cache 保存:

generation

加载开始时记录 Generation。

完成时只有:

current generation == captured generation

才写入 Cache Entry。

81. Plugin Load 使用 Semaphore 避免重复重建

PluginsManager 还持有:

loaded_plugins_load_semaphore: Semaphore

Permit 数量是:

1

流程是:

先查 Cache
-> Miss
-> 获取 Permit
-> 再查一次 Cache
-> 仍 Miss 才真正加载

第二次检查防止多个并发请求在排队后重复扫描相同 Plugin Bundle。

82. Plugin 变化会联动三类失效

App Server 在有效 Plugin 集合变化后执行:

PluginsManager.clear_cache()
SkillsService.clear_cache()
queue_best_effort_refresh(all threads)

分别对应:

Plugin Manifest 和能力摘要
Plugin Skill Root 与 Skill Metadata
Plugin MCP Server 和 Apps Connector Runtime

Hook Engine 通常随新的 Thread 或配置重建路径重新解析。

这不是一个统一的全局锁内原地更新。

每个子系统按自己的快照和生命周期刷新。

83. Plugin 安装不等于当前 Turn 立刻拥有新 Tool

安装成功只保证:

Bundle 已进入 Store
Config 已记录 Enablement
相关 Cache 已失效
MCP Refresh 已排队

当前 Sampling 已经构造的:

ToolRouter
McpRuntimeSnapshot
Skill Snapshot

不会在中途被修改。

新能力通常在:

后续 Step
后续 Sampling
或显式 Refresh 完成后

出现。

84. App、Connector 与 Codex Apps MCP 是三个概念

三者可以这样区分:

App
  -> Plugin 或目录中声明的产品级能力

Connector
  -> App 的稳定身份和可访问性元数据

Codex Apps MCP
  -> 承载多个 Connector Tool 的保留 MCP Server

一个 App Declaration 主要包含:

name
connector_id
category

真正的 Tool 由:

codex_apps MCP Server

返回,并通过 Tool Metadata 标记所属 Connector。

85. ConnectorSnapshot 保留 Plugin 归属

Plugin 声明会形成:

PluginConnectorSource

其中保存:

Plugin ID
Plugin Display Name
Connector IDs

多个来源合并为:

ConnectorSnapshot

Snapshot 既能返回有序 Connector ID,也能查询:

某个 Connector 来自哪些 Plugin Display Name

这用于 Prompt 引导、UI 展示和工具来源归属。

86. App Tool 暴露至少经过三层过滤

Codex Apps MCP Tool 进入模型前,必须满足:

1. Tool Meta 对 model 可见
2. connector_id 在当前可用 Connector 集合中
3. AppToolPolicyEvaluator 判定 enabled

普通 MCP Tool 只需:

非 codex_apps Server
且 model visible

App Tool 额外受 Connector 可访问性约束,因为 Codex Apps Server 可能返回当前账号可见但本次 Plugin 或配置未启用的 Tool。

87. App Tool Policy 同时看用户配置与 Managed Requirements

AppToolPolicyEvaluator 合并:

普通 Config Layer 中的 apps 配置
Requirements 中的 Managed Apps 限制

策略输入包括:

connector_id
tool_name
tool_title
destructive_hint
open_world_hint

输出是:

pub struct AppToolPolicy {
    pub enabled: bool,
    pub approval: AppToolApproval,
}

Managed Requirement 可以强制关闭 App,普通用户配置不能把它重新打开。

88. App Tool 的 Approval 有多级回退

Approval 解析顺序可概括为:

Managed Per-Tool Approval
-> App Per-Tool Approval
-> App Default Tool Approval
-> Global App Default Approval
-> Auto

Enablement 还会考虑:

App Enabled
Per-Tool Enabled
App Default Tools Enabled
Destructive Hint
Open World Hint

这比普通 MCP Server 的:

default_tools_approval_mode
tools.<name>.approval_mode

多了一层 Connector 与组织策略。

89. Connector 认证失败可以转成 URL Elicitation

Codex Apps Tool Result 可以携带受信任的认证失败 Metadata。

Core 会验证:

返回的 Connector ID
必须与当前 Tool Metadata 的 Connector ID 一致

避免不受信任 Tool Result 把用户导向另一个 Connector 的认证地址。

验证通过后:

Tool Error
-> 构造 URL Elicitation
-> 用户完成认证
-> Hard Refresh Codex Apps Tool Cache
-> 更新 Accessible Connector Cache

如果审批策略禁止 Elicitation,则保留原 Tool Result,不弹认证流程。

90. Hooks 是独立的生命周期引擎

Hook Engine 的核心是:

pub(crate) struct ClaudeHooksEngine {
    handlers: Vec<ConfiguredHandler>,
    warnings: Vec<String>,
    shell: CommandShell,
    output_spiller: HookOutputSpiller,
}

它负责:

发现配置
验证 Matcher
验证信任
选择 Handler
运行 Command
解析事件专属输出
限制进入模型的输出

Hook 不是一个简单:

Vec<fn(Event)>

而是带配置来源、进程执行、审计状态和上下文输出的完整子系统。

91. Hook 共有十类事件

当前事件集合是:

PreToolUse
PermissionRequest
PostToolUse
PreCompact
PostCompact
SessionStart
UserPromptSubmit
SubagentStart
SubagentStop
Stop

按作用域分:

Thread Scope
  SessionStart
  SubagentStart

Turn Scope
  PreToolUse
  PermissionRequest
  PostToolUse
  PreCompact
  PostCompact
  UserPromptSubmit
  SubagentStop
  Stop

Scope 影响事件记录和 UI 展示,不代表 Thread Scope Hook 只会执行一次。

例如 Session Start 可以有:

startup
resume
compact

等来源。

92. 只有八类事件使用 Matcher

支持 Matcher 的事件是:

PreToolUse
PermissionRequest
PostToolUse
PreCompact
PostCompact
SessionStart
SubagentStart
SubagentStop

以下两类忽略 Matcher:

UserPromptSubmit
Stop

因为它们没有需要按工具名、压缩类型或启动来源分发的目标键。

为它们配置 Matcher 不会按用户 Prompt 文本做正则过滤。

93. Hook 来源包含本地、托管与 Plugin

Hook Discovery 依次合并:

Managed Requirements
System Config
User Config
Project Config
MDM
Enterprise Managed Config
Session Flags
Legacy Managed Sources
Plugin Hook Sources

每个 Handler 保存:

source path
HookSource
managed flag
plugin id
display order
environment

Requirements 还可以设置:

allow_managed_hooks_only

启用后,未受管 User、Project 和 Plugin Hook 不进入执行集合。

94. JSON Hook 与 TOML Hook 可以并存但会告警

一个 Config Layer 可以从:

hooks.json
config.toml [hooks]

读取 Hook。

如果两者都非空,Codex 会发出 Warning:

prefer a single representation for this layer

它不会静默只选其中一个。

这样兼容迁移期配置,同时提醒用户避免难以判断的重复 Handler。

95. 未受管 Hook 必须建立内容信任

每个 Command Hook 都会生成规范化 Identity:

event name
normalized matcher group
normalized command handler
effective timeout

再通过 Config Fingerprint 形成:

sha256:<hash>

信任状态是:

pub enum HookTrustStatus {
    Managed,
    Trusted,
    Modified,
    Untrusted,
}

规则是:

Managed Source
  -> Managed

trusted_hash == current_hash
  -> Trusted

存在 trusted_hash 但不相等
  -> Modified

没有 trusted_hash
  -> Untrusted

96. Hook 信任是配置语义的信任

Hash 使用规范化后的 Handler Identity,而不是原始文件字节。

因此:

JSON 与 TOML 表达同一有效配置

可以收敛到同一信任身份。

而真正改变:

Command
Matcher
Timeout
Event

会改变 Hash。

这比对整个配置文件做字节 Hash 更精确,因为无关空白和其他 Hook 改动不应让所有 Handler 同时失信。

97. Enabled 与 Trusted 是两条独立状态

Hook State 保存:

pub struct HookStateToml {
    pub enabled: Option<bool>,
    pub trusted_hash: Option<String>,
}

一个 Handler 可能:

Enabled + Untrusted

此时它会出现在 Hook List,但不会进入 Runtime Handler 集合。

也可能:

Disabled + Trusted

此时仍不执行。

只有:

Enabled
and
(Managed or Trusted or explicit bypass)

才执行。

98. Hook State 只能由 User 与 Session Flags 覆盖

hook_states_from_stack 只读取:

User Layer
Session Flags

并按字段合并。

Project、Managed 和 Plugin Layer 可以发现 Hook,但不能自行写入用户信任状态。

否则一个项目可以:

声明恶意 Hook
同时声明自己已被用户信任

信任边界就失去意义。

99. Plugin Hook 有稳定 Persisted Key

Plugin Hook Key 形如:

<plugin-id>:<source-relative-path>:<event>:<group-index>:<handler-index>

例如:

demo@test:hooks/hooks.json:pre_tool_use:0:1

这样 Plugin 安装到不同绝对缓存版本目录后,Key 仍保持稳定。

如果直接把 Active Version 的绝对路径写入 Key:

Plugin 升级
-> 路径变化
-> 所有 Hook 都变成全新未信任项

当前设计把 Package 身份和包内相对位置作为稳定来源。

100. 当前只执行同步 Command Hook

配置模型可以表达:

Command
Prompt
Agent

也可以带:

async = true

但当前 Runtime:

Prompt Hook
  -> Warning 并跳过

Agent Hook
  -> Warning 并跳过

Async Command Hook
  -> Warning 并跳过

Sync Command Hook
  -> 支持

所以文档或 Plugin Manifest 中出现某种声明形态,不等于当前执行器已经实现该形态。

101. Hook Command 接收 JSON Stdin

Command Runner:

设置 cwd
打开 stdin/stdout/stderr Pipe
kill_on_drop(true)
启动 Shell Command
把事件 JSON 写入 stdin
等待退出或超时

非 Windows 默认:

$SHELL -lc <command>

Windows 默认:

%COMSPEC% /C <command>

每个事件有独立 JSON Schema。

例如 Tool Hook 输入包含:

session_id
turn_id
cwd
model
permission_mode
tool_name
tool_input
tool_use_id

102. 同一事件的多个 Hook 并发执行

Dispatcher 使用:

FuturesUnordered

同时运行所有匹配 Handler。

完成后记录:

completion_order

再按:

configured_order

排序结果,用于稳定展示和大部分聚合。

这兼顾:

并行降低延迟
输出顺序可预测

PreToolUse 多个输入改写有特殊规则:

最后实际完成的 Rewrite 胜出。

所以 Completion Order 也被保留。

103. Matcher 只决定选择,不改变审计名称

一个 Tool 可以有:

Canonical Hook Name
Compatibility Matcher Aliases

例如内部工具可能兼容:

Bash
shell_command
exec_command

Dispatcher 用所有 Alias 匹配正则,但同一 Handler 最多执行一次。

写入 Hook Stdin 的仍是 Canonical Name。

这样审计日志不会因命中哪个 Alias 而改变 Tool 身份。

104. Hook Exit Code 与 JSON 输出共同决定结果

通用规则可以概括为:

Exit 0
  -> 解析事件专属 JSON

Exit 2
  -> stderr 作为阻断或反馈原因

其他 Exit
  -> Hook Failed,但通常不让整个 Runtime Panic

Timeout / Spawn Error
  -> Hook Failed

每种事件对 JSON 字段的解释不同。

不能把一个 Stop Hook 的输出直接用于 PreToolUse

Parser 会对不支持字段生成明确 Warning 或 Error。

105. PreToolUse 可以阻断、改写和注入上下文

输出聚合为:

pub struct PreToolUseOutcome {
    pub hook_events: Vec<HookCompletedEvent>,
    pub should_block: bool,
    pub block_reason: Option<String>,
    pub additional_contexts: Vec<String>,
    pub updated_input: Option<Value>,
}

决策规则:

任一 Handler Block
  -> 整次 Tool Call Block
  -> 不采用 Rewrite

没有 Block
  -> 使用最后完成的 updated_input

所有 Additional Context
  -> 记录进 Turn Context

输入改写不是任意修改内部对象。

Tool Runtime 必须实现:

Hook-facing Tool Input
<-> ToolInvocation

的稳定转换。

106. PermissionRequest 位于用户审批之前

该 Hook 的位置是:

Tool 已判断需要审批
-> PermissionRequest Hook
-> Guardian 或 User Approval

结果只有:

pub enum PermissionRequestDecision {
    Allow,
    Deny { message: String },
}

多个 Handler 的聚合策略是:

任一 Deny
  -> Deny

否则存在 Allow
  -> Allow

否则
  -> None,继续正常审批链

这是一种保守折叠。

低优先级 Allow 不能覆盖任何 Deny。

107. PreToolUse Allow 不等于 Permission Approval

PreToolUse 主要决定:

这个 Tool Call 是否允许进入 Handler
输入是否需要改写
模型是否需要额外上下文

PermissionRequest 决定:

已经需要审批的动作是否直接 Allow 或 Deny

所以:

PreToolUse 没有 Block

不代表:

跳过审批。

两者必须分开配置。

108. PostToolUse 无法撤销已发生的副作用

PostToolUse 接收:

tool_input
tool_response

并能:

追加模型上下文
返回反馈
阻止当前 Tool Result 以原样继续
把 continue:false 记录为 Stopped Hook Result

但执行顺序已经是:

Tool Side Effect
-> Tool Output
-> PostToolUse

所以 Post Hook Block 的真实含义是:

不要把成功结果当作可继续依据,
向模型反馈问题。

如果返回 continue:false,当前实现同样生成 Feedback,并用它替换模型可见的原 Tool Output。

它不会直接把整个 Turn 切换到终止状态。

它不能回滚文件、命令或远端 API。

授权和副作用防护必须放在 Pre 或 Permission 阶段。

109. SessionStart 与 UserPromptSubmit 可以注入上下文

两者都能产生:

additional_contexts
should_stop
stop_reason

典型用途:

SessionStart
  -> 注入组织环境状态
  -> 恢复时提示外部上下文变化

UserPromptSubmit
  -> 在用户输入进入采样前补充策略上下文
  -> 拒绝不完整的输入

SubagentStart 主要用于上下文注入。

源码明确限制:

continue:false 只由 SessionStart 作为停止信号处理
SubagentStart 保持 Context Injection Only

110. Stop Hook 可以让 Turn 继续采样

模型认为任务完成时,Core 运行:

Stop
或
SubagentStop

结果包括:

pub struct StopOutcome {
    pub hook_events: Vec<HookCompletedEvent>,
    pub should_stop: bool,
    pub stop_reason: Option<String>,
    pub should_block: bool,
    pub block_reason: Option<String>,
    pub continuation_fragments: Vec<HookPromptFragment>,
}

如果 Hook 返回 Block 和非空 Reason:

Reason
-> HookPromptFragment
-> 记录为新的上下文 Item
-> stop_hook_active = true
-> Turn Sampling Loop continue

这就是 Hook 的“续写能力”。

它不是直接生成最终 Agent Answer,而是给模型一条必须继续处理的反馈。

多个 Stop Handler 冲突时还有一个优先级:

任一 Handler 返回 should_stop
  -> should_stop = true
  -> should_block = false

只有没有 should_stop 时
  -> Block 才聚合为续写片段

因此显式停止结果优先于要求继续。

111. Stop Hook 必须提供续写 Prompt

如果 Hook 请求 Block,却没有有效 Reason:

Core 无法构造 HookPromptMessage

此时会记录 Warning,并忽略这次 Block。

原因很直接:

只告诉模型“不能结束”,
却不告诉它还缺什么,
容易形成无信息的无限循环。

stop_hook_active 会传给下一次 Stop Hook,让 Hook 自己识别这是续写后的再次结束。

112. PreCompact 与 PostCompact 观察压缩生命周期

压缩 Hook 的 Matcher 输入通常是:

manual
auto

PreCompact 可在压缩前运行策略。

PostCompact 用于压缩完成后的观察和审计。

它们不替代 ContextManager 的压缩算法,也不能直接提交一份新的 Summary。

Hook 仍通过事件输出影响外围行为。

113. 大 Hook 输出会落盘

进入模型的单段 Hook 文本预算是:

const HOOK_OUTPUT_TOKEN_LIMIT: usize = 2_500;

超过后:

完整输出
  -> OS Temp / hook_outputs / <thread_id> / <uuid>.txt

模型上下文
  -> Head/Tail Preview
  -> Full Output Path

如果创建目录或写文件失败,则退化为普通 Token Truncation。

这避免一个 Hook 用超大 stdout 挤占整个 Turn Context,同时保留排障证据。

114. Hook 生命周期会发布 Started 与 Completed

Core 在执行前先调用 Preview:

preview_pre_tool_use
preview_permission_request
preview_post_tool_use
...

再发布:

HookStarted

执行结束后发布:

HookCompleted

Completed 记录:

status
duration
source
scope
display order
output entries

这让 TUI 或 App Server Client 能在 Hook 运行期间展示独立状态,而不是把 Hook 延迟误认为 Tool 卡住。

115. Hook 失败通常是可观察失败,不是 Session 崩溃

Hook Command 可能:

启动失败
超时
返回未知 Exit Code
输出无效 JSON
使用不支持字段

这些情况通常形成:

HookRunStatus::Failed
HookOutputEntryKind::Error

然后按事件规则继续。

只有合法的:

Block
Deny
Stop

才改变主流程。

这避免一个辅助审计脚本偶发故障直接摧毁整个 Thread。

116. 四种扩展的热更新语义并不相同

把更新边界汇总如下:

机制 失效动作 新版本何时可见 旧版本如何处理
Skill SkillsService Cache 后续 Snapshot 或 List 旧 Turn 保留旧 Snapshot
Plugin 清 Plugin 与 Skill Cache 后续能力解析 已构建能力继续服务当前 Step
MCP 排队 Refresh 并发布新 Runtime 后续 Step 旧 Manager 延迟关闭
Hook 重建 Engine 或新 Thread Config 新 Engine 已开始的 Hook 继续完成

因此“热更新”不是:

所有活跃对象立刻原地变成新版本。

而是:

使新请求选择新快照,
让已开始请求保持一致。

117. 为什么不应给所有扩展加一个全局刷新锁

看似简单的方案是:

锁住所有 Turn
-> 重新加载 Plugins
-> 重新加载 Skills
-> 重启 MCP
-> 重建 Hooks
-> 解锁

但它会带来:

任一慢 MCP 启动阻塞所有会话
文件保存事件暂停无关 Tool Call
刷新失败难以局部降级
旧请求被迫切换到不匹配的新能力

当前设计让:

PluginsManager
SkillsService
McpRuntimeSnapshot
ClaudeHooksEngine

分别拥有自己的缓存或不可变运行时。

协调点只负责:

清缓存
排队刷新
发布通知

118. 最小只读 MCP 示例的目标

实践示例只实现一个 Tool:

repository_summary

它接收:

{
  "path": "/absolute/path"
}

返回:

目录下最多 20 个一级条目

并声明:

readOnlyHint = true

这个示例刻意不执行 Shell,也不递归扫描目录。

它用于验证:

Stdio 启动
Initialize
Tools List
Tool Call
Read-Only Annotation

不是完整生产级 MCP SDK 的替代品。

119. 一个最小 Python Stdio MCP Server

将下面内容放到:

/absolute/path/readonly_mcp.py
#!/usr/bin/env python3
import json
import os
import sys


def send(message):
    sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\n")
    sys.stdout.flush()


def tool_spec():
    return {
        "name": "repository_summary",
        "description": "List at most 20 entries in one local directory.",
        "inputSchema": {
            "type": "object",
            "properties": {"path": {"type": "string"}},
            "required": ["path"],
            "additionalProperties": False,
        },
        "annotations": {"readOnlyHint": True},
    }


def call_tool(arguments):
    path = os.path.abspath(arguments["path"])
    entries = sorted(os.listdir(path))[:20]
    text = json.dumps({"path": path, "entries": entries}, ensure_ascii=False)
    return {"content": [{"type": "text", "text": text}], "isError": False}


def handle(request):
    method = request.get("method")
    if method == "initialize":
        return {
            "protocolVersion": request["params"]["protocolVersion"],
            "capabilities": {"tools": {}},
            "serverInfo": {"name": "readonly-demo", "version": "0.1.0"},
        }
    if method == "tools/list":
        return {"tools": [tool_spec()]}
    if method == "tools/call":
        params = request["params"]
        if params.get("name") != "repository_summary":
            raise ValueError("unknown tool")
        return call_tool(params.get("arguments", {}))
    return None


for line in sys.stdin:
    request = json.loads(line)
    if "id" not in request:
        continue
    try:
        result = handle(request)
        send({"jsonrpc": "2.0", "id": request["id"], "result": result})
    except Exception as error:
        # stdout 只能承载协议消息,诊断信息应写入 stderr。
        print(f"request failed: {error}", file=sys.stderr)
        send({
            "jsonrpc": "2.0",
            "id": request["id"],
            "error": {"code": -32603, "message": str(error)},
        })

关键约束:

stdout
  -> 只能输出 MCP JSON-RPC Frame

stderr
  -> 日志和诊断

Tool
  -> 只读取指定目录一级条目

Schema
  -> additionalProperties = false

生产实现应优先使用正式 MCP SDK,以获得协议版本协商、取消、进度、分页和传输细节支持。

120. 注册并检查最小 MCP Server

注册:

codex mcp add readonly-demo -- \
  python3 /absolute/path/readonly_mcp.py

查看配置:

codex mcp get readonly-demo --json

查看全部 Server:

codex mcp list --json

等价 TOML 形态大致是:

[mcp_servers.readonly-demo]
command = "python3"
args = ["/absolute/path/readonly_mcp.py"]
enabled = true
required = false
startup_timeout_sec = 10
tool_timeout_sec = 30

启动 Codex 后,可以要求:

使用 repository_summary 查看当前仓库的一级目录,
不要运行 Shell 命令。

验证事件:

McpStartupUpdate Starting
McpStartupUpdate Ready
McpStartupComplete
Tool Call
Tool Result

121. 使用仓库内测试 Server 做低成本验证

仓库已经有:

codex-rs/rmcp-client/src/bin/test_stdio_server.rs

可先构建:

cargo build -p codex-rmcp-client \
  --bin test_stdio_server

再注册生成的二进制。

这比从零实现协议更适合验证 Codex Client 行为,因为测试 Server 已覆盖:

Tool
Resource
Resource Template
Image
Parallel Barrier
Sandbox State Meta
Model Visibility Meta

122. 最小本地 Skill 目录

在目标仓库创建:

.agents/
  skills/
    repository-map/
      SKILL.md

SKILL.md

---
name: repository-map
description: Inspect a repository and produce a source-backed architecture map.
metadata:
  short-description: Build a repository architecture map
---

# Repository Map

1. Read the root build and workspace files first.
2. Identify executable entry points before internal modules.
3. Trace one complete request path with exact source paths.
4. Separate protocol types, runtime state, and persistence.
5. Report unknowns instead of inferring unsupported behavior.

这个 Skill 没有脚本和外部依赖。

它只定义一套可复用阅读流程。

123. 为 Skill 添加可选 Interface 与 Policy

可再创建:

.agents/skills/repository-map/agents/openai.yaml
interface:
  display_name: Repository Map
  short_description: Build a source-backed architecture map
  brand_color: "#4F46E5"
  default_prompt: Map this repository

policy:
  allow_implicit_invocation: true
  products:
    - codex

如果希望只允许用户显式选择:

policy:
  allow_implicit_invocation: false

这样它不会进入模型自动路由的 Available Skills 列表,但仍可通过结构化路径选择。

124. 验证 Skill 的两阶段加载

先通过 App Server:

skills/list

或对应客户端界面确认元数据已出现:

name = repository-map
description = ...
path = .../SKILL.md

此时模型上下文只需要 Skill 列表项。

然后在用户输入中明确提及:

$repository-map

或使用客户端生成的:

UserInput::Skill

观察:

Turn Input
  -> SkillInstructions
  -> 完整 SKILL.md 正文

修改 SKILL.md 后,等待 Watcher 节流窗口,再确认收到:

skills/changed

新 Snapshot 应读取新正文,已运行 Turn 不应被原地修改。

125. 最小 Plugin Bundle

如果要把 Skill 与 MCP 一起分发,可以组织为:

repository-tools/
  .codex-plugin/
    plugin.json
  skills/
    repository-map/
      SKILL.md
  .mcp.json
  hooks/
    hooks.json

Manifest:

{
  "name": "repository-tools",
  "version": "0.1.0",
  "description": "Repository inspection workflows and tools.",
  "skills": "./skills",
  "mcpServers": "./.mcp.json",
  "hooks": "./hooks/hooks.json",
  "interface": {
    "displayName": "Repository Tools",
    "shortDescription": "Inspect repositories with source-backed workflows"
  }
}

这里的 Plugin 只负责:

打包
安装
版本
归属

Skill 与 MCP 仍分别进入自己的 Runtime。

126. 最小 PreToolUse Hook

一个只阻止明显危险 Shell 文本的教学示例:

{
  "description": "Block destructive recursive deletion.",
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "^(Bash|shell_command|exec_command)$",
        "hooks": [
          {
            "type": "command",
            "command": "python3 ${PLUGIN_ROOT}/hooks/check_command.py",
            "timeout": 5,
            "statusMessage": "Checking command policy"
          }
        ]
      }
    ]
  }
}

check_command.py 可以从 stdin 解析 JSON。

允许时:

Exit 0
无 stdout

拒绝时:

import json
import sys

payload = json.load(sys.stdin)
command = payload.get("tool_input", {}).get("command", "")
if "rm -rf" in command:
    print("Recursive force deletion is forbidden.", file=sys.stderr)
    raise SystemExit(2)

这个字符串检查只适合作为 Hook 数据流示例,不足以作为生产 Shell Policy Parser。

生产环境应使用结构化命令解析或已有 Exec Policy。

127. Plugin Hook 安装后仍需要信任

普通 Plugin Source 不是 Managed Source。

因此安装 Bundle 后:

Hook 可以被 hooks/list 发现
但默认 Trust Status 可能是 Untrusted

客户端应展示:

key
command
source
plugin id
current hash
trust status

用户确认后,把:

trusted_hash = current_hash

写入 User Hook State。

Plugin 升级改变 Command 或 Matcher 后,状态会变为:

Modified

需要重新确认。

128. 一条 Plugin 能力进入 Turn 的完整链

以同时提供 Skill、MCP 和 Hook 的 Plugin 为例:

Marketplace Entry
  -> PluginsManager::install
  -> PluginStore Atomic Install
  -> User Config enabled
  -> PluginsManager Cache Invalidate
  -> load_plugin
       -> load_plugin_skills
       -> load_plugin_mcp_servers
       -> load_plugin_hooks
  -> Skill Root enters SkillsService
  -> MCP Registration enters McpCatalog
  -> Hook Source enters Hook Discovery
  -> Session publishes new MCP Runtime
  -> next Turn sees Skill Metadata
  -> matching lifecycle event executes trusted Hook

其中没有一步会把整个 Plugin 对象直接交给模型。

模型只看到投影后的能力表面。

129. 一条 App Tool 进入模型的完整链

Plugin .app.json
  -> AppDeclaration
  -> PluginConnectorSource
  -> ConnectorSnapshot
  -> Codex Apps MCP starts
  -> list_all_tools
  -> Tool connector_id metadata
  -> accessible connector merge
  -> AppToolPolicyEvaluator
  -> model visibility filter
  -> Direct or Deferred Tool Exposure
  -> ToolRouter

调用时:

PreToolUse
-> App Approval
-> PermissionRequest
-> MCP call_tool
-> optional Auth Elicitation
-> PostToolUse

这说明 App Tool 同时跨越:

Plugin
Connector
MCP
ToolRouter
Hook
Approval

但每层只承担自己的职责。

130. 扩展机制的失败隔离矩阵

失败 局部结果 不应发生
一个 Skill Frontmatter 无效 记录 Skill Error 所有 Skill 消失
openai.yaml 无效 忽略可选元数据 合法 SKILL.md 被禁用
一个非 Required MCP 启动失败 发布 Failed Session 初始化整体失败
Required MCP 启动失败 初始化返回聚合错误 静默继续
一个 Plugin Manifest 无效 Plugin inactive with error 加载部分不可信能力
一个 Plugin Hook 未信任 列出但不执行 自动信任
Hook Command 超时 Hook Failed Event Session Panic
一个 Thread MCP Refresh 失败 Best Effort Warning 阻断其他 Thread 刷新
Skills Watcher 初始化失败 使用 Noop Watcher App Server 无法启动

131. 扩展系统的安全边界

可以把安全责任划分为:

Plugin Store
  -> Bundle 身份、版本和路径边界

Skill Loader
  -> Frontmatter、Root、Authority 和上下文预算

MCP Runtime
  -> Transport、Auth、Tool Filter、Environment 和 Elicitation

App Policy
  -> Connector 可访问性、Tool Enablement 和 Approval

Hook Engine
  -> Source、Trust、Matcher、Timeout 和输出解析

Tool Runtime
  -> Approval、Permission 和 Sandbox

任何一层都不能宣称替代其他层。

例如:

MCP Tool 标记 readOnlyHint

是能力提示,不是操作系统级只读沙箱。

Plugin 路径合法

也不代表 Plugin Hook 自动可信。

132. 扩展系统的性能边界

当前实现中值得保留的优化包括:

Skill 扫描有深度和条目上限
Core Skill 元数据使用 2% Context Window 预算
Skills Extension 使用 8000 Byte 列表和正文上限
Skill 正文按需读取
Plugin Load 用单 Permit 和二次 Cache Check
Plugin Cache 用 Generation 防止过期回写
MCP Server 并发启动
MCP Auth Status 并发计算
MCP Resource 跨 Server 并发分页
MCP Runtime 仅在投影实质变化时重建
Hook Handler 并发执行
Hook 大输出落盘
File Watcher 聚合事件

这些优化都建立在不可变快照和清晰所有权上。

它们避免了为了并行而共享大量可变对象。

133. 推荐调试顺序

MCP Tool 不可见

依次检查:

Server 是否进入 Resolved Catalog
Server 是否因 Auth Gate 被移除
Startup 是否 Ready
Tool 是否被 enabled_tools / disabled_tools 过滤
Tool Meta visibility 是否包含 model
App Connector 是否 Accessible
App Tool Policy 是否 Enabled
Tool Search 是否把它放入 Deferred

Skill 不可见

依次检查:

Root 是否注册
扫描是否达到上限
SKILL.md Frontmatter 是否有效
Skill 是否 Disabled
Product Restriction 是否匹配
allow_implicit_invocation 是否为 false
Metadata Budget 是否省略了该项
当前走 Legacy Selector 还是 Skills Extension Selector
名称是否与其他 Skill 或 Connector 冲突

Hook 不执行

依次检查:

Hook Feature 是否启用
Source 是否被 allow_managed_hooks_only 排除
Event Name 是否正确
Matcher 是否匹配 Canonical Name 或 Alias
Handler 是否 Enabled
Trust Status 是否 Trusted 或 Managed
是否声明了暂不支持的 Async/Prompt/Agent
Command 是否超时或启动失败

134. 推荐测试命令

MCP Catalog 与连接:

cargo test -p codex-mcp

RMCP Transport:

cargo test -p codex-rmcp-client

Skills Loader、Render 与 Injection:

cargo test -p codex-core-skills

Plugin Manifest、Store 与 Manager:

cargo test -p codex-core-plugins

Hooks:

cargo test -p codex-hooks

Core MCP 与 Tool 集成:

cargo test -p codex-core \
  mcp

App Server Refresh 与 Catalog API:

cargo test -p codex-app-server \
  mcp_refresh

实际开发中应先运行修改所属 crate,再扩大到 Core 和 App Server 集成测试。

135. 动手练习

练习一:验证 MCP Tool Filter

给最小 Server 增加第二个 Tool:

hidden_summary

配置:

enabled_tools = ["repository_summary"]

验证:

模型只看到 repository_summary
直接伪造 hidden_summary 调用也被 Runtime 拒绝

练习二:验证 Model Visibility

给 Tool Meta 添加:

{
  "ui": {
    "visibility": []
  }
}

验证它不进入模型 Tool Declaration。

再改为:

{
  "ui": {
    "visibility": ["model"]
  }
}

确认重新可见。

练习三:验证 Required MCP

配置一个不存在的 Command:

required = false

记录 Startup Failed 但 Session 可继续。

再改为:

required = true

验证初始化返回 Required Server 聚合错误。

练习四:验证 Skill 两阶段上下文

创建两个正文很长的 Skill。

确认未提及时 Prompt 只有:

name
description
path

明确提及其中一个后,只有它的完整正文进入 Turn。

练习五:比较两条 Skill 名称冲突路径

在 Repo 与 User Scope 分别创建:

name: audit

只写:

$audit

分别记录:

Legacy Core Selector
  -> 因重名跳过纯名称

Skills Extension Selector
  -> 当前选择第一个 Enabled Catalog Entry

再使用明确 skill:// 路径,确认精确选择成功。

为两条路径设计统一冲突语义,并补充回归测试。

练习六:验证 Skill Watcher

连续快速保存同一个 SKILL.md 多次。

确认:

事件被节流
Cache 被清理
客户端收到 skills/changed
旧 Turn 不改变
新 Snapshot 使用新内容

练习七:验证 Plugin 路径边界

把 Manifest 中的 Skills 路径改为:

{
  "skills": "../outside"
}

确认路径被忽略并记录 Warning。

练习八:验证 Hook Trust

发现一个 User Hook,记录:

current_hash
trust_status = Untrusted

写入相同 trusted_hash 后确认执行。

修改 Command 后确认:

trust_status = Modified

且不再执行。

练习九:验证 Hook 并发与稳定顺序

配置三个 PreToolUse Hook:

A sleep 300ms
B sleep 100ms
C sleep 200ms

确认实际完成顺序是:

B C A

但 Completed Event 展示顺序保持:

A B C

练习十:验证 Stop 续写

让 Stop Hook 第一次返回:

decision = block
reason = "Run the focused tests before finishing."

第二次看到:

stop_hook_active = true

后允许结束。

确认 Turn 只额外采样一次,不形成无限循环。

136. 常见误区

误区一:Plugin 是一种新的 Tool Runtime

实际:

Plugin 是 Bundle 和安装单元。

它提供的 Tool 最终仍来自 MCP、App 或其他已存在 Runtime。

误区二:Skill 被发现时正文已经进入 Prompt

实际:

发现阶段只保留 Metadata 和 Locator,
正文按选择读取。

误区三:allow_implicit_invocation = false 会禁用 Skill

实际:

它只禁止模型通过可见目录自动选择,
不等于整体 Disabled。

误区四:命令隐式使用检测会补注入 Skill

实际:

它主要记录脚本或文档已经被使用的 Telemetry,
不是延迟 Prompt 注入。

误区五:MCP OAuth 适用于 Stdio

实际:

OAuth Discovery 只用于没有 Bearer Env 配置的 Streamable HTTP。

误区六:MCP Tool Hidden 就无法调用

实际:

Model Visibility 和 Runtime Tool Filter 是两层。

隐藏只表示不进入模型声明,Runtime Filter 才是调用门禁。

误区七:Hook 安装后自动可信

实际:

未受管 Hook 默认需要用户信任其规范化内容 Hash。

误区八:PostToolUse Block 会回滚副作用

实际:

Post Hook 运行时副作用已经发生。

误区九:Refresh 会中断所有旧 MCP 调用

实际:

旧 Runtime 由现有 Snapshot 引用保活,
新请求使用新 Runtime。

误区十:Apps 就是普通 MCP Server 的别名

实际:

App 还包含 Connector 身份、账号可访问性、
Plugin Provenance 和独立 Tool Policy。

137. 本篇小结

Codex 的扩展体系不是一个单一 Plugin API,而是四个职责清晰、在 Turn 中汇合的子系统。

核心结论如下:

  1. MCP、Skill、Plugin 与 Hook 分别属于协议、知识、分发和生命周期策略层。
  2. Plugin 是安装和归属单元,不是新的执行 Runtime。
  3. 一个 Plugin 可以同时投影 Skills、MCP Servers、Apps 和 Hooks。
  4. McpManager 从 Config、Plugin、Selected Package、Compatibility 和 Extension 构造运行时投影。
  5. MCP Catalog 用明确优先级和稳定动作顺序解析同名 Server。
  6. Configured、Projected 与 Effective MCP Server 是三种不同视图。
  7. McpRuntimeSnapshot 绑定 Config、Manager、Environment 和 Plugin 可用性。
  8. 刷新通过发布新 Snapshot 完成,旧 Manager 服务完已有 Step 后再关闭。
  9. Stdio 与 Streamable HTTP 在反序列化阶段严格互斥。
  10. MCP Server 并发启动,并分别发布 Starting、Ready、Failed 和 Cancelled。
  11. Required Server 必须在 Manager 已可路由 Elicitation 后验证。
  12. MCP 同时支持 Tool、Resource 和 Resource Template。
  13. Model Visibility、Tool Filter、Approval 和 Hook 是不同门禁。
  14. Tool Search 可以把 MCP Tool 从 Direct Exposure 转为 Deferred Exposure。
  15. OAuth 只适用于合格的 Streamable HTTP Transport。
  16. OAuth Scope 优先级是 Explicit、Configured、Discovered、Empty。
  17. 只有 Discovered Scope 被 Provider 拒绝时,Codex 才可重试空 Scope。
  18. Elicitation 是 MCP Server 主动请求用户或 Reviewer 输入的反向通道。
  19. Elicitation 使用 Codex 生成的公开 ID,避免多 Runtime Request ID 碰撞。
  20. Codex Apps 可以在在线启动失败时临时复用 Tool Cache 并后台重连。
  21. Skill Root 来自 Config Layer、用户目录、Plugin、额外 Root 和仓库层级。
  22. Skill 扫描有深度、目录数、条目数和并发数上限。
  23. SKILL.md Frontmatter 提供核心元数据,agents/openai.yaml 提供可选扩展元数据。
  24. SkillMetadata 不长期保存正文,只保存定位符和解析后的元数据。
  25. Core Available Skills 使用 2% Context Window 预算,Skills Extension 当前使用 8000 Byte 上限。
  26. Skill 使用两阶段上下文,元数据常驻,正文按选择读取。
  27. 显式路径优先于名称;Legacy Selector 跳过歧义名称,Skills Extension 当前仍选择第一个启用同名项。
  28. 多 Authority Skill 必须通过原 Provider 读取,不能把不透明资源当本地路径。
  29. 模型隐式选择与命令使用检测是两个不同概念。
  30. Skill 可以声明 MCP Dependency,但 Skill 自身仍不是 Tool。
  31. Skills Watcher 只清缓存并广播变化,不原地修改活跃 Turn。
  32. Plugin Store 分离版本化 Bundle 与持久数据。
  33. Manifest 路径必须以 ./ 开头并保持在 Plugin Root 内。
  34. Plugin Interface Metadata 不会自动创建 Runtime Capability。
  35. Plugin Loader 只投影 Enabled 且无 Error 的 Active Plugin。
  36. Plugin MCP Transport 由 Bundle 所有,用户配置只覆盖启用和 Tool Policy。
  37. App、Connector 与 Codex Apps MCP 分别表示产品能力、稳定身份和工具承载协议。
  38. App Tool 还要经过 Connector 可访问性与 App Tool Policy。
  39. Hook Engine 合并本地、托管、Session 和 Plugin 来源。
  40. 未受管 Hook 使用规范化 Handler Hash 建立内容信任。
  41. Enabled 与 Trusted 是两条独立状态。
  42. 当前 Runtime 只执行同步 Command Hook。
  43. 同一事件的 Hook 并发执行,但结果恢复配置顺序。
  44. PreToolUse 可阻断、改写输入和注入上下文。
  45. PermissionRequest 在 Guardian 或用户审批之前运行,任一 Deny 优先。
  46. PostToolUse 可反馈,但不能撤销已经发生的副作用。
  47. Stop Hook 可以生成续写片段,让 Turn 再次进入模型采样。
  48. 超过 2500 Token 的 Hook 输出会落盘,只把预览和路径放入上下文。
  49. Plugin、Skill、MCP 与 Hook 各自使用快照或缓存失效,不需要一个全局刷新锁。
  50. 扩展能力最终仍要遵守 ToolRouter、Approval、Permission 和 Sandbox 的安全边界。

下一篇将进入多智能体与远程执行环境,分析父子 Thread、Agent Control Tool、
Selected Capability Roots,以及多个 Environment 如何改变文件系统、MCP 和工具调用。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐