From b2f2c98a7cf7ded3337971cb9176022e7384356f Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 13:10:30 -0700 Subject: [PATCH] Discover hooks bundled with plugins --- codex-rs/Cargo.lock | 2 + codex-rs/analytics/src/events.rs | 1 + .../schema/json/ServerNotification.json | 1 + .../codex_app_server_protocol.schemas.json | 1 + .../codex_app_server_protocol.v2.schemas.json | 1 + .../json/v2/HookCompletedNotification.json | 1 + .../json/v2/HookStartedNotification.json | 1 + .../schema/typescript/v2/HookSource.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 1 + codex-rs/core-plugins/src/loader.rs | 272 +++++++++++++++++- codex-rs/core-plugins/src/manifest.rs | 52 ++++ codex-rs/core/config.schema.json | 6 + codex-rs/core/src/hook_runtime.rs | 1 + codex-rs/core/src/plugins/manager_tests.rs | 6 + codex-rs/core/src/session/session.rs | 12 + codex-rs/features/src/lib.rs | 8 + codex-rs/hooks/Cargo.toml | 1 + codex-rs/hooks/src/engine/command_runner.rs | 4 +- codex-rs/hooks/src/engine/discovery.rs | 78 ++++- codex-rs/hooks/src/engine/dispatcher.rs | 1 + codex-rs/hooks/src/engine/mod.rs | 12 +- codex-rs/hooks/src/engine/mod_tests.rs | 109 +++++++ codex-rs/hooks/src/events/post_tool_use.rs | 1 + codex-rs/hooks/src/events/pre_tool_use.rs | 1 + codex-rs/hooks/src/events/session_start.rs | 1 + codex-rs/hooks/src/events/stop.rs | 1 + .../hooks/src/events/user_prompt_submit.rs | 1 + codex-rs/hooks/src/registry.rs | 5 + codex-rs/plugin/Cargo.toml | 1 + codex-rs/plugin/src/lib.rs | 11 + codex-rs/plugin/src/load_outcome.rs | 19 ++ codex-rs/protocol/src/protocol.rs | 1 + 32 files changed, 596 insertions(+), 19 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5ff3f462f1..fdd119aa2e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2744,6 +2744,7 @@ dependencies = [ "anyhow", "chrono", "codex-config", + "codex-plugin", "codex-protocol", "codex-utils-absolute-path", "futures", @@ -3065,6 +3066,7 @@ dependencies = [ name = "codex-plugin" version = "0.0.0" dependencies = [ + "codex-config", "codex-utils-absolute-path", "codex-utils-plugins", "thiserror 2.0.18", diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 98d0e6ff6b..24ae8e00b9 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 629c0b97fa..df78bc0c3f 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1915,6 +1915,7 @@ "project", "mdm", "sessionFlags", + "plugin", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 2fc1be3469..404f681944 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9737,6 +9737,7 @@ "project", "mdm", "sessionFlags", + "plugin", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 87e133a07a..83f5889566 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6367,6 +6367,7 @@ "project", "mdm", "sessionFlags", + "plugin", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index a4d378649b..7c03e35543 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -160,6 +160,7 @@ "project", "mdm", "sessionFlags", + "plugin", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index ac77d6163f..d08300d526 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -160,6 +160,7 @@ "project", "mdm", "sessionFlags", + "plugin", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts index 7edf61f918..24a06bd138 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index b7dccc8613..c0a76f1b79 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -469,6 +469,7 @@ v2_enum_from_core!( Project, Mdm, SessionFlags, + Plugin, LegacyManagedConfigFile, LegacyManagedConfigMdm, Unknown, diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 589467199e..0aa7a115fb 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -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,9 @@ async fn load_plugin( } loaded_plugin.mcp_servers = mcp_servers; loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; + let hook_discovery = load_plugin_hooks(&plugin_root, &loaded_plugin_id, manifest_paths); + loaded_plugin.hook_sources = hook_discovery.sources; + loaded_plugin.hook_load_warnings = hook_discovery.warnings; loaded_plugin } @@ -674,6 +684,97 @@ fn default_app_config_paths(plugin_root: &Path) -> Vec { paths } +#[derive(Debug, Default)] +pub struct PluginHookDiscovery { + pub sources: Vec, + pub warnings: Vec, +} + +pub fn load_plugin_hooks( + plugin_root: &AbsolutePathBuf, + plugin_id: &PluginId, + manifest_paths: &PluginManifestPaths, +) -> PluginHookDiscovery { + let mut discovery = PluginHookDiscovery::default(); + match &manifest_paths.hooks { + Some(PluginManifestHooks::Paths(paths)) => { + for path in paths { + append_plugin_hook_file(plugin_root, plugin_id, path, &mut discovery); + } + } + 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; + } + discovery.sources.push(PluginHookSource { + plugin_id: plugin_id.clone(), + plugin_root: plugin_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, &default_path, &mut discovery); + } + } + } + discovery +} + +fn append_plugin_hook_file( + plugin_root: &AbsolutePathBuf, + plugin_id: &PluginId, + path: &AbsolutePathBuf, + discovery: &mut PluginHookDiscovery, +) { + let contents = match fs::read_to_string(path.as_path()) { + Ok(contents) => contents, + Err(err) => { + discovery.warnings.push(format!( + "failed to read plugin hooks config {}: {err}", + path.display() + )); + return; + } + }; + let parsed = match serde_json::from_str::(&contents) { + Ok(parsed) => parsed, + Err(err) => { + discovery.warnings.push(format!( + "failed to parse plugin hooks config {}: {err}", + path.display() + )); + return; + } + }; + if parsed.hooks.is_empty() { + return; + } + + discovery.sources.push(PluginHookSource { + plugin_id: plugin_id.clone(), + plugin_root: plugin_root.clone(), + source_path: path.clone(), + source_relative_path: plugin_relative_path(plugin_root.as_path(), path.as_path()), + hooks: parsed.hooks, + }); +} + +fn plugin_relative_path(plugin_root: &Path, path: &Path) -> String { + path.strip_prefix(plugin_root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + async fn load_apps_from_paths( plugin_root: &Path, app_config_paths: Vec, @@ -1111,6 +1212,165 @@ mod tests { assert_eq!(curated_plugin_cache_version("0123456"), "0123456"); } + #[test] + fn load_plugin_hooks_discovers_default_hooks_file() { + 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"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ "name": "demo-plugin" }"#, + ) + .expect("write manifest"); + fs::write( + plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "echo default" }] + } + ] + } +}"#, + ) + .expect("write hooks"); + + let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); + let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"); + let discovery = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); + + assert_eq!(discovery.warnings, Vec::::new()); + assert_eq!(discovery.sources.len(), 1); + assert_eq!( + discovery.sources[0].plugin_id, + PluginId::parse("demo-plugin@test-marketplace").expect("plugin id") + ); + assert_eq!( + discovery.sources[0].source_relative_path, + "hooks/hooks.json" + ); + assert_eq!(discovery.sources[0].hooks.handler_count(), 1); + } + + #[test] + fn load_plugin_hooks_manifest_paths_replace_default_hooks_file() { + 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"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "hooks": ["./hooks/one.json", "./hooks/two.json"] +}"#, + ) + .expect("write manifest"); + fs::write( + plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "hooks": [{ "type": "command", "command": "echo ignored" }] + } + ] + } +}"#, + ) + .expect("write default hooks"); + fs::write( + plugin_root.join("hooks/one.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "hooks": [{ "type": "command", "command": "echo one" }] + } + ] + } +}"#, + ) + .expect("write first hooks"); + fs::write( + plugin_root.join("hooks/two.json"), + r#"{ + "hooks": { + "PostToolUse": [ + { + "hooks": [{ "type": "command", "command": "echo two" }] + } + ] + } +}"#, + ) + .expect("write second hooks"); + + let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); + let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"); + let discovery = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); + + assert_eq!(discovery.warnings, Vec::::new()); + assert_eq!( + discovery + .sources + .iter() + .map(|source| source.source_relative_path.as_str()) + .collect::>(), + vec!["hooks/one.json", "hooks/two.json"] + ); + assert_eq!( + discovery + .sources + .iter() + .map(|source| source.hooks.handler_count()) + .collect::>(), + vec![1, 1] + ); + } + + #[test] + fn load_plugin_hooks_supports_inline_manifest_hooks() { + 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::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "hooks": { + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [{ "type": "command", "command": "echo inline" }] + } + ] + } + } +}"#, + ) + .expect("write manifest"); + + let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); + let plugin_id = PluginId::parse("demo-plugin@test-marketplace").expect("plugin id"); + let discovery = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); + + assert_eq!(discovery.warnings, Vec::::new()); + assert_eq!(discovery.sources.len(), 1); + assert_eq!( + discovery.sources[0].source_relative_path, + "plugin.json#hooks[0]" + ); + assert_eq!(discovery.sources[0].hooks.handler_count(), 1); + } + #[test] fn materialize_git_subdir_uses_sparse_checkout() { let codex_home = tempfile::tempdir().expect("create codex home"); diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 5b53662599..12b738f537 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -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, #[serde(default)] + hooks: Option, + #[serde(default)] interface: Option, } @@ -43,6 +46,13 @@ pub struct PluginManifestPaths { pub skills: Option, pub mcp_servers: Option, pub apps: Option, + pub hooks: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginManifestHooks { + Paths(Vec), + Inline(Vec), } #[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), + Inline(HooksFile), + InlineList(Vec), + Invalid(JsonValue), +} + pub fn load_plugin_manifest(plugin_root: &Path) -> Option { 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 { skills, mcp_servers, apps, + hooks, interface, } = manifest; let name = plugin_root @@ -219,6 +240,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option { 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 { } } +fn resolve_manifest_hooks( + plugin_root: &Path, + hooks: Option, +) -> Option { + 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::>(); + (!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, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 3fbbfaf6eb..fc314b3cdb 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -463,6 +463,9 @@ "personality": { "type": "boolean" }, + "plugin_hooks": { + "type": "boolean" + }, "plugins": { "type": "boolean" }, @@ -2668,6 +2671,9 @@ "personality": { "type": "boolean" }, + "plugin_hooks": { + "type": "boolean" + }, "plugins": { "type": "boolean" }, diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index db47688685..b534c63cf4 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -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", diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index c8bbba01b9..60b7dd6f4d 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -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 { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 9520485a5b..409398f788 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -690,10 +690,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, }); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 6a2a2bc717..3104bdd505 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -158,6 +158,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. @@ -872,6 +874,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", diff --git a/codex-rs/hooks/Cargo.toml b/codex-rs/hooks/Cargo.toml index d4d2f9cbc7..028a055424 100644 --- a/codex-rs/hooks/Cargo.toml +++ b/codex-rs/hooks/Cargo.toml @@ -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"] } diff --git a/codex-rs/hooks/src/engine/command_runner.rs b/codex-rs/hooks/src/engine/command_runner.rs index e0e08c3fa5..7366d4ec51 100644 --- a/codex-rs/hooks/src/engine/command_runner.rs +++ b/codex-rs/hooks/src/engine/command_runner.rs @@ -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 { diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 4e704e0a03..ebeda10ab9 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -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, } -#[derive(Clone, Copy)] +#[derive(Clone)] struct HookHandlerSource<'a> { path: &'a AbsolutePathBuf, is_managed: bool, source: HookSource, + env: HashMap, } -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, + plugin_hook_load_warnings: Vec, +) -> 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,45 @@ 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, + warnings: &mut Vec, + display_order: &mut i64, + plugin_hook_sources: Vec, +) { + // TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable. + for source in plugin_hook_sources { + let PluginHookSource { + plugin_root, + source_path, + hooks, + .. + } = source; + let mut env = HashMap::new(); + let plugin_root_value = plugin_root.display().to_string(); + env.insert("AGENTS_PLUGIN_ROOT".to_string(), plugin_root_value.clone()); + env.insert("CLAUDE_PLUGIN_ROOT".to_string(), plugin_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 +334,7 @@ fn append_hook_events( handlers, warnings, display_order, - source, + source.clone(), event_name, groups, ); @@ -298,7 +354,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, @@ -358,6 +414,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 +488,7 @@ mod tests { path, is_managed: false, source: hook_source(), + env: std::collections::HashMap::new(), } } @@ -475,6 +533,7 @@ mod tests { source_path: source_path.clone(), source: hook_source(), display_order: 0, + env: std::collections::HashMap::new(), }] ); } @@ -508,6 +567,7 @@ mod tests { source_path: source_path.clone(), source: hook_source(), display_order: 0, + env: std::collections::HashMap::new(), }] ); } diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index d1cda96541..c19b311843 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -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(), } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 3bfb17f6d6..89daf501ca 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -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, } impl ConfiguredHandler { @@ -74,6 +78,8 @@ impl ClaudeHooksEngine { pub(crate) fn new( enabled: bool, config_layer_stack: Option<&ConfigLayerStack>, + plugin_hook_sources: Vec, + plugin_hook_load_warnings: Vec, 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, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 81004aefb4..c64bb1fbf4 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -15,7 +15,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 +108,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 +193,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 +302,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 +334,103 @@ 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"); + 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({{ + "agents": os.environ.get("AGENTS_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(), + 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, + 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!({ + "agents": plugin_root.display().to_string(), + "claude": plugin_root.display().to_string(), + }) + ); +} diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 20cdfd2010..c01cebf78a 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -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(), } } diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 46012150bb..3b20c2c2c0 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -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(), } } diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index b1ccdd440a..54c7f51732 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -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(), } } diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index f376dccd2c..392f15eee2 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -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(), } } diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 2acd4808b8..8aaf3ad608 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -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(), } } diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 6f4e56b1bf..7dd93213a1 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -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>, pub feature_enabled: bool, pub config_layer_stack: Option, + pub plugin_hook_sources: Vec, + pub plugin_hook_load_warnings: Vec, pub shell_program: Option, pub shell_args: Vec, } @@ -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, diff --git a/codex-rs/plugin/Cargo.toml b/codex-rs/plugin/Cargo.toml index b72d74682c..a431a543d4 100644 --- a/codex-rs/plugin/Cargo.toml +++ b/codex-rs/plugin/Cargo.toml @@ -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 } diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index b984b9d2fc..31ecf56015 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -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,15 @@ pub struct PluginCapabilitySummary { pub app_connector_ids: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginHookSource { + pub plugin_id: PluginId, + pub plugin_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, diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index 062886be5c..0865b9020f 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -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 { pub has_enabled_skills: bool, pub mcp_servers: HashMap, pub apps: Vec, + pub hook_sources: Vec, + pub hook_load_warnings: Vec, pub error: Option, } @@ -140,6 +143,22 @@ impl PluginLoadOutcome { apps } + pub fn effective_plugin_hook_sources(&self) -> Vec { + 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 { + 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 } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c3254e92a7..c80b15be98 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1645,6 +1645,7 @@ pub enum HookSource { Project, Mdm, SessionFlags, + Plugin, LegacyManagedConfigFile, LegacyManagedConfigMdm, #[default]