Discover hooks bundled with plugins (#19705)

## Why

Plugins can bundle lifecycle hooks, but Codex previously only discovered
hooks from user, project, and managed config layers. This adds the
plugin discovery and runtime plumbing needed for plugin-bundled hooks
while keeping execution behind the `plugin_hooks` feature flag.

## What

- Discovers plugin hook sources from each plugin's default
`hooks/hooks.json`.
- Supports `plugin.json` manifest `hooks` entries as either relative
paths or inline hook objects.
- Plumbs discovered plugin hook sources through plugin loading into the
hook runtime when `plugin_hooks` is enabled.
- Marks plugin-originated hook runs as `HookSource::Plugin`.
- Injects `PLUGIN_ROOT` and `CLAUDE_PLUGIN_ROOT` into plugin hook
command environments.
- Updates generated schemas and hook source metadata for the plugin hook
source.

## Stack

1. This PR - openai/codex#19705
2. openai/codex#19778
3. openai/codex#19840
4. openai/codex#19882

## Reviewer Notes

- Core logic is in `codex-rs/core-plugins/src/loader.rs` and
`codex-rs/hooks/src/engine/discovery.rs`
- Moved existing / adding new tests to
`codex-rs/core-plugins/src/loader_tests.rs` hence the large diff there
- Otherwise mostly plumbing and minor schema updates

### Core Changes

The `codex-rs/core` changes are limited to wiring plugin hook support
into existing core flows:

- `core/src/session/session.rs` conditionally pulls effective plugin
hook sources and plugin hook load warnings from `PluginsManager` when
`plugin_hooks` is enabled, then passes them into `HooksConfig`.
- `core/src/hook_runtime.rs` adds the `plugin` metric tag for
`HookSource::Plugin`.
- `core/config.schema.json` picks up the new `plugin_hooks` feature
flag, and `core/src/plugins/manager_tests.rs` updates fixtures for the
added plugin hook fields.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav
2026-04-28 14:17:18 -07:00
committed by GitHub
Unverified
parent 89698ad1c3
commit c6e7d564c3
36 changed files with 1129 additions and 194 deletions
+2
View File
@@ -2780,6 +2780,7 @@ dependencies = [
"anyhow",
"chrono",
"codex-config",
"codex-plugin",
"codex-protocol",
"codex-utils-absolute-path",
"futures",
@@ -3151,6 +3152,7 @@ dependencies = [
name = "codex-plugin"
version = "0.0.0"
dependencies = [
"codex-config",
"codex-utils-absolute-path",
"codex-utils-plugins",
"thiserror 2.0.18",
+1
View File
@@ -684,6 +684,7 @@ fn analytics_hook_source(source: HookSource) -> &'static str {
HookSource::Project => "project",
HookSource::Mdm => "mdm",
HookSource::SessionFlags => "session_flags",
HookSource::Plugin => "plugin",
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
HookSource::Unknown => "unknown",
@@ -1900,6 +1900,7 @@
"project",
"mdm",
"sessionFlags",
"plugin",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
@@ -9680,6 +9680,7 @@
"project",
"mdm",
"sessionFlags",
"plugin",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
@@ -6310,6 +6310,7 @@
"project",
"mdm",
"sessionFlags",
"plugin",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
@@ -160,6 +160,7 @@
"project",
"mdm",
"sessionFlags",
"plugin",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
@@ -160,6 +160,7 @@
"project",
"mdm",
"sessionFlags",
"plugin",
"legacyManagedConfigFile",
"legacyManagedConfigMdm",
"unknown"
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown";
@@ -469,6 +469,7 @@ v2_enum_from_core!(
Project,
Mdm,
SessionFlags,
Plugin,
LegacyManagedConfigFile,
LegacyManagedConfigMdm,
Unknown,
+133 -152
View File
@@ -1,4 +1,5 @@
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::manifest::PluginManifestHooks;
use crate::manifest::PluginManifestPaths;
use crate::manifest::load_plugin_manifest;
use crate::marketplace::MarketplacePluginSource;
@@ -7,6 +8,7 @@ use crate::marketplace::load_marketplace;
use crate::store::PluginStore;
use crate::store::plugin_version_for_source;
use codex_config::ConfigLayerStack;
use codex_config::HooksFile;
use codex_config::types::McpServerConfig;
use codex_config::types::PluginConfig;
use codex_core_skills::SkillMetadata;
@@ -19,6 +21,7 @@ use codex_exec_server::LOCAL_FS;
use codex_plugin::AppConnectorId;
use codex_plugin::LoadedPlugin;
use codex_plugin::PluginCapabilitySummary;
use codex_plugin::PluginHookSource;
use codex_plugin::PluginId;
use codex_plugin::PluginIdError;
use codex_plugin::PluginLoadOutcome;
@@ -26,6 +29,7 @@ use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::find_plugin_manifest_path;
use serde::Deserialize;
use serde_json::Map as JsonMap;
use serde_json::Value as JsonValue;
@@ -39,6 +43,7 @@ use tempfile::TempDir;
use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_HOOKS_CONFIG_FILE: &str = "hooks/hooks.json";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
const CONFIG_TOML_FILE: &str = "config.toml";
@@ -477,6 +482,8 @@ async fn load_plugin(
has_enabled_skills: false,
mcp_servers: HashMap::new(),
apps: Vec::new(),
hook_sources: Vec::new(),
hook_load_warnings: Vec::new(),
error: None,
};
@@ -484,14 +491,14 @@ async fn load_plugin(
return loaded_plugin;
}
let plugin_root = match plugin_id {
Ok(_) => match active_plugin_root {
Some(plugin_root) => plugin_root,
None => {
let (loaded_plugin_id, plugin_root) = match plugin_id {
Ok(plugin_id) => {
let Some(plugin_root) = active_plugin_root else {
loaded_plugin.error = Some("plugin is not installed".to_string());
return loaded_plugin;
}
},
};
(plugin_id, plugin_root)
}
Err(err) => {
loaded_plugin.error = Some(err.to_string());
return loaded_plugin;
@@ -545,6 +552,14 @@ async fn load_plugin(
}
loaded_plugin.mcp_servers = mcp_servers;
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
let (hook_sources, hook_load_warnings) = load_plugin_hooks(
&plugin_root,
&loaded_plugin_id,
&store.plugin_data_root(&loaded_plugin_id),
manifest_paths,
);
loaded_plugin.hook_sources = hook_sources;
loaded_plugin.hook_load_warnings = hook_load_warnings;
loaded_plugin
}
@@ -674,6 +689,116 @@ fn default_app_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
paths
}
// Discover plugin-bundled hooks from manifest `hooks` entries when present
// (path, paths, inline object, or inline objects), otherwise from the default
// `hooks/hooks.json` file.
pub fn load_plugin_hooks(
plugin_root: &AbsolutePathBuf,
plugin_id: &PluginId,
plugin_data_root: &AbsolutePathBuf,
manifest_paths: &PluginManifestPaths,
) -> (Vec<PluginHookSource>, Vec<String>) {
let mut sources = Vec::new();
let mut warnings = Vec::new();
match &manifest_paths.hooks {
Some(PluginManifestHooks::Paths(paths)) => {
for path in paths {
append_plugin_hook_file(
plugin_root,
plugin_id,
plugin_data_root,
path,
&mut sources,
&mut warnings,
);
}
}
Some(PluginManifestHooks::Inline(hooks_files)) => {
let manifest_path = find_plugin_manifest_path(plugin_root.as_path())
.and_then(|path| AbsolutePathBuf::try_from(path).ok())
.unwrap_or_else(|| plugin_root.join(".codex-plugin/plugin.json"));
for (index, hooks_file) in hooks_files.iter().enumerate() {
if hooks_file.hooks.is_empty() {
continue;
}
sources.push(PluginHookSource {
plugin_id: plugin_id.clone(),
plugin_root: plugin_root.clone(),
plugin_data_root: plugin_data_root.clone(),
source_path: manifest_path.clone(),
source_relative_path: format!("plugin.json#hooks[{index}]"),
hooks: hooks_file.hooks.clone(),
});
}
}
None => {
let default_path = plugin_root.join(DEFAULT_HOOKS_CONFIG_FILE);
if default_path.as_path().is_file() {
append_plugin_hook_file(
plugin_root,
plugin_id,
plugin_data_root,
&default_path,
&mut sources,
&mut warnings,
);
}
}
}
(sources, warnings)
}
// Append one resolved plugin hook file, keeping source metadata for runtime
// reporting and collecting load warnings for startup surfacing.
fn append_plugin_hook_file(
plugin_root: &AbsolutePathBuf,
plugin_id: &PluginId,
plugin_data_root: &AbsolutePathBuf,
path: &AbsolutePathBuf,
sources: &mut Vec<PluginHookSource>,
warnings: &mut Vec<String>,
) {
let contents = match fs::read_to_string(path.as_path()) {
Ok(contents) => contents,
Err(err) => {
warnings.push(format!(
"failed to read plugin hooks config {}: {err}",
path.display()
));
return;
}
};
let parsed = match serde_json::from_str::<HooksFile>(&contents) {
Ok(parsed) => parsed,
Err(err) => {
warnings.push(format!(
"failed to parse plugin hooks config {}: {err}",
path.display()
));
return;
}
};
if parsed.hooks.is_empty() {
return;
}
let source_relative_path = path
.as_path()
.strip_prefix(plugin_root.as_path())
.unwrap_or(path.as_path())
.to_string_lossy()
.replace('\\', "/");
sources.push(PluginHookSource {
plugin_id: plugin_id.clone(),
plugin_root: plugin_root.clone(),
plugin_data_root: plugin_data_root.clone(),
source_path: path.clone(),
source_relative_path,
hooks: parsed.hooks,
});
}
async fn load_apps_from_paths(
plugin_root: &Path,
app_config_paths: Vec<AbsolutePathBuf>,
@@ -1014,149 +1139,5 @@ fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), String> {
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn plugin_mcp_file_supports_mcp_servers_object_format() {
let parsed = serde_json::from_str::<PluginMcpFile>(
r#"{
"mcpServers": {
"sample": {
"command": "sample-mcp"
}
}
}"#,
)
.expect("parse wrapped plugin mcp config")
.into_mcp_servers();
assert_eq!(
parsed,
HashMap::from([(
"sample".to_string(),
serde_json::json!({
"command": "sample-mcp"
}),
)])
);
}
#[test]
fn plugin_mcp_file_supports_mcp_servers_object_format_with_metadata() {
let parsed = serde_json::from_str::<PluginMcpFile>(
r#"{
"$schema": "https://example.com/plugin-mcp.schema.json",
"mcpServers": {
"sample": {
"command": "sample-mcp"
}
}
}"#,
)
.expect("parse plugin mcp config with metadata")
.into_mcp_servers();
assert_eq!(
parsed,
HashMap::from([(
"sample".to_string(),
serde_json::json!({
"command": "sample-mcp"
}),
)])
);
}
#[test]
fn plugin_mcp_file_supports_top_level_server_map_format() {
let parsed = serde_json::from_str::<PluginMcpFile>(
r#"{
"linear": {
"type": "http",
"url": "https://mcp.linear.app/mcp"
}
}"#,
)
.expect("parse flat plugin mcp config")
.into_mcp_servers();
assert_eq!(
parsed,
HashMap::from([(
"linear".to_string(),
serde_json::json!({
"type": "http",
"url": "https://mcp.linear.app/mcp"
}),
)])
);
}
#[test]
fn curated_plugin_cache_version_shortens_full_git_sha() {
assert_eq!(
curated_plugin_cache_version("0123456789abcdef0123456789abcdef01234567"),
"01234567"
);
}
#[test]
fn curated_plugin_cache_version_preserves_non_git_sha_versions() {
assert_eq!(
curated_plugin_cache_version("export-backup"),
"export-backup"
);
assert_eq!(curated_plugin_cache_version("0123456"), "0123456");
}
#[test]
fn materialize_git_subdir_uses_sparse_checkout() {
let codex_home = tempfile::tempdir().expect("create codex home");
let repo = tempfile::tempdir().expect("create git repo");
let plugin_dir = repo.path().join("plugins/toolkit");
fs::create_dir_all(&plugin_dir).expect("create plugin directory");
fs::create_dir_all(repo.path().join("plugins/other")).expect("create other plugin");
fs::write(plugin_dir.join("marker.txt"), "toolkit").expect("write plugin marker");
fs::write(repo.path().join("plugins/other/marker.txt"), "other")
.expect("write other marker");
fs::write(repo.path().join("root.txt"), "root").expect("write root marker");
run_git(&["init"], Some(repo.path())).expect("init git repo");
run_git(
&["config", "user.email", "test@example.com"],
Some(repo.path()),
)
.expect("configure git email");
run_git(&["config", "user.name", "Test User"], Some(repo.path()))
.expect("configure git name");
run_git(&["add", "."], Some(repo.path())).expect("stage git repo");
run_git(&["commit", "-m", "init"], Some(repo.path())).expect("commit git repo");
let materialized = materialize_marketplace_plugin_source(
codex_home.path(),
&MarketplacePluginSource::Git {
url: repo.path().display().to_string(),
path: Some("plugins/toolkit".to_string()),
ref_name: None,
sha: None,
},
)
.expect("materialize git source");
assert_eq!(
plugin_dir.file_name(),
materialized.path.as_path().file_name()
);
assert!(materialized.path.as_path().join("marker.txt").is_file());
let checkout_root = materialized
.path
.as_path()
.parent()
.and_then(Path::parent)
.expect("materialized path should be nested under checkout root");
assert!(!checkout_root.join("root.txt").exists());
assert!(!checkout_root.join("plugins/other/marker.txt").exists());
}
}
#[path = "loader_tests.rs"]
mod tests;
+369
View File
@@ -0,0 +1,369 @@
use super::*;
use crate::manifest::load_plugin_manifest;
use codex_plugin::PluginId;
use pretty_assertions::assert_eq;
#[test]
fn plugin_mcp_file_supports_mcp_servers_object_format() {
let parsed = serde_json::from_str::<PluginMcpFile>(
r#"{
"mcpServers": {
"sample": {
"command": "sample-mcp"
}
}
}"#,
)
.expect("parse wrapped plugin mcp config")
.into_mcp_servers();
assert_eq!(
parsed,
HashMap::from([(
"sample".to_string(),
serde_json::json!({
"command": "sample-mcp"
}),
)])
);
}
#[test]
fn plugin_mcp_file_supports_mcp_servers_object_format_with_metadata() {
let parsed = serde_json::from_str::<PluginMcpFile>(
r#"{
"$schema": "https://example.com/plugin-mcp.schema.json",
"mcpServers": {
"sample": {
"command": "sample-mcp"
}
}
}"#,
)
.expect("parse plugin mcp config with metadata")
.into_mcp_servers();
assert_eq!(
parsed,
HashMap::from([(
"sample".to_string(),
serde_json::json!({
"command": "sample-mcp"
}),
)])
);
}
#[test]
fn plugin_mcp_file_supports_top_level_server_map_format() {
let parsed = serde_json::from_str::<PluginMcpFile>(
r#"{
"linear": {
"type": "http",
"url": "https://mcp.linear.app/mcp"
}
}"#,
)
.expect("parse flat plugin mcp config")
.into_mcp_servers();
assert_eq!(
parsed,
HashMap::from([(
"linear".to_string(),
serde_json::json!({
"type": "http",
"url": "https://mcp.linear.app/mcp"
}),
)])
);
}
#[test]
fn curated_plugin_cache_version_shortens_full_git_sha() {
assert_eq!(
curated_plugin_cache_version("0123456789abcdef0123456789abcdef01234567"),
"01234567"
);
}
#[test]
fn curated_plugin_cache_version_preserves_non_git_sha_versions() {
assert_eq!(
curated_plugin_cache_version("export-backup"),
"export-backup"
);
assert_eq!(curated_plugin_cache_version("0123456"), "0123456");
}
fn plugin_id() -> PluginId {
PluginId::parse("demo-plugin@test-marketplace").expect("plugin id")
}
fn plugin_root() -> (tempfile::TempDir, AbsolutePathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let plugin_root =
AbsolutePathBuf::try_from(tmp.path().join("demo-plugin")).expect("plugin root");
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
fs::create_dir_all(plugin_root.join("hooks")).expect("create hooks dir");
(tmp, plugin_root)
}
fn write_manifest(plugin_root: &AbsolutePathBuf, manifest: &str) {
fs::write(plugin_root.join(".codex-plugin/plugin.json"), manifest).expect("write manifest");
}
fn write_hook_file(plugin_root: &AbsolutePathBuf, relative_path: &str, event: &str, command: &str) {
fs::write(
plugin_root.join(relative_path),
format!(
r#"{{
"hooks": {{
"{event}": [
{{
"hooks": [{{ "type": "command", "command": "{command}" }}]
}}
]
}}
}}"#
),
)
.expect("write hooks");
}
fn load_sources(plugin_root: &AbsolutePathBuf) -> (Vec<PluginHookSource>, Vec<String>) {
let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest");
let plugin_data_root = AbsolutePathBuf::try_from(
plugin_root
.as_path()
.parent()
.expect("plugin root parent")
.join("plugin-data"),
)
.expect("plugin data root");
load_plugin_hooks(
plugin_root,
&plugin_id(),
&plugin_data_root,
&manifest.paths,
)
}
fn assert_sources(sources: &[PluginHookSource], expected_relative_paths: &[&str]) {
assert_eq!(
sources
.iter()
.map(|source| source.plugin_id.clone())
.collect::<Vec<_>>(),
vec![plugin_id(); expected_relative_paths.len()]
);
assert_eq!(
sources
.iter()
.map(|source| source.source_relative_path.as_str())
.collect::<Vec<_>>(),
expected_relative_paths
);
assert_eq!(
sources
.iter()
.map(|source| source.hooks.handler_count())
.collect::<Vec<_>>(),
vec![1; expected_relative_paths.len()]
);
}
#[test]
fn load_plugin_hooks_discovers_default_hooks_file() {
let (_tmp, plugin_root) = plugin_root();
write_manifest(&plugin_root, r#"{ "name": "demo-plugin" }"#);
fs::write(
plugin_root.join("hooks/hooks.json"),
r#"{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "echo default" }]
}
]
}
}"#,
)
.expect("write hooks");
let (sources, warnings) = load_sources(&plugin_root);
assert_eq!(warnings, Vec::<String>::new());
assert_sources(&sources, &["hooks/hooks.json"]);
}
#[test]
fn load_plugin_hooks_supports_manifest_hook_path() {
let (_tmp, plugin_root) = plugin_root();
write_manifest(
&plugin_root,
r#"{
"name": "demo-plugin",
"hooks": "./hooks/one.json"
}"#,
);
write_hook_file(&plugin_root, "hooks/one.json", "PreToolUse", "echo one");
let (sources, warnings) = load_sources(&plugin_root);
assert_eq!(warnings, Vec::<String>::new());
assert_sources(&sources, &["hooks/one.json"]);
}
#[test]
fn load_plugin_hooks_manifest_paths_replace_default_hooks_file() {
let (_tmp, plugin_root) = plugin_root();
write_manifest(
&plugin_root,
r#"{
"name": "demo-plugin",
"hooks": ["./hooks/one.json", "./hooks/two.json"]
}"#,
);
write_hook_file(
&plugin_root,
"hooks/hooks.json",
"PreToolUse",
"echo ignored",
);
write_hook_file(&plugin_root, "hooks/one.json", "PreToolUse", "echo one");
write_hook_file(&plugin_root, "hooks/two.json", "PostToolUse", "echo two");
let (sources, warnings) = load_sources(&plugin_root);
assert_eq!(warnings, Vec::<String>::new());
assert_sources(&sources, &["hooks/one.json", "hooks/two.json"]);
}
#[test]
fn load_plugin_hooks_supports_inline_manifest_hooks() {
let (_tmp, plugin_root) = plugin_root();
write_manifest(
&plugin_root,
r#"{
"name": "demo-plugin",
"hooks": {
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [{ "type": "command", "command": "echo inline" }]
}
]
}
}
}"#,
);
let (sources, warnings) = load_sources(&plugin_root);
assert_eq!(warnings, Vec::<String>::new());
assert_sources(&sources, &["plugin.json#hooks[0]"]);
}
#[test]
fn load_plugin_hooks_reports_invalid_hook_file() {
let (_tmp, plugin_root) = plugin_root();
write_manifest(&plugin_root, r#"{ "name": "demo-plugin" }"#);
fs::write(plugin_root.join("hooks/hooks.json"), "{ not-json").expect("write invalid hooks");
let (sources, warnings) = load_sources(&plugin_root);
assert_eq!(sources, Vec::<PluginHookSource>::new());
assert_eq!(
warnings,
vec![format!(
"failed to parse plugin hooks config {}: key must be a string at line 1 column 3",
plugin_root.join("hooks/hooks.json").display()
)]
);
}
#[test]
fn load_plugin_hooks_supports_inline_manifest_hook_list() {
let (_tmp, plugin_root) = plugin_root();
write_manifest(
&plugin_root,
r#"{
"name": "demo-plugin",
"hooks": [
{
"hooks": {
"SessionStart": [
{
"hooks": [{ "type": "command", "command": "echo inline one" }]
}
]
}
},
{
"hooks": {
"Stop": [
{
"hooks": [{ "type": "command", "command": "echo inline two" }]
}
]
}
}
]
}"#,
);
let (sources, warnings) = load_sources(&plugin_root);
assert_eq!(warnings, Vec::<String>::new());
assert_sources(&sources, &["plugin.json#hooks[0]", "plugin.json#hooks[1]"]);
}
#[test]
fn materialize_git_subdir_uses_sparse_checkout() {
let codex_home = tempfile::tempdir().expect("create codex home");
let repo = tempfile::tempdir().expect("create git repo");
let plugin_dir = repo.path().join("plugins/toolkit");
fs::create_dir_all(&plugin_dir).expect("create plugin directory");
fs::create_dir_all(repo.path().join("plugins/other")).expect("create other plugin");
fs::write(plugin_dir.join("marker.txt"), "toolkit").expect("write plugin marker");
fs::write(repo.path().join("plugins/other/marker.txt"), "other").expect("write other marker");
fs::write(repo.path().join("root.txt"), "root").expect("write root marker");
run_git(&["init"], Some(repo.path())).expect("init git repo");
run_git(
&["config", "user.email", "test@example.com"],
Some(repo.path()),
)
.expect("configure git email");
run_git(&["config", "user.name", "Test User"], Some(repo.path())).expect("configure git name");
run_git(&["add", "."], Some(repo.path())).expect("stage git repo");
run_git(&["commit", "-m", "init"], Some(repo.path())).expect("commit git repo");
let materialized = materialize_marketplace_plugin_source(
codex_home.path(),
&MarketplacePluginSource::Git {
url: repo.path().display().to_string(),
path: Some("plugins/toolkit".to_string()),
ref_name: None,
sha: None,
},
)
.expect("materialize git source");
assert_eq!(
plugin_dir.file_name(),
materialized.path.as_path().file_name()
);
assert!(materialized.path.as_path().join("marker.txt").is_file());
let checkout_root = materialized
.path
.as_path()
.parent()
.and_then(Path::parent)
.expect("materialized path should be nested under checkout root");
assert!(!checkout_root.join("root.txt").exists());
assert!(!checkout_root.join("plugins/other/marker.txt").exists());
}
+52
View File
@@ -1,3 +1,4 @@
use codex_config::HooksFile;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::find_plugin_manifest_path;
use serde::Deserialize;
@@ -26,6 +27,8 @@ struct RawPluginManifest {
#[serde(default)]
apps: Option<String>,
#[serde(default)]
hooks: Option<RawPluginManifestHooks>,
#[serde(default)]
interface: Option<RawPluginManifestInterface>,
}
@@ -43,6 +46,13 @@ pub struct PluginManifestPaths {
pub skills: Option<AbsolutePathBuf>,
pub mcp_servers: Option<AbsolutePathBuf>,
pub apps: Option<AbsolutePathBuf>,
pub hooks: Option<PluginManifestHooks>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginManifestHooks {
Paths(Vec<AbsolutePathBuf>),
Inline(Vec<HooksFile>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -114,6 +124,16 @@ enum RawPluginManifestDefaultPromptEntry {
Invalid(JsonValue),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawPluginManifestHooks {
Path(String),
Paths(Vec<String>),
Inline(HooksFile),
InlineList(Vec<HooksFile>),
Invalid(JsonValue),
}
pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
let manifest_path = find_plugin_manifest_path(plugin_root)?;
let contents = fs::read_to_string(&manifest_path).ok()?;
@@ -126,6 +146,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
skills,
mcp_servers,
apps,
hooks,
interface,
} = manifest;
let name = plugin_root
@@ -219,6 +240,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
mcp_servers.as_deref(),
),
apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()),
hooks: resolve_manifest_hooks(plugin_root, hooks),
},
interface,
})
@@ -233,6 +255,36 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
}
}
fn resolve_manifest_hooks(
plugin_root: &Path,
hooks: Option<RawPluginManifestHooks>,
) -> Option<PluginManifestHooks> {
match hooks? {
RawPluginManifestHooks::Path(path) => {
resolve_manifest_path(plugin_root, "hooks", Some(&path))
.map(|path| PluginManifestHooks::Paths(vec![path]))
}
RawPluginManifestHooks::Paths(paths) => {
let hooks = paths
.iter()
.filter_map(|path| resolve_manifest_path(plugin_root, "hooks", Some(path)))
.collect::<Vec<_>>();
(!hooks.is_empty()).then_some(PluginManifestHooks::Paths(hooks))
}
RawPluginManifestHooks::Inline(hooks) => Some(PluginManifestHooks::Inline(vec![hooks])),
RawPluginManifestHooks::InlineList(hooks) => {
(!hooks.is_empty()).then_some(PluginManifestHooks::Inline(hooks))
}
RawPluginManifestHooks::Invalid(value) => {
tracing::warn!(
"ignoring hooks: expected a string, string array, object, or object array; found {}",
json_value_type(&value)
);
None
}
}
}
fn resolve_interface_asset_path(
plugin_root: &Path,
field: &'static str,
+13 -1
View File
@@ -13,6 +13,7 @@ use std::path::PathBuf;
pub const DEFAULT_PLUGIN_VERSION: &str = "local";
pub const PLUGINS_CACHE_DIR: &str = "plugins/cache";
pub const PLUGINS_DATA_DIR: &str = "plugins/data";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallResult {
@@ -24,6 +25,7 @@ pub struct PluginInstallResult {
#[derive(Debug, Clone)]
pub struct PluginStore {
root: AbsolutePathBuf,
data_root: AbsolutePathBuf,
}
impl PluginStore {
@@ -35,8 +37,11 @@ impl PluginStore {
pub fn try_new(codex_home: PathBuf) -> Result<Self, PluginStoreError> {
let root = AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_CACHE_DIR))
.map_err(|err| PluginStoreError::io("failed to resolve plugin cache root", err))?;
let data_root =
AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_DATA_DIR))
.map_err(|err| PluginStoreError::io("failed to resolve plugin data root", err))?;
Ok(Self { root })
Ok(Self { root, data_root })
}
pub fn root(&self) -> &AbsolutePathBuf {
@@ -53,6 +58,13 @@ impl PluginStore {
self.plugin_base_root(plugin_id).join(plugin_version)
}
pub fn plugin_data_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
self.data_root.join(format!(
"{}-{}",
plugin_id.plugin_name, plugin_id.marketplace_name
))
}
pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String> {
let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path())
.ok()?
+12
View File
@@ -109,6 +109,18 @@ fn plugin_root_derives_path_from_key_and_version() {
);
}
#[test]
fn plugin_data_root_derives_path_from_key() {
let tmp = tempdir().unwrap();
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.plugin_data_root(&plugin_id).as_path(),
tmp.path().join("plugins/data/sample-debug")
);
}
#[test]
fn install_with_version_uses_requested_cache_version() {
let tmp = tempdir().unwrap();
+6
View File
@@ -463,6 +463,9 @@
"personality": {
"type": "boolean"
},
"plugin_hooks": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
@@ -3379,6 +3382,9 @@
"personality": {
"type": "boolean"
},
"plugin_hooks": {
"type": "boolean"
},
"plugins": {
"type": "boolean"
},
+1
View File
@@ -473,6 +473,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str);
HookSource::Project => "project",
HookSource::Mdm => "mdm",
HookSource::SessionFlags => "session_flags",
HookSource::Plugin => "plugin",
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
HookSource::Unknown => "unknown",
@@ -219,6 +219,8 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
},
)]),
apps: vec![AppConnectorId("connector_example".to_string())],
hook_sources: Vec::new(),
hook_load_warnings: Vec::new(),
error: None,
}]
);
@@ -719,6 +721,8 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions
has_enabled_skills: false,
mcp_servers: HashMap::new(),
apps: Vec::new(),
hook_sources: Vec::new(),
hook_load_warnings: Vec::new(),
error: None,
}]
);
@@ -836,6 +840,8 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
has_enabled_skills: false,
mcp_servers: HashMap::new(),
apps: Vec::new(),
hook_sources: Vec::new(),
hook_load_warnings: Vec::new(),
error: None,
};
let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary {
+12
View File
@@ -750,10 +750,22 @@ impl Session {
default_shell.derive_exec_args("", /*use_login_shell*/ false);
let hook_shell_program = hook_shell_argv.remove(0);
let _ = hook_shell_argv.pop();
let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks);
let (plugin_hook_sources, plugin_hook_load_warnings) = if plugin_hooks_enabled {
let plugin_outcome = plugins_manager.plugins_for_config(&config).await;
(
plugin_outcome.effective_plugin_hook_sources(),
plugin_outcome.effective_plugin_hook_warnings(),
)
} else {
(Vec::new(), Vec::new())
};
let hooks = Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
feature_enabled: config.features.enabled(Feature::CodexHooks),
config_layer_stack: Some(config.config_layer_stack.clone()),
plugin_hook_sources,
plugin_hook_load_warnings,
shell_program: Some(hook_shell_program),
shell_args: hook_shell_argv,
});
+172 -28
View File
@@ -727,7 +727,7 @@ fn request_message_input_texts(body: &[u8], role: &str) -> Vec<String> {
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -841,7 +841,7 @@ async fn stop_hook_can_block_multiple_times_in_same_turn() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -886,7 +886,7 @@ async fn session_start_hook_sees_materialized_transcript_path() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -962,7 +962,7 @@ async fn resumed_thread_keeps_stop_continuation_prompt_in_history() -> Result<()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1028,7 +1028,7 @@ async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() -
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1111,7 +1111,7 @@ async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1276,7 +1276,7 @@ async fn blocked_queued_prompt_does_not_strand_earlier_accepted_prompt() -> Resu
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn permission_request_hook_allows_shell_command_without_user_approval() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1355,7 +1355,7 @@ async fn permission_request_hook_allows_shell_command_without_user_approval() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1437,7 +1437,7 @@ async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn permission_request_hook_sees_raw_exec_command_input() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1518,7 +1518,7 @@ async fn permission_request_hook_sees_raw_exec_command_input() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn permission_request_hook_allows_network_approval_without_prompt() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1649,7 +1649,7 @@ allow_local_binding = true
}
#[cfg(not(target_os = "linux"))]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn permission_request_hook_sees_retry_context_after_sandbox_denial() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1719,7 +1719,7 @@ async fn permission_request_hook_sees_retry_context_after_sandbox_denial() -> Re
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1821,7 +1821,151 @@ async fn pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn plugin_pre_tool_use_blocks_shell_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let call_id = "plugin-pretooluse-shell-command";
let marker = std::env::temp_dir().join("plugin-pretooluse-shell-command-marker");
let command = format!("printf blocked > {}", marker.display());
let args = serde_json::json!({ "command": command });
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
call_id,
"shell_command",
&serde_json::to_string(&args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "plugin hook blocked it"),
ev_completed("resp-2"),
]),
],
)
.await;
let home = Arc::new(TempDir::new()?);
let plugin_root = home.path().join("plugins/cache/test/sample/local");
let hooks_dir = plugin_root.join("hooks");
fs::create_dir_all(plugin_root.join(".codex-plugin"))
.context("create plugin manifest directory")?;
fs::create_dir_all(&hooks_dir).context("create plugin hooks directory")?;
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.context("write plugin manifest")?;
fs::write(
home.path().join("config.toml"),
r#"[plugins."sample@test"]
enabled = true
"#,
)
.context("write plugin config")?;
let script_path = hooks_dir.join("pre_tool_use_hook.py");
let log_path = hooks_dir.join("pre_tool_use_hook_log.jsonl");
fs::write(
&script_path,
format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
print(json.dumps({{
"hookSpecificOutput": {{
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "blocked by plugin hook"
}}
}}))
"#,
log_path = log_path.display(),
),
)
.context("write plugin pre tool use hook script")?;
fs::write(
hooks_dir.join("hooks.json"),
r#"{
"hooks": {
"PreToolUse": [{
"matcher": "^Bash$",
"hooks": [{
"type": "command",
"command": "python3 ${PLUGIN_ROOT}/hooks/pre_tool_use_hook.py"
}]
}]
}
}"#,
)
.context("write plugin hooks config")?;
let mut builder = test_codex()
.with_home(Arc::clone(&home))
.with_config(|config| {
config
.features
.enable(Feature::Plugins)
.expect("test config should allow feature update");
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
config
.features
.enable(Feature::PluginHooks)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
if marker.exists() {
fs::remove_file(&marker).context("remove leftover plugin pre tool use marker")?;
}
test.submit_turn_with_policy(
"run the shell command blocked by a plugin hook",
codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output_item = requests[1].function_call_output(call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("shell command output string");
assert!(
output.contains("Command blocked by PreToolUse hook: blocked by plugin hook"),
"blocked tool output should surface the plugin hook reason",
);
assert!(
!marker.exists(),
"plugin hook should block shell command execution"
);
let hook_inputs = read_hook_inputs_from_log(&log_path)?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(hook_inputs[0]["hook_event_name"], "PreToolUse");
assert_eq!(hook_inputs[0]["tool_name"], "Bash");
assert_eq!(hook_inputs[0]["tool_use_id"], call_id);
assert_eq!(hook_inputs[0]["tool_input"]["command"], command);
Ok(())
}
#[tokio::test]
async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1904,7 +2048,7 @@ async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2005,7 +2149,7 @@ async fn pre_tool_use_merges_hooks_json_and_config_toml() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2099,7 +2243,7 @@ async fn pre_tool_use_blocks_local_shell_before_execution() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2186,7 +2330,7 @@ async fn pre_tool_use_blocks_exec_command_before_execution() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2263,7 +2407,7 @@ async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2338,7 +2482,7 @@ async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2410,7 +2554,7 @@ async fn pre_tool_use_does_not_fire_for_plan_tool() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn post_tool_use_records_additional_context_for_shell_command() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2507,7 +2651,7 @@ async fn post_tool_use_records_additional_context_for_shell_command() -> Result<
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2575,7 +2719,7 @@ async fn post_tool_use_block_decision_replaces_shell_command_output_with_reason(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_reason() -> Result<()>
{
skip_if_no_network!(Ok(()));
@@ -2644,7 +2788,7 @@ async fn post_tool_use_continue_false_replaces_shell_command_output_with_stop_re
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn post_tool_use_records_additional_context_for_local_shell() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2718,7 +2862,7 @@ async fn post_tool_use_records_additional_context_for_local_shell() -> Result<()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedback() -> Result<()>
{
skip_if_no_network!(Ok(()));
@@ -2793,7 +2937,7 @@ async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedb
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn post_tool_use_blocks_when_exec_session_completes_via_write_stdin() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_windows!(Ok(()));
@@ -2899,7 +3043,7 @@ async fn post_tool_use_blocks_when_exec_session_completes_via_write_stdin() -> R
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2990,7 +3134,7 @@ async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<()
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -3063,7 +3207,7 @@ async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[tokio::test]
async fn post_tool_use_does_not_fire_for_plan_tool() -> Result<()> {
skip_if_no_network!(Ok(()));
+8
View File
@@ -159,6 +159,8 @@ pub enum Feature {
ToolSuggest,
/// Enable plugins.
Plugins,
/// Enable plugin-bundled lifecycle hooks.
PluginHooks,
/// Allow the in-app browser pane in desktop apps.
///
/// Requirements-only gate: this should be set from requirements, not user config.
@@ -876,6 +878,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::PluginHooks,
key: "plugin_hooks",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::InAppBrowser,
key: "in_app_browser",
+1
View File
@@ -16,6 +16,7 @@ workspace = true
anyhow = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
codex-config = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
futures = { workspace = true, features = ["alloc"] }
+2 -2
View File
@@ -108,12 +108,12 @@ fn build_command(shell: &CommandShell, handler: &ConfiguredHandler) -> Command {
};
if shell.program.is_empty() {
command.arg(&handler.command);
command
} else {
command.args(&shell.args);
command.arg(&handler.command);
command
}
command.envs(&handler.env);
command
}
fn default_shell_command() -> Command {
+78 -9
View File
@@ -12,8 +12,10 @@ use codex_config::HooksFile;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup;
use codex_config::RequirementSource;
use codex_plugin::PluginHookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use std::collections::HashMap;
use super::ConfiguredHandler;
use crate::events::common::matcher_pattern_for_event;
@@ -25,23 +27,34 @@ pub(crate) struct DiscoveryResult {
pub warnings: Vec<String>,
}
#[derive(Clone, Copy)]
#[derive(Clone)]
struct HookHandlerSource<'a> {
path: &'a AbsolutePathBuf,
is_managed: bool,
source: HookSource,
env: HashMap<String, String>,
}
pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -> DiscoveryResult {
pub(crate) fn discover_handlers(
config_layer_stack: Option<&ConfigLayerStack>,
plugin_hook_sources: Vec<PluginHookSource>,
plugin_hook_load_warnings: Vec<String>,
) -> DiscoveryResult {
let Some(config_layer_stack) = config_layer_stack else {
return DiscoveryResult {
handlers: Vec::new(),
warnings: Vec::new(),
};
let mut handlers = Vec::new();
let mut warnings = plugin_hook_load_warnings;
let mut display_order = 0_i64;
append_plugin_hook_sources(
&mut handlers,
&mut warnings,
&mut display_order,
plugin_hook_sources,
);
return DiscoveryResult { handlers, warnings };
};
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut warnings = plugin_hook_load_warnings;
let mut display_order = 0_i64;
append_managed_requirement_handlers(
@@ -80,6 +93,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
path: &source_path,
is_managed: false,
source: hook_source,
env: HashMap::new(),
},
hook_events,
);
@@ -94,12 +108,20 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
path: &source_path,
is_managed: false,
source: hook_source,
env: HashMap::new(),
},
hook_events,
);
}
}
append_plugin_hook_sources(
&mut handlers,
&mut warnings,
&mut display_order,
plugin_hook_sources,
);
DiscoveryResult { handlers, warnings }
}
@@ -125,11 +147,51 @@ fn append_managed_requirement_handlers(
path: &source_path,
is_managed: true,
source: hook_source_for_requirement_source(managed_hooks.source.as_ref()),
env: HashMap::new(),
},
managed_hooks.get().hooks.clone(),
);
}
fn append_plugin_hook_sources(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
display_order: &mut i64,
plugin_hook_sources: Vec<PluginHookSource>,
) {
// TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable.
for source in plugin_hook_sources {
let PluginHookSource {
plugin_root,
plugin_data_root,
source_path,
hooks,
..
} = source;
let mut env = HashMap::new();
let plugin_root_value = plugin_root.display().to_string();
let plugin_data_root_value = plugin_data_root.display().to_string();
env.insert("PLUGIN_ROOT".to_string(), plugin_root_value.clone());
// For OOTB compat with existing plugins that use this env var.
env.insert("CLAUDE_PLUGIN_ROOT".to_string(), plugin_root_value);
env.insert("PLUGIN_DATA".to_string(), plugin_data_root_value.clone());
// For OOTB compat with existing plugins that use this env var.
env.insert("CLAUDE_PLUGIN_DATA".to_string(), plugin_data_root_value);
append_hook_events(
handlers,
warnings,
display_order,
HookHandlerSource {
path: &source_path,
is_managed: false,
source: HookSource::Plugin,
env,
},
hooks,
);
}
}
fn managed_hooks_source_path(
managed_hooks: &ManagedHooksRequirementsToml,
requirement_source: Option<&RequirementSource>,
@@ -278,7 +340,7 @@ fn append_hook_events(
handlers,
warnings,
display_order,
source,
source.clone(),
event_name,
groups,
);
@@ -298,7 +360,7 @@ fn append_matcher_groups(
handlers,
warnings,
display_order,
source,
source.clone(),
event_name,
matcher_pattern_for_event(event_name, group.matcher.as_deref()),
group.hooks,
@@ -347,6 +409,9 @@ fn append_group_handlers(
));
continue;
}
let command = source.env.iter().fold(command, |command, (key, value)| {
command.replace(&format!("${{{key}}}"), value)
});
let timeout_sec = timeout_sec.unwrap_or(600).max(1);
handlers.push(ConfiguredHandler {
event_name,
@@ -358,6 +423,7 @@ fn append_group_handlers(
source_path: source.path.clone(),
source: source.source,
display_order: *display_order,
env: source.env.clone(),
});
*display_order += 1;
}
@@ -431,6 +497,7 @@ mod tests {
path,
is_managed: false,
source: hook_source(),
env: std::collections::HashMap::new(),
}
}
@@ -475,6 +542,7 @@ mod tests {
source_path: source_path.clone(),
source: hook_source(),
display_order: 0,
env: std::collections::HashMap::new(),
}]
);
}
@@ -508,6 +576,7 @@ mod tests {
source_path: source_path.clone(),
source: hook_source(),
display_order: 0,
env: std::collections::HashMap::new(),
}]
);
}
+1
View File
@@ -164,6 +164,7 @@ mod tests {
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: HookSource::User,
display_order,
env: std::collections::HashMap::new(),
}
}
+11 -1
View File
@@ -4,7 +4,10 @@ pub(crate) mod dispatcher;
pub(crate) mod output_parser;
pub(crate) mod schema_loader;
use std::collections::HashMap;
use codex_config::ConfigLayerStack;
use codex_plugin::PluginHookSource;
use codex_protocol::protocol::HookRunSummary;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -39,6 +42,7 @@ pub(crate) struct ConfiguredHandler {
pub source_path: AbsolutePathBuf,
pub source: HookSource,
pub display_order: i64,
pub env: HashMap<String, String>,
}
impl ConfiguredHandler {
@@ -74,6 +78,8 @@ impl ClaudeHooksEngine {
pub(crate) fn new(
enabled: bool,
config_layer_stack: Option<&ConfigLayerStack>,
plugin_hook_sources: Vec<PluginHookSource>,
plugin_hook_load_warnings: Vec<String>,
shell: CommandShell,
) -> Self {
if !enabled {
@@ -85,7 +91,11 @@ impl ClaudeHooksEngine {
}
let _ = schema_loader::generated_hook_schemas();
let discovered = discovery::discover_handlers(config_layer_stack);
let discovered = discovery::discover_handlers(
config_layer_stack,
plugin_hook_sources,
plugin_hook_load_warnings,
);
Self {
handlers: discovered.handlers,
warnings: discovered.warnings,
+199
View File
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::fs;
use std::path::Path;
@@ -15,7 +16,10 @@ use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup;
use codex_config::RequirementSource;
use codex_config::TomlValue;
use codex_plugin::PluginHookSource;
use codex_plugin::PluginId;
use codex_protocol::ThreadId;
use codex_protocol::protocol::HookSource;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
@@ -105,6 +109,8 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
@@ -188,6 +194,8 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() {
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
@@ -295,6 +303,8 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
Some(&config_layer_stack),
Vec::new(),
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
@@ -325,3 +335,192 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
assert_eq!(preview[0].source_path, hooks_json_path);
assert_eq!(preview[1].source_path, config_path);
}
#[tokio::test]
async fn plugin_hook_sources_run_with_plugin_env_and_plugin_source() {
let temp = tempdir().expect("create temp dir");
let plugin_root =
AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root");
let plugin_data_root =
AbsolutePathBuf::try_from(temp.path().join("plugin-data")).expect("plugin data root");
fs::create_dir_all(plugin_root.join("hooks")).expect("create hooks dir");
let source_path = plugin_root.join("hooks/hooks.json");
let log_path = plugin_root.join("env.json");
let script_path = plugin_root.join("hooks/write_env.py");
fs::write(
script_path.as_path(),
format!(
r#"import json
import os
from pathlib import Path
Path(r"{log_path}").write_text(json.dumps({{
"plugin": os.environ.get("PLUGIN_ROOT"),
"claude": os.environ.get("CLAUDE_PLUGIN_ROOT"),
}}), encoding="utf-8")
"#,
log_path = log_path.display(),
),
)
.expect("write hook script");
let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id");
let plugin_hook_sources = vec![PluginHookSource {
plugin_id,
plugin_root: plugin_root.clone(),
plugin_data_root: plugin_data_root.clone(),
source_path: source_path.clone(),
source_relative_path: "hooks/hooks.json".to_string(),
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("Bash".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: format!("python3 {}", script_path.display()),
timeout_sec: Some(5),
r#async: false,
status_message: None,
}],
}],
..Default::default()
},
}];
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
/*config_layer_stack*/ None,
plugin_hook_sources,
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
let preview = engine.preview_pre_tool_use(&PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
});
assert_eq!(preview.len(), 1);
assert_eq!(preview[0].source, HookSource::Plugin);
assert_eq!(preview[0].source_path, source_path);
let outcome = engine
.run_pre_tool_use(PreToolUseRequest {
session_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
cwd: cwd(),
transcript_path: None,
model: "gpt-test".to_string(),
permission_mode: "default".to_string(),
tool_name: "Bash".to_string(),
matcher_aliases: Vec::new(),
tool_use_id: "tool-1".to_string(),
tool_input: serde_json::json!({ "command": "echo hello" }),
})
.await;
assert_eq!(outcome.hook_events.len(), 1);
assert_eq!(outcome.hook_events[0].run.source, HookSource::Plugin);
let logged: serde_json::Value =
serde_json::from_str(&fs::read_to_string(log_path.as_path()).expect("read env log"))
.expect("parse env log");
assert_eq!(
logged,
serde_json::json!({
"plugin": plugin_root.display().to_string(),
"claude": plugin_root.display().to_string(),
})
);
}
#[test]
fn plugin_hook_sources_expand_plugin_placeholders() {
let temp = tempdir().expect("create temp dir");
let plugin_root =
AbsolutePathBuf::try_from(temp.path().join("demo-plugin")).expect("plugin root");
let plugin_data_root =
AbsolutePathBuf::try_from(temp.path().join("plugin-data")).expect("plugin data root");
let source_path = plugin_root.join("hooks/hooks.json");
let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id");
let plugin_hook_sources = vec![PluginHookSource {
plugin_id,
plugin_root: plugin_root.clone(),
plugin_data_root: plugin_data_root.clone(),
source_path,
source_relative_path: "hooks/hooks.json".to_string(),
hooks: HookEventsToml {
pre_tool_use: vec![MatcherGroup {
matcher: Some("Bash".to_string()),
hooks: vec![HookHandlerConfig::Command {
command: "run ${PLUGIN_ROOT} ${CLAUDE_PLUGIN_ROOT} ${PLUGIN_DATA} ${CLAUDE_PLUGIN_DATA}"
.to_string(),
timeout_sec: Some(5),
r#async: false,
status_message: None,
}],
}],
..Default::default()
},
}];
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
/*config_layer_stack*/ None,
plugin_hook_sources,
Vec::new(),
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert_eq!(
engine.handlers[0].command,
format!(
"run {} {} {} {}",
plugin_root.display(),
plugin_root.display(),
plugin_data_root.display(),
plugin_data_root.display()
)
);
assert_eq!(
engine.handlers[0].env,
HashMap::from([
("PLUGIN_ROOT".to_string(), plugin_root.display().to_string()),
(
"CLAUDE_PLUGIN_ROOT".to_string(),
plugin_root.display().to_string()
),
(
"PLUGIN_DATA".to_string(),
plugin_data_root.display().to_string()
),
(
"CLAUDE_PLUGIN_DATA".to_string(),
plugin_data_root.display().to_string()
),
])
);
}
#[test]
fn plugin_hook_load_warnings_are_startup_warnings() {
let engine = ClaudeHooksEngine::new(
/*enabled*/ true,
/*config_layer_stack*/ None,
Vec::new(),
vec!["failed plugin hook".to_string()],
CommandShell {
program: String::new(),
args: Vec::new(),
},
);
assert_eq!(engine.warnings(), &["failed plugin hook".to_string()]);
}
@@ -551,6 +551,7 @@ mod tests {
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
}
}
@@ -542,6 +542,7 @@ mod tests {
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
}
}
@@ -364,6 +364,7 @@ mod tests {
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
}
}
+1
View File
@@ -531,6 +531,7 @@ mod tests {
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
}
}
@@ -422,6 +422,7 @@ mod tests {
source_path: test_path_buf("/tmp/hooks.json").abs(),
source: codex_protocol::protocol::HookSource::User,
display_order: 0,
env: std::collections::HashMap::new(),
}
}
+5
View File
@@ -1,4 +1,5 @@
use codex_config::ConfigLayerStack;
use codex_plugin::PluginHookSource;
use tokio::process::Command;
use crate::engine::ClaudeHooksEngine;
@@ -25,6 +26,8 @@ pub struct HooksConfig {
pub legacy_notify_argv: Option<Vec<String>>,
pub feature_enabled: bool,
pub config_layer_stack: Option<ConfigLayerStack>,
pub plugin_hook_sources: Vec<PluginHookSource>,
pub plugin_hook_load_warnings: Vec<String>,
pub shell_program: Option<String>,
pub shell_args: Vec<String>,
}
@@ -53,6 +56,8 @@ impl Hooks {
let engine = ClaudeHooksEngine::new(
config.feature_enabled,
config.config_layer_stack.as_ref(),
config.plugin_hook_sources,
config.plugin_hook_load_warnings,
CommandShell {
program: config.shell_program.unwrap_or_default(),
args: config.shell_args,
+1
View File
@@ -13,6 +13,7 @@ path = "src/lib.rs"
workspace = true
[dependencies]
codex-config = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-plugins = { workspace = true }
thiserror = { workspace = true }
+12
View File
@@ -6,6 +6,8 @@ pub use codex_utils_plugins::plugin_namespace_for_skill_path;
mod load_outcome;
mod plugin_id;
use codex_config::HookEventsToml;
use codex_utils_absolute_path::AbsolutePathBuf;
pub use load_outcome::EffectiveSkillRoots;
pub use load_outcome::LoadedPlugin;
pub use load_outcome::PluginLoadOutcome;
@@ -27,6 +29,16 @@ pub struct PluginCapabilitySummary {
pub app_connector_ids: Vec<AppConnectorId>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginHookSource {
pub plugin_id: PluginId,
pub plugin_root: AbsolutePathBuf,
pub plugin_data_root: AbsolutePathBuf,
pub source_path: AbsolutePathBuf,
pub source_relative_path: String,
pub hooks: HookEventsToml,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginTelemetryMetadata {
pub plugin_id: PluginId,
+19
View File
@@ -5,6 +5,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use crate::AppConnectorId;
use crate::PluginCapabilitySummary;
use crate::PluginHookSource;
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
@@ -21,6 +22,8 @@ pub struct LoadedPlugin<M> {
pub has_enabled_skills: bool,
pub mcp_servers: HashMap<String, M>,
pub apps: Vec<AppConnectorId>,
pub hook_sources: Vec<PluginHookSource>,
pub hook_load_warnings: Vec<String>,
pub error: Option<String>,
}
@@ -140,6 +143,22 @@ impl<M: Clone> PluginLoadOutcome<M> {
apps
}
pub fn effective_plugin_hook_sources(&self) -> Vec<PluginHookSource> {
self.plugins
.iter()
.filter(|plugin| plugin.is_active())
.flat_map(|plugin| plugin.hook_sources.iter().cloned())
.collect()
}
pub fn effective_plugin_hook_warnings(&self) -> Vec<String> {
self.plugins
.iter()
.filter(|plugin| plugin.is_active())
.flat_map(|plugin| plugin.hook_load_warnings.iter().cloned())
.collect()
}
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
&self.capability_summaries
}
+1
View File
@@ -1563,6 +1563,7 @@ pub enum HookSource {
Project,
Mdm,
SessionFlags,
Plugin,
LegacyManagedConfigFile,
LegacyManagedConfigMdm,
#[default]