From b2f2c98a7cf7ded3337971cb9176022e7384356f Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 13:10:30 -0700 Subject: [PATCH 01/64] 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 5ff3f462f10..fdd119aa2ec 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 98d0e6ff6b9..24ae8e00b9c 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 629c0b97fa5..df78bc0c3f9 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 2fc1be34693..404f6819443 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 87e133a07ad..83f58895664 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 a4d378649b6..7c03e35543b 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 ac77d6163f2..d08300d5264 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 7edf61f9186..24a06bd1385 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 b7dccc8613b..c0a76f1b790 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 589467199e3..0aa7a115fbd 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 5b536625998..12b738f537f 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 3fbbfaf6ebc..fc314b3cdbe 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 db47688685b..b534c63cf42 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 c8bbba01b9c..60b7dd6f4d7 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 9520485a5b7..409398f7887 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 6a2a2bc7176..3104bdd505d 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 d4d2f9cbc70..028a0554248 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 e0e08c3fa5e..7366d4ec511 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 4e704e0a035..ebeda10ab97 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 d1cda96541a..c19b311843b 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 3bfb17f6d6f..89daf501cae 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 81004aefb42..c64bb1fbf49 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 20cdfd20101..c01cebf78a2 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 46012150bb5..3b20c2c2c02 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 b1ccdd440a3..54c7f51732b 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 f376dccd2c0..392f15eee24 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 2acd4808b8b..8aaf3ad608e 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 6f4e56b1bfa..7dd93213a11 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 b72d74682c6..a431a543d43 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 b984b9d2fcd..31ecf560152 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 062886be5c1..0865b9020fc 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 c3254e92a75..c80b15be986 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] From d2097f980273f26cf0fcf93f0b2405e46ff6181d Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 13:32:55 -0700 Subject: [PATCH 02/64] Remove plugin hook load warnings from discovery --- codex-rs/core-plugins/src/loader.rs | 83 +++++++++------------- codex-rs/core/src/plugins/manager_tests.rs | 3 - codex-rs/core/src/session/session.rs | 10 +-- codex-rs/hooks/src/engine/discovery.rs | 8 +-- codex-rs/hooks/src/engine/mod.rs | 7 +- codex-rs/hooks/src/engine/mod_tests.rs | 8 +-- codex-rs/hooks/src/registry.rs | 2 - codex-rs/plugin/src/load_outcome.rs | 9 --- 8 files changed, 44 insertions(+), 86 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 0aa7a115fbd..df2c6492b65 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -483,7 +483,6 @@ async fn load_plugin( mcp_servers: HashMap::new(), apps: Vec::new(), hook_sources: Vec::new(), - hook_load_warnings: Vec::new(), error: None, }; @@ -552,9 +551,7 @@ 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.hook_sources = load_plugin_hooks(&plugin_root, &loaded_plugin_id, manifest_paths); loaded_plugin } @@ -684,22 +681,19 @@ fn default_app_config_paths(plugin_root: &Path) -> Vec { paths } -#[derive(Debug, Default)] -pub struct PluginHookDiscovery { - pub sources: Vec, - pub warnings: Vec, -} - +// 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, manifest_paths: &PluginManifestPaths, -) -> PluginHookDiscovery { - let mut discovery = PluginHookDiscovery::default(); +) -> Vec { + let mut sources = Vec::new(); match &manifest_paths.hooks { Some(PluginManifestHooks::Paths(paths)) => { for path in paths { - append_plugin_hook_file(plugin_root, plugin_id, path, &mut discovery); + append_plugin_hook_file(plugin_root, plugin_id, path, &mut sources); } } Some(PluginManifestHooks::Inline(hooks_files)) => { @@ -710,7 +704,7 @@ pub fn load_plugin_hooks( if hooks_file.hooks.is_empty() { continue; } - discovery.sources.push(PluginHookSource { + sources.push(PluginHookSource { plugin_id: plugin_id.clone(), plugin_root: plugin_root.clone(), source_path: manifest_path.clone(), @@ -722,36 +716,38 @@ pub fn load_plugin_hooks( 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); + append_plugin_hook_file(plugin_root, plugin_id, &default_path, &mut sources); } } } - discovery + sources } +// Load one resolved plugin hook file and keep source metadata with its parsed +// hook events so runtime discovery can report plugin-originated hook runs. fn append_plugin_hook_file( plugin_root: &AbsolutePathBuf, plugin_id: &PluginId, path: &AbsolutePathBuf, - discovery: &mut PluginHookDiscovery, + sources: &mut Vec, ) { 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() - )); + warn!( + path = %path.display(), + "failed to read plugin hooks config: {err}" + ); 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() - )); + warn!( + path = %path.display(), + "failed to parse plugin hooks config: {err}" + ); return; } }; @@ -759,7 +755,7 @@ fn append_plugin_hook_file( return; } - discovery.sources.push(PluginHookSource { + sources.push(PluginHookSource { plugin_id: plugin_id.clone(), plugin_root: plugin_root.clone(), source_path: path.clone(), @@ -1241,19 +1237,15 @@ mod tests { 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); + let sources = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); - assert_eq!(discovery.warnings, Vec::::new()); - assert_eq!(discovery.sources.len(), 1); + assert_eq!(sources.len(), 1); assert_eq!( - discovery.sources[0].plugin_id, + 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); + assert_eq!(sources[0].source_relative_path, "hooks/hooks.json"); + assert_eq!(sources[0].hooks.handler_count(), 1); } #[test] @@ -1313,20 +1305,17 @@ mod tests { 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); + let sources = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); - assert_eq!(discovery.warnings, Vec::::new()); assert_eq!( - discovery - .sources + sources .iter() .map(|source| source.source_relative_path.as_str()) .collect::>(), vec!["hooks/one.json", "hooks/two.json"] ); assert_eq!( - discovery - .sources + sources .iter() .map(|source| source.hooks.handler_count()) .collect::>(), @@ -1360,15 +1349,11 @@ mod tests { 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); + let sources = 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); + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].source_relative_path, "plugin.json#hooks[0]"); + assert_eq!(sources[0].hooks.handler_count(), 1); } #[test] diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 60b7dd6f4d7..4e794506c01 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -220,7 +220,6 @@ 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, }] ); @@ -722,7 +721,6 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions mcp_servers: HashMap::new(), apps: Vec::new(), hook_sources: Vec::new(), - hook_load_warnings: Vec::new(), error: None, }] ); @@ -841,7 +839,6 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { 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 409398f7887..e49e25ac6e8 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -691,21 +691,17 @@ impl Session { 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_hook_sources = 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(), - ) + plugin_outcome.effective_plugin_hook_sources() } else { - (Vec::new(), 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/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index ebeda10ab97..ce42908c097 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -38,11 +38,10 @@ struct HookHandlerSource<'a> { 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 { let mut handlers = Vec::new(); - let mut warnings = plugin_hook_load_warnings; + let mut warnings = Vec::new(); let mut display_order = 0_i64; append_plugin_hook_sources( &mut handlers, @@ -54,7 +53,7 @@ pub(crate) fn discover_handlers( }; let mut handlers = Vec::new(); - let mut warnings = plugin_hook_load_warnings; + let mut warnings = Vec::new(); let mut display_order = 0_i64; append_managed_requirement_handlers( @@ -169,7 +168,8 @@ fn append_plugin_hook_sources( } = 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("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); append_hook_events( handlers, diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 89daf501cae..5c121136f7a 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -79,7 +79,6 @@ impl ClaudeHooksEngine { enabled: bool, config_layer_stack: Option<&ConfigLayerStack>, plugin_hook_sources: Vec, - plugin_hook_load_warnings: Vec, shell: CommandShell, ) -> Self { if !enabled { @@ -91,11 +90,7 @@ impl ClaudeHooksEngine { } let _ = schema_loader::generated_hook_schemas(); - let discovered = discovery::discover_handlers( - config_layer_stack, - plugin_hook_sources, - plugin_hook_load_warnings, - ); + let discovered = discovery::discover_handlers(config_layer_stack, plugin_hook_sources); 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 c64bb1fbf49..4b382df4a63 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -109,7 +109,6 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: /*enabled*/ true, Some(&config_layer_stack), Vec::new(), - Vec::new(), CommandShell { program: String::new(), args: Vec::new(), @@ -194,7 +193,6 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { /*enabled*/ true, Some(&config_layer_stack), Vec::new(), - Vec::new(), CommandShell { program: String::new(), args: Vec::new(), @@ -303,7 +301,6 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { /*enabled*/ true, Some(&config_layer_stack), Vec::new(), - Vec::new(), CommandShell { program: String::new(), args: Vec::new(), @@ -352,7 +349,7 @@ import os from pathlib import Path Path(r"{log_path}").write_text(json.dumps({{ - "agents": os.environ.get("AGENTS_PLUGIN_ROOT"), + "plugin": os.environ.get("PLUGIN_ROOT"), "claude": os.environ.get("CLAUDE_PLUGIN_ROOT"), }}), encoding="utf-8") "#, @@ -383,7 +380,6 @@ Path(r"{log_path}").write_text(json.dumps({{ /*enabled*/ true, None, plugin_hook_sources, - Vec::new(), CommandShell { program: String::new(), args: Vec::new(), @@ -429,7 +425,7 @@ Path(r"{log_path}").write_text(json.dumps({{ assert_eq!( logged, serde_json::json!({ - "agents": plugin_root.display().to_string(), + "plugin": plugin_root.display().to_string(), "claude": plugin_root.display().to_string(), }) ); diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 7dd93213a11..4509a8a6318 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -27,7 +27,6 @@ pub struct HooksConfig { 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, } @@ -57,7 +56,6 @@ impl Hooks { 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/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index 0865b9020fc..40dba4ae2ae 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -23,7 +23,6 @@ pub struct LoadedPlugin { pub mcp_servers: HashMap, pub apps: Vec, pub hook_sources: Vec, - pub hook_load_warnings: Vec, pub error: Option, } @@ -151,14 +150,6 @@ impl PluginLoadOutcome { .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 } From 6655134d4954f954d21b9aa43ba919043f9348e8 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 13:43:54 -0700 Subject: [PATCH 03/64] Refactor plugin hook file loading --- codex-rs/core-plugins/src/loader.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index df2c6492b65..306c71960ad 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -693,7 +693,9 @@ pub fn load_plugin_hooks( match &manifest_paths.hooks { Some(PluginManifestHooks::Paths(paths)) => { for path in paths { - append_plugin_hook_file(plugin_root, plugin_id, path, &mut sources); + if let Some(source) = load_plugin_hook_file(plugin_root, plugin_id, path) { + sources.push(source); + } } } Some(PluginManifestHooks::Inline(hooks_files)) => { @@ -715,8 +717,10 @@ pub fn load_plugin_hooks( } 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 sources); + if default_path.as_path().is_file() + && let Some(source) = load_plugin_hook_file(plugin_root, plugin_id, &default_path) + { + sources.push(source); } } } @@ -725,12 +729,11 @@ pub fn load_plugin_hooks( // Load one resolved plugin hook file and keep source metadata with its parsed // hook events so runtime discovery can report plugin-originated hook runs. -fn append_plugin_hook_file( +fn load_plugin_hook_file( plugin_root: &AbsolutePathBuf, plugin_id: &PluginId, path: &AbsolutePathBuf, - sources: &mut Vec, -) { +) -> Option { let contents = match fs::read_to_string(path.as_path()) { Ok(contents) => contents, Err(err) => { @@ -738,7 +741,7 @@ fn append_plugin_hook_file( path = %path.display(), "failed to read plugin hooks config: {err}" ); - return; + return None; } }; let parsed = match serde_json::from_str::(&contents) { @@ -748,20 +751,20 @@ fn append_plugin_hook_file( path = %path.display(), "failed to parse plugin hooks config: {err}" ); - return; + return None; } }; if parsed.hooks.is_empty() { - return; + return None; } - sources.push(PluginHookSource { + Some(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 { From 5aed78ae4b8d9e1babd521dd595a5681c1b76ed7 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 13:57:39 -0700 Subject: [PATCH 04/64] Fix plugin hook test argument comment --- codex-rs/hooks/src/engine/mod_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 4b382df4a63..245c31ba5fd 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -378,7 +378,7 @@ Path(r"{log_path}").write_text(json.dumps({{ }]; let engine = ClaudeHooksEngine::new( /*enabled*/ true, - None, + /*config_layer_stack*/ None, plugin_hook_sources, CommandShell { program: String::new(), From 803c16f2d34827f5853760a4eddc5e008e878bbc Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 14:12:55 -0700 Subject: [PATCH 05/64] Move plugin loader tests out of implementation --- codex-rs/core-plugins/src/loader.rs | 296 +----------------- codex-rs/core-plugins/src/loader_tests.rs | 347 ++++++++++++++++++++++ 2 files changed, 349 insertions(+), 294 deletions(-) create mode 100644 codex-rs/core-plugins/src/loader_tests.rs diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 306c71960ad..b2be23c45a4 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -1114,297 +1114,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::( - 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::( - 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::( - 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 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 sources = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); - - assert_eq!(sources.len(), 1); - assert_eq!( - sources[0].plugin_id, - PluginId::parse("demo-plugin@test-marketplace").expect("plugin id") - ); - assert_eq!(sources[0].source_relative_path, "hooks/hooks.json"); - assert_eq!(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 sources = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); - - assert_eq!( - sources - .iter() - .map(|source| source.source_relative_path.as_str()) - .collect::>(), - vec!["hooks/one.json", "hooks/two.json"] - ); - assert_eq!( - 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 sources = load_plugin_hooks(&plugin_root, &plugin_id, &manifest.paths); - - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].source_relative_path, "plugin.json#hooks[0]"); - assert_eq!(sources[0].hooks.handler_count(), 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()); - } -} +#[path = "loader_tests.rs"] +mod tests; diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs new file mode 100644 index 00000000000..92ac4160693 --- /dev/null +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -0,0 +1,347 @@ +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::( + 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::( + 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::( + 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 { + let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); + load_plugin_hooks(plugin_root, &plugin_id(), &manifest.paths) +} + +#[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 = load_sources(&plugin_root); + + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].plugin_id, plugin_id()); + assert_eq!(sources[0].source_relative_path, "hooks/hooks.json"); + assert_eq!(sources[0].hooks.handler_count(), 1); +} + +#[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 = load_sources(&plugin_root); + + assert_eq!( + sources + .iter() + .map(|source| source.source_relative_path.as_str()) + .collect::>(), + vec!["hooks/one.json"] + ); + assert_eq!(sources[0].hooks.handler_count(), 1); +} + +#[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 = load_sources(&plugin_root); + + assert_eq!( + sources + .iter() + .map(|source| source.source_relative_path.as_str()) + .collect::>(), + vec!["hooks/one.json", "hooks/two.json"] + ); + assert_eq!( + sources + .iter() + .map(|source| source.hooks.handler_count()) + .collect::>(), + vec![1, 1] + ); +} + +#[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 = load_sources(&plugin_root); + + assert_eq!(sources.len(), 1); + assert_eq!(sources[0].source_relative_path, "plugin.json#hooks[0]"); + assert_eq!(sources[0].hooks.handler_count(), 1); +} + +#[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 = load_sources(&plugin_root); + + assert_eq!( + sources + .iter() + .map(|source| source.source_relative_path.as_str()) + .collect::>(), + vec!["plugin.json#hooks[0]", "plugin.json#hooks[1]"] + ); + assert_eq!( + sources + .iter() + .map(|source| source.hooks.handler_count()) + .collect::>(), + vec![1, 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()); +} From 108dc3514ca7b841813e4605f220f47dea2d2d96 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 21:14:48 -0700 Subject: [PATCH 06/64] Inline plugin hook relative path --- codex-rs/core-plugins/src/loader.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index b2be23c45a4..41217666ae9 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -758,22 +758,22 @@ fn load_plugin_hook_file( return None; } + let source_relative_path = path + .as_path() + .strip_prefix(plugin_root.as_path()) + .unwrap_or(path.as_path()) + .to_string_lossy() + .replace('\\', "/"); + Some(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()), + source_relative_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, From d5539f437651ccecfff224ec4c88b9073d1a9fe9 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Sun, 26 Apr 2026 22:14:47 -0700 Subject: [PATCH 07/64] Add hook listing and config APIs --- .../schema/json/ClientRequest.json | 75 ++++++ .../codex_app_server_protocol.schemas.json | 227 ++++++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 227 ++++++++++++++++++ .../json/v2/HooksConfigWriteParams.json | 17 ++ .../json/v2/HooksConfigWriteResponse.json | 13 + .../schema/json/v2/HooksListParams.json | 14 ++ .../schema/json/v2/HooksListResponse.json | 174 ++++++++++++++ .../schema/typescript/ClientRequest.ts | 4 +- .../schema/typescript/v2/HookErrorInfo.ts | 5 + .../schema/typescript/v2/HookMetadata.ts | 9 + .../typescript/v2/HooksConfigWriteParams.ts | 5 + .../typescript/v2/HooksConfigWriteResponse.ts | 5 + .../schema/typescript/v2/HooksListEntry.ts | 7 + .../schema/typescript/v2/HooksListParams.ts | 9 + .../schema/typescript/v2/HooksListResponse.ts | 6 + .../schema/typescript/v2/index.ts | 7 + .../src/protocol/common.rs | 8 + .../app-server-protocol/src/protocol/v2.rs | 68 ++++++ codex-rs/app-server/README.md | 55 +++++ .../app-server/src/codex_message_processor.rs | 164 +++++++++++++ codex-rs/app-server/src/config_api.rs | 1 + .../app-server/tests/common/mcp_process.rs | 20 ++ .../app-server/tests/suite/v2/hooks_list.rs | 99 ++++++++ codex-rs/app-server/tests/suite/v2/mod.rs | 1 + codex-rs/config/src/hook_config.rs | 11 + codex-rs/config/src/lib.rs | 1 + codex-rs/config/src/types.rs | 1 + codex-rs/core/config.schema.json | 20 ++ codex-rs/core/src/config/edit.rs | 120 +++++++++ codex-rs/core/src/config/edit_tests.rs | 46 ++++ codex-rs/core/src/hooks.rs | 3 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/plugins/manager.rs | 14 ++ codex-rs/hooks/src/config_rules.rs | 71 ++++++ codex-rs/hooks/src/engine/discovery.rs | 199 +++++++++------ codex-rs/hooks/src/engine/dispatcher.rs | 4 + codex-rs/hooks/src/engine/mod.rs | 62 ++++- codex-rs/hooks/src/engine/mod_tests.rs | 86 +++++++ codex-rs/hooks/src/events/post_tool_use.rs | 4 + codex-rs/hooks/src/events/pre_tool_use.rs | 4 + codex-rs/hooks/src/events/session_start.rs | 4 + codex-rs/hooks/src/events/stop.rs | 4 + .../hooks/src/events/user_prompt_submit.rs | 4 + codex-rs/hooks/src/lib.rs | 2 + codex-rs/hooks/src/registry.rs | 5 + 45 files changed, 1811 insertions(+), 75 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts create mode 100644 codex-rs/app-server/tests/suite/v2/hooks_list.rs create mode 100644 codex-rs/core/src/hooks.rs create mode 100644 codex-rs/hooks/src/config_rules.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index f34ee289767..8dc1e5e5d87 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1447,6 +1447,33 @@ ], "type": "object" }, + "HooksConfigWriteParams": { + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + }, + "required": [ + "enabled", + "key" + ], + "type": "object" + }, + "HooksListParams": { + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "ImageDetail": { "enum": [ "auto", @@ -4880,6 +4907,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "hooks/list" + ], + "title": "Hooks/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HooksListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -5336,6 +5387,30 @@ "title": "Skills/config/writeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "hooks/config/write" + ], + "title": "Hooks/config/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HooksConfigWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/config/writeRequest", + "type": "object" + }, { "properties": { "id": { 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 404f6819443..3f780790d69 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 @@ -642,6 +642,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "hooks/list" + ], + "title": "Hooks/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/HooksListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -1098,6 +1122,30 @@ "title": "Skills/config/writeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "hooks/config/write" + ], + "title": "Hooks/config/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/HooksConfigWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/config/writeRequest", + "type": "object" + }, { "properties": { "id": { @@ -9567,6 +9615,21 @@ "title": "HookCompletedNotification", "type": "object" }, + "HookErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, "HookEventName": { "enum": [ "preToolUse", @@ -9593,6 +9656,78 @@ ], "type": "string" }, + "HookMetadata": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventName": { + "$ref": "#/definitions/v2/HookEventName" + }, + "handlerType": { + "$ref": "#/definitions/v2/HookHandlerType" + }, + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/v2/HookSource" + }, + "sourcePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "sourceRelativePath": { + "type": [ + "string", + "null" + ] + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "displayOrder", + "enabled", + "eventName", + "handlerType", + "key", + "source", + "sourcePath", + "timeoutSec" + ], + "type": "object" + }, "HookOutputEntry": { "properties": { "kind": { @@ -9767,6 +9902,98 @@ "title": "HookStartedNotification", "type": "object" }, + "HooksConfigWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + }, + "required": [ + "enabled", + "key" + ], + "title": "HooksConfigWriteParams", + "type": "object" + }, + "HooksConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "effectiveEnabled": { + "type": "boolean" + } + }, + "required": [ + "effectiveEnabled" + ], + "title": "HooksConfigWriteResponse", + "type": "object" + }, + "HooksListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/v2/HookErrorInfo" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/definitions/v2/HookMetadata" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "hooks", + "warnings" + ], + "type": "object" + }, + "HooksListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "HooksListParams", + "type": "object" + }, + "HooksListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/HooksListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "HooksListResponse", + "type": "object" + }, "ImageDetail": { "enum": [ "auto", 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 83f58895664..70c8f84bf7d 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 @@ -1348,6 +1348,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "hooks/list" + ], + "title": "Hooks/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HooksListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -1804,6 +1828,30 @@ "title": "Skills/config/writeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "hooks/config/write" + ], + "title": "Hooks/config/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HooksConfigWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/config/writeRequest", + "type": "object" + }, { "properties": { "id": { @@ -6197,6 +6245,21 @@ "title": "HookCompletedNotification", "type": "object" }, + "HookErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, "HookEventName": { "enum": [ "preToolUse", @@ -6223,6 +6286,78 @@ ], "type": "string" }, + "HookMetadata": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/HookSource" + }, + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "sourceRelativePath": { + "type": [ + "string", + "null" + ] + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "displayOrder", + "enabled", + "eventName", + "handlerType", + "key", + "source", + "sourcePath", + "timeoutSec" + ], + "type": "object" + }, "HookOutputEntry": { "properties": { "kind": { @@ -6397,6 +6532,98 @@ "title": "HookStartedNotification", "type": "object" }, + "HooksConfigWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + }, + "required": [ + "enabled", + "key" + ], + "title": "HooksConfigWriteParams", + "type": "object" + }, + "HooksConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "effectiveEnabled": { + "type": "boolean" + } + }, + "required": [ + "effectiveEnabled" + ], + "title": "HooksConfigWriteResponse", + "type": "object" + }, + "HooksListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/HookErrorInfo" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/definitions/HookMetadata" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "hooks", + "warnings" + ], + "type": "object" + }, + "HooksListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "HooksListParams", + "type": "object" + }, + "HooksListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/HooksListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "HooksListResponse", + "type": "object" + }, "ImageDetail": { "enum": [ "auto", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json new file mode 100644 index 00000000000..da575768df9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + }, + "required": [ + "enabled", + "key" + ], + "title": "HooksConfigWriteParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json new file mode 100644 index 00000000000..6016edad4f9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "effectiveEnabled": { + "type": "boolean" + } + }, + "required": [ + "effectiveEnabled" + ], + "title": "HooksConfigWriteResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json new file mode 100644 index 00000000000..858d415f4f1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListParams.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "title": "HooksListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json new file mode 100644 index 00000000000..c758455bc52 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -0,0 +1,174 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "HookErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, + "HookHandlerType": { + "enum": [ + "command", + "prompt", + "agent" + ], + "type": "string" + }, + "HookMetadata": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "enabled": { + "type": "boolean" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "key": { + "type": "string" + }, + "matcher": { + "type": [ + "string", + "null" + ] + }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/HookSource" + }, + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "sourceRelativePath": { + "type": [ + "string", + "null" + ] + }, + "statusMessage": { + "type": [ + "string", + "null" + ] + }, + "timeoutSec": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "displayOrder", + "enabled", + "eventName", + "handlerType", + "key", + "source", + "sourcePath", + "timeoutSec" + ], + "type": "object" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "plugin", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + }, + "HooksListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/HookErrorInfo" + }, + "type": "array" + }, + "hooks": { + "items": { + "$ref": "#/definitions/HookMetadata" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "hooks", + "warnings" + ], + "type": "object" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/HooksListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "HooksListResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 7aaa17461c1..2a28d77b8ce 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -34,6 +34,8 @@ import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; +import type { HooksConfigWriteParams } from "./v2/HooksConfigWriteParams"; +import type { HooksListParams } from "./v2/HooksListParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; import type { MarketplaceAddParams } from "./v2/MarketplaceAddParams"; @@ -76,4 +78,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "hooks/config/write", id: RequestId, params: HooksConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts new file mode 100644 index 00000000000..75c259b0c0c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookErrorInfo.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HookErrorInfo = { path: string, message: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts new file mode 100644 index 00000000000..c56eb004ada --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { HookEventName } from "./HookEventName"; +import type { HookHandlerType } from "./HookHandlerType"; +import type { HookSource } from "./HookSource"; + +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, sourceRelativePath: string | null, displayOrder: bigint, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts new file mode 100644 index 00000000000..d7f7394a339 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HooksConfigWriteParams = { key: string, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts new file mode 100644 index 00000000000..10b3b73da45 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HooksConfigWriteResponse = { effectiveEnabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts new file mode 100644 index 00000000000..256b29bb465 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListEntry.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HookErrorInfo } from "./HookErrorInfo"; +import type { HookMetadata } from "./HookMetadata"; + +export type HooksListEntry = { cwd: string, hooks: Array, warnings: Array, errors: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts new file mode 100644 index 00000000000..db29387d29c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListParams.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HooksListParams = { +/** + * When empty, defaults to the current session working directory. + */ +cwds?: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts new file mode 100644 index 00000000000..4c2dd1a8dba --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksListResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HooksListEntry } from "./HooksListEntry"; + +export type HooksListResponse = { data: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 0e43b5a4b7c..fcc9881dfb3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -151,9 +151,11 @@ export type { GuardianRiskLevel } from "./GuardianRiskLevel"; export type { GuardianUserAuthorization } from "./GuardianUserAuthorization"; export type { GuardianWarningNotification } from "./GuardianWarningNotification"; export type { HookCompletedNotification } from "./HookCompletedNotification"; +export type { HookErrorInfo } from "./HookErrorInfo"; export type { HookEventName } from "./HookEventName"; export type { HookExecutionMode } from "./HookExecutionMode"; export type { HookHandlerType } from "./HookHandlerType"; +export type { HookMetadata } from "./HookMetadata"; export type { HookOutputEntry } from "./HookOutputEntry"; export type { HookOutputEntryKind } from "./HookOutputEntryKind"; export type { HookPromptFragment } from "./HookPromptFragment"; @@ -162,6 +164,11 @@ export type { HookRunSummary } from "./HookRunSummary"; export type { HookScope } from "./HookScope"; export type { HookSource } from "./HookSource"; export type { HookStartedNotification } from "./HookStartedNotification"; +export type { HooksConfigWriteParams } from "./HooksConfigWriteParams"; +export type { HooksConfigWriteResponse } from "./HooksConfigWriteResponse"; +export type { HooksListEntry } from "./HooksListEntry"; +export type { HooksListParams } from "./HooksListParams"; +export type { HooksListResponse } from "./HooksListResponse"; export type { ItemCompletedNotification } from "./ItemCompletedNotification"; export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification"; export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 016d6e16b8b..219e806bdb5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -364,6 +364,10 @@ client_request_definitions! { params: v2::SkillsListParams, response: v2::SkillsListResponse, }, + HooksList => "hooks/list" { + params: v2::HooksListParams, + response: v2::HooksListResponse, + }, MarketplaceAdd => "marketplace/add" { params: v2::MarketplaceAddParams, response: v2::MarketplaceAddResponse, @@ -440,6 +444,10 @@ client_request_definitions! { params: v2::SkillsConfigWriteParams, response: v2::SkillsConfigWriteResponse, }, + HooksConfigWrite => "hooks/config/write" { + params: v2::HooksConfigWriteParams, + response: v2::HooksConfigWriteResponse, + }, PluginInstall => "plugin/install" { params: v2::PluginInstallParams, response: v2::PluginInstallResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c0a76f1b790..93d6a2d529d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4254,6 +4254,22 @@ pub struct SkillsListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListResponse { + pub data: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4457,6 +4473,43 @@ pub struct SkillsListEntry { pub errors: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListEntry { + pub cwd: PathBuf, + pub hooks: Vec, + pub warnings: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookMetadata { + pub key: String, + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub matcher: Option, + pub command: Option, + pub timeout_sec: u64, + pub status_message: Option, + pub source_path: AbsolutePathBuf, + pub source: HookSource, + pub plugin_id: Option, + pub source_relative_path: Option, + pub display_order: i64, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookErrorInfo { + pub path: PathBuf, + pub message: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4612,6 +4665,21 @@ pub struct SkillsConfigWriteResponse { pub effective_enabled: bool, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksConfigWriteParams { + pub key: String, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksConfigWriteResponse { + pub effective_enabled: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 35df7016c48..3a2dbdf3e02 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -195,6 +195,7 @@ Example with notification opt-out: - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). +- `hooks/list` — list discovered hooks for one or more `cwd` values, including hooks disabled by user config. - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. @@ -206,6 +207,7 @@ Example with notification opt-out: - `device/key/public` — return a device key's SPKI DER public key as base64 plus its `algorithm` and `protectionClass`. - `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API. - `skills/config/write` — write user-level skill config by name or absolute path. +- `hooks/config/write` — write user-level hook config by stable hook key. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. @@ -1450,6 +1452,59 @@ To enable or disable a skill by name: } ``` +Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. + +```json +{ + "method": "hooks/list", + "id": 28, + "params": { + "cwds": ["/Users/me/project"] + } +} +``` + +```json +{ + "id": 28, + "result": { + "data": [{ + "cwd": "/Users/me/project", + "hooks": [{ + "key": "path:/Users/me/.codex/config.toml:pre_tool_use:0:0", + "eventName": "pre_tool_use", + "handlerType": "command", + "matcher": "Bash", + "command": "python3 /Users/me/hook.py", + "timeoutSec": 5, + "statusMessage": "running hook", + "sourcePath": "/Users/me/.codex/config.toml", + "source": "user", + "pluginId": null, + "sourceRelativePath": null, + "displayOrder": 0, + "enabled": true + }], + "warnings": [], + "errors": [] + }] + } +} +``` + +To enable or disable a hook, write the hook key returned by `hooks/list`: + +```json +{ + "method": "hooks/config/write", + "id": 29, + "params": { + "key": "path:/Users/me/.codex/config.toml:pre_tool_use:0:0", + "enabled": false + } +} +``` + ## Apps Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index cddc5d58564..ce83a22bb26 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -75,6 +75,11 @@ use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; +use codex_app_server_protocol::HookMetadata; +use codex_app_server_protocol::HooksConfigWriteParams; +use codex_app_server_protocol::HooksConfigWriteResponse; +use codex_app_server_protocol::HooksListParams; +use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; @@ -1042,6 +1047,10 @@ impl CodexMessageProcessor { self.skills_list(to_connection_request_id(request_id), params) .await; } + ClientRequest::HooksList { request_id, params } => { + self.hooks_list(to_connection_request_id(request_id), params) + .await; + } ClientRequest::MarketplaceAdd { request_id, params } => { self.marketplace_add(to_connection_request_id(request_id), params) .await; @@ -1070,6 +1079,10 @@ impl CodexMessageProcessor { self.skills_config_write(to_connection_request_id(request_id), params) .await; } + ClientRequest::HooksConfigWrite { request_id, params } => { + self.hooks_config_write(to_connection_request_id(request_id), params) + .await; + } ClientRequest::PluginInstall { request_id, params } => { self.plugin_install(to_connection_request_id(request_id), params) .await; @@ -6896,6 +6909,92 @@ impl CodexMessageProcessor { .send_response(request_id, SkillsListResponse { data }) .await; } + + async fn hooks_list(&self, request_id: ConnectionRequestId, params: HooksListParams) { + let HooksListParams { cwds } = params; + let cwds = if cwds.is_empty() { + vec![self.config.cwd.to_path_buf()] + } else { + cwds + }; + + let config = match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => config, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let auth = self.auth_manager.auth().await; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; + let plugins_manager = self.thread_manager.plugins_manager(); + let mut data = Vec::new(); + for cwd in cwds { + let cwd_abs = match AbsolutePathBuf::relative_to_current_dir(cwd.as_path()) { + Ok(path) => path, + Err(err) => { + let error_path = cwd.clone(); + data.push(codex_app_server_protocol::HooksListEntry { + cwd, + hooks: Vec::new(), + warnings: Vec::new(), + errors: vec![codex_app_server_protocol::HookErrorInfo { + path: error_path, + message: err.to_string(), + }], + }); + continue; + } + }; + let config_layer_stack = match self + .config_manager + .load_config_layers_for_cwd(cwd_abs.clone()) + .await + { + Ok(config_layer_stack) => config_layer_stack, + Err(err) => { + let error_path = cwd.clone(); + data.push(codex_app_server_protocol::HooksListEntry { + cwd, + hooks: Vec::new(), + warnings: Vec::new(), + errors: vec![codex_app_server_protocol::HookErrorInfo { + path: error_path, + message: err.to_string(), + }], + }); + continue; + } + }; + let plugin_hook_sources = plugins_manager + .effective_plugin_hook_sources_for_layer_stack( + &config_layer_stack, + config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled, + config.features.enabled(Feature::PluginHooks), + ) + .await; + let hooks = codex_core::hooks::Hooks::new(codex_core::hooks::HooksConfig { + legacy_notify_argv: None, + feature_enabled: config.features.enabled(Feature::CodexHooks), + config_layer_stack: Some(config_layer_stack), + plugin_hook_sources, + shell_program: None, + shell_args: Vec::new(), + }); + data.push(codex_app_server_protocol::HooksListEntry { + cwd, + hooks: hooks_to_info(hooks.configured_hooks()), + warnings: hooks.startup_warnings().to_vec(), + errors: Vec::new(), + }); + } + self.outgoing + .send_response(request_id, HooksListResponse { data }) + .await; + } + async fn marketplace_remove( &self, request_id: ConnectionRequestId, @@ -7075,6 +7174,50 @@ impl CodexMessageProcessor { } } + async fn hooks_config_write( + &self, + request_id: ConnectionRequestId, + params: HooksConfigWriteParams, + ) { + let HooksConfigWriteParams { key, enabled } = params; + if key.trim().is_empty() { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "hooks/config/write requires a non-empty key".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits(vec![ConfigEdit::SetHookConfig { key, enabled }]) + .apply() + .await; + + match result { + Ok(()) => { + self.clear_plugin_related_caches(); + self.outgoing + .send_response( + request_id, + HooksConfigWriteResponse { + effective_enabled: enabled, + }, + ) + .await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to update hook settings: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn turn_start( &self, request_id: ConnectionRequestId, @@ -9361,6 +9504,27 @@ fn skills_to_info( .collect() } +fn hooks_to_info(hooks: &[codex_core::hooks::HookListEntry]) -> Vec { + hooks + .iter() + .map(|hook| HookMetadata { + key: hook.key.clone(), + event_name: hook.event_name.into(), + handler_type: hook.handler_type.into(), + matcher: hook.matcher.clone(), + command: hook.command.clone(), + timeout_sec: hook.timeout_sec, + status_message: hook.status_message.clone(), + source_path: hook.source_path.clone(), + source: hook.source.into(), + plugin_id: hook.plugin_id.clone(), + source_relative_path: hook.source_relative_path.clone(), + display_order: hook.display_order, + enabled: hook.enabled, + }) + .collect() +} + fn plugin_skills_to_info( skills: &[codex_core::skills::SkillMetadata], disabled_skill_paths: &std::collections::HashSet, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index ce0ea340697..0d0ae40b660 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -318,6 +318,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed session_start, user_prompt_submit, stop, + config: _, } = hooks; ManagedHooksRequirements { diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index befa248e80f..82b7bbe9703 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -37,6 +37,8 @@ use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetConversationSummaryParams; +use codex_app_server_protocol::HooksConfigWriteParams; +use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; @@ -548,6 +550,24 @@ impl McpProcess { self.send_request("skills/list", params).await } + /// Send a `hooks/list` JSON-RPC request. + pub async fn send_hooks_list_request( + &mut self, + params: HooksListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("hooks/list", params).await + } + + /// Send a `hooks/config/write` JSON-RPC request. + pub async fn send_hooks_config_write_request( + &mut self, + params: HooksConfigWriteParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("hooks/config/write", params).await + } + /// Send a `marketplace/add` JSON-RPC request. pub async fn send_marketplace_add_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs new file mode 100644 index 00000000000..2522aef33d9 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -0,0 +1,99 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::HookSource; +use codex_app_server_protocol::HooksConfigWriteParams; +use codex_app_server_protocol::HooksConfigWriteResponse; +use codex_app_server_protocol::HooksListParams; +use codex_app_server_protocol::HooksListResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +fn write_user_hook_config(codex_home: &std::path::Path) -> Result<()> { + std::fs::write( + codex_home.join("config.toml"), + r#"[hooks] + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /tmp/listed-hook.py" +timeout = 5 +statusMessage = "running listed hook" +"#, + )?; + Ok(()) +} + +#[tokio::test] +async fn hooks_list_shows_discovered_hook_and_config_write_disables_it() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_user_hook_config(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].cwd.as_path(), cwd.path()); + assert_eq!(data[0].hooks.len(), 1); + let hook = &data[0].hooks[0]; + assert_eq!(hook.event_name, HookEventName::PreToolUse); + assert_eq!(hook.matcher.as_deref(), Some("Bash")); + assert_eq!(hook.command.as_deref(), Some("python3 /tmp/listed-hook.py")); + assert_eq!(hook.timeout_sec, 5); + assert_eq!(hook.status_message.as_deref(), Some("running listed hook")); + assert_eq!(hook.source, HookSource::User); + assert_eq!(hook.enabled, true); + + let write_id = mcp + .send_hooks_config_write_request(HooksConfigWriteParams { + key: hook.key.clone(), + enabled: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let HooksConfigWriteResponse { effective_enabled } = to_response(response)?; + assert_eq!(effective_enabled, false); + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!(data[0].hooks.len(), 1); + assert_eq!(data[0].hooks[0].key, hook.key); + assert_eq!(data[0].hooks[0].enabled, false); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 776424cc99f..98674a38153 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -16,6 +16,7 @@ mod experimental_api; mod experimental_feature_list; mod external_agent_config; mod fs; +mod hooks_list; mod initialize; mod marketplace_add; mod marketplace_remove; diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 8a5c73d6b9b..b5107e47d63 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -26,6 +26,15 @@ pub struct HookEventsToml { pub user_prompt_submit: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HookConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + pub enabled: bool, } impl HookEventsToml { @@ -37,6 +46,7 @@ impl HookEventsToml { session_start, user_prompt_submit, stop, + config: _, } = self; pre_tool_use.is_empty() && permission_request.is_empty() @@ -54,6 +64,7 @@ impl HookEventsToml { session_start, user_prompt_submit, stop, + config: _, } = self; [ pre_tool_use, diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e3d95acb866..ba5b511ab70 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -68,6 +68,7 @@ pub use diagnostics::format_config_error; pub use diagnostics::format_config_error_with_source; pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; +pub use hook_config::HookConfig; pub use hook_config::HookEventsToml; pub use hook_config::HookHandlerConfig; pub use hook_config::HooksFile; diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 6668e25318b..0785b9e2ef1 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -642,6 +642,7 @@ pub struct Notice { pub external_config_migration_prompts: ExternalConfigMigrationPrompts, } +pub use crate::hook_config::HookConfig; pub use crate::skills_config::BundledSkillsConfig; pub use crate::skills_config::SkillConfig; pub use crate::skills_config::SkillsConfig; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index fc314b3cdbe..6a6364a3156 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -859,6 +859,20 @@ } ] }, + "HookConfig": { + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "HookEventsToml": { "properties": { "PermissionRequest": { @@ -902,6 +916,12 @@ "$ref": "#/definitions/MatcherGroup" }, "type": "array" + }, + "config": { + "items": { + "$ref": "#/definitions/HookConfig" + }, + "type": "array" } }, "type": "object" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index e49dc9dc08d..3821f049b10 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -61,6 +61,8 @@ pub enum ConfigEdit { SetSkillConfig { path: PathBuf, enabled: bool }, /// Set or clear a skill config entry under `[[skills.config]]` by name. SetSkillConfigByName { name: String, enabled: bool }, + /// Set or clear a hook config entry under `[[hooks.config]]` by key. + SetHookConfig { key: String, enabled: bool }, /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, @@ -519,6 +521,9 @@ impl ConfigDocument { ConfigEdit::SetSkillConfigByName { name, enabled } => { Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) } + ConfigEdit::SetHookConfig { key, enabled } => { + Ok(self.set_hook_config(key.clone(), *enabled)) + } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), ConfigEdit::SetProjectTrustLevel { path, level } => { @@ -719,6 +724,112 @@ impl ConfigDocument { mutated } + fn set_hook_config(&mut self, key: String, enabled: bool) -> bool { + let key = key.trim().to_string(); + if key.is_empty() { + return false; + } + let mut remove_hooks_table = false; + let mut mutated = false; + + { + let root = self.doc.as_table_mut(); + let hooks_item = match root.get_mut("hooks") { + Some(item) => item, + None => { + if enabled { + return false; + } + root.insert( + "hooks", + TomlItem::Table(document_helpers::new_implicit_table()), + ); + let Some(item) = root.get_mut("hooks") else { + return false; + }; + item + } + }; + + if document_helpers::ensure_table_for_write(hooks_item).is_none() { + if enabled { + return false; + } + *hooks_item = TomlItem::Table(document_helpers::new_implicit_table()); + } + let Some(hooks_table) = hooks_item.as_table_mut() else { + return false; + }; + + let config_item = match hooks_table.get_mut("config") { + Some(item) => item, + None => { + if enabled { + return false; + } + hooks_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new())); + let Some(item) = hooks_table.get_mut("config") else { + return false; + }; + item + } + }; + + if !matches!(config_item, TomlItem::ArrayOfTables(_)) { + if enabled { + return false; + } + *config_item = TomlItem::ArrayOfTables(ArrayOfTables::new()); + } + + let TomlItem::ArrayOfTables(overrides) = config_item else { + return false; + }; + + let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { + hook_config_key_from_table(table) + .filter(|value| value == &key) + .map(|_| idx) + }); + + if enabled { + if let Some(index) = existing_index { + overrides.remove(index); + mutated = true; + if overrides.is_empty() { + hooks_table.remove("config"); + if hooks_table.is_empty() { + remove_hooks_table = true; + } + } + } + } else if let Some(index) = existing_index { + for (idx, table) in overrides.iter_mut().enumerate() { + if idx == index { + table["key"] = value(key); + table["enabled"] = value(false); + mutated = true; + break; + } + } + } else { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + entry["key"] = value(key); + entry["enabled"] = value(false); + overrides.push(entry); + mutated = true; + } + } + + if remove_hooks_table { + let root = self.doc.as_table_mut(); + root.remove("hooks"); + } + + mutated + } + fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { let resolved: Vec = segments .iter() @@ -865,6 +976,15 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele } } +fn hook_config_key_from_table(table: &TomlTable) -> Option { + table + .get("key") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string) +} + /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index ec81c7c06dc..6461c8e8c06 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -133,6 +133,52 @@ enabled = false assert_eq!(contents, expected); } +#[test] +fn set_hook_config_writes_disabled_entry() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetHookConfig { + key: "path:/tmp/hooks.json:pre_tool_use:0:0".to_string(), + enabled: false, + }]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[[hooks.config]] +key = "path:/tmp/hooks.json:pre_tool_use:0:0" +enabled = false +"#; + assert_eq!(contents, expected); +} + +#[test] +fn set_hook_config_removes_entry_when_enabled() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[[hooks.config]] +key = "path:/tmp/hooks.json:pre_tool_use:0:0" +enabled = false +"#, + ) + .expect("seed config"); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetHookConfig { + key: "path:/tmp/hooks.json:pre_tool_use:0:0".to_string(), + enabled: true, + }]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, ""); +} + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/hooks.rs b/codex-rs/core/src/hooks.rs new file mode 100644 index 00000000000..25b5aec89f3 --- /dev/null +++ b/codex-rs/core/src/hooks.rs @@ -0,0 +1,3 @@ +pub use codex_hooks::HookListEntry; +pub use codex_hooks::Hooks; +pub use codex_hooks::HooksConfig; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 3e2d2ee5237..7be13a5ff43 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -40,6 +40,7 @@ mod git_info_tests; mod goals; mod guardian; mod hook_runtime; +pub mod hooks; mod installation_id; pub(crate) mod landlock; pub use landlock::spawn_command_under_linux_sandbox; diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 77265ece75a..baa51f8f4b2 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -447,6 +447,20 @@ impl PluginsManager { .effective_skill_roots() } + pub async fn effective_plugin_hook_sources_for_layer_stack( + &self, + config_layer_stack: &ConfigLayerStack, + plugins_feature_enabled: bool, + plugin_hooks_feature_enabled: bool, + ) -> Vec { + if !plugins_feature_enabled || !plugin_hooks_feature_enabled { + return Vec::new(); + } + load_plugins_from_layer_stack(config_layer_stack, &self.store, self.restriction_product) + .await + .effective_plugin_hook_sources() + } + fn cached_enabled_outcome(&self) -> Option { match self.cached_enabled_outcome.read() { Ok(cache) => cache.clone(), diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs new file mode 100644 index 00000000000..1dd7ac9757c --- /dev/null +++ b/codex-rs/hooks/src/config_rules.rs @@ -0,0 +1,71 @@ +use std::collections::HashSet; + +use codex_config::ConfigLayerSource; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::HookConfig; +use codex_config::HookEventsToml; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct HookConfigRules { + disabled_keys: HashSet, +} + +impl HookConfigRules { + pub(crate) fn is_enabled(&self, key: &str) -> bool { + !self.disabled_keys.contains(key) + } +} + +pub(crate) fn hook_config_rules_from_stack( + config_layer_stack: Option<&ConfigLayerStack>, +) -> HookConfigRules { + let Some(config_layer_stack) = config_layer_stack else { + return HookConfigRules::default(); + }; + + let mut disabled_keys = HashSet::new(); + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) { + if !matches!( + layer.name, + ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags + ) { + continue; + } + + let Some(hooks_value) = layer.config.get("hooks") else { + continue; + }; + let hooks: HookEventsToml = match hooks_value.clone().try_into() { + Ok(hooks) => hooks, + Err(_) => { + continue; + } + }; + + for entry in hooks.config { + let Some(key) = hook_config_key(&entry) else { + continue; + }; + if entry.enabled { + disabled_keys.remove(&key); + } else { + disabled_keys.insert(key); + } + } + } + + HookConfigRules { disabled_keys } +} + +fn hook_config_key(entry: &HookConfig) -> Option { + let key = entry.key.as_deref().map(str::trim).unwrap_or_default(); + if key.is_empty() { + None + } else { + Some(key.to_string()) + } +} diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index ce42908c097..cb7ea1caf87 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -18,6 +18,8 @@ use serde::Deserialize; use std::collections::HashMap; use super::ConfiguredHandler; +use crate::config_rules::HookConfigRules; +use crate::config_rules::hook_config_rules_from_stack; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; use codex_protocol::protocol::HookSource; @@ -30,9 +32,12 @@ pub(crate) struct DiscoveryResult { #[derive(Clone)] struct HookHandlerSource<'a> { path: &'a AbsolutePathBuf, + key_prefix: String, is_managed: bool, source: HookSource, env: HashMap, + plugin_id: Option, + source_relative_path: Option, } pub(crate) fn discover_handlers( @@ -43,11 +48,13 @@ pub(crate) fn discover_handlers( let mut handlers = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0_i64; + let hook_config_rules = HookConfigRules::default(); append_plugin_hook_sources( &mut handlers, &mut warnings, &mut display_order, plugin_hook_sources, + &hook_config_rules, ); return DiscoveryResult { handlers, warnings }; }; @@ -55,12 +62,14 @@ pub(crate) fn discover_handlers( let mut handlers = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0_i64; + let hook_config_rules = hook_config_rules_from_stack(Some(config_layer_stack)); append_managed_requirement_handlers( &mut handlers, &mut warnings, &mut display_order, config_layer_stack, + &hook_config_rules, ); for layer in config_layer_stack.get_layers( @@ -90,11 +99,15 @@ pub(crate) fn discover_handlers( &mut display_order, HookHandlerSource { path: &source_path, + key_prefix: format!("path:{}", source_path.display()), is_managed: false, source: hook_source, env: HashMap::new(), + plugin_id: None, + source_relative_path: None, }, hook_events, + &hook_config_rules, ); } @@ -105,11 +118,15 @@ pub(crate) fn discover_handlers( &mut display_order, HookHandlerSource { path: &source_path, + key_prefix: format!("path:{}", source_path.display()), is_managed: false, source: hook_source, env: HashMap::new(), + plugin_id: None, + source_relative_path: None, }, hook_events, + &hook_config_rules, ); } } @@ -119,6 +136,7 @@ pub(crate) fn discover_handlers( &mut warnings, &mut display_order, plugin_hook_sources, + &hook_config_rules, ); DiscoveryResult { handlers, warnings } @@ -129,6 +147,7 @@ fn append_managed_requirement_handlers( warnings: &mut Vec, display_order: &mut i64, config_layer_stack: &ConfigLayerStack, + hook_config_rules: &HookConfigRules, ) { let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else { return; @@ -144,11 +163,15 @@ fn append_managed_requirement_handlers( display_order, HookHandlerSource { path: &source_path, + key_prefix: format!("path:{}", source_path.display()), is_managed: true, source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), env: HashMap::new(), + plugin_id: None, + source_relative_path: None, }, managed_hooks.get().hooks.clone(), + hook_config_rules, ); } @@ -157,31 +180,38 @@ fn append_plugin_hook_sources( warnings: &mut Vec, display_order: &mut i64, plugin_hook_sources: Vec, + hook_config_rules: &HookConfigRules, ) { // TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable. for source in plugin_hook_sources { let PluginHookSource { plugin_root, + plugin_id, source_path, + source_relative_path, hooks, - .. } = source; let mut env = HashMap::new(); let plugin_root_value = plugin_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); + let plugin_id = plugin_id.as_key(); append_hook_events( handlers, warnings, display_order, HookHandlerSource { path: &source_path, + key_prefix: format!("plugin:{plugin_id}:{source_relative_path}"), is_managed: false, source: HookSource::Plugin, env, + plugin_id: Some(plugin_id), + source_relative_path: Some(source_relative_path), }, hooks, + hook_config_rules, ); } } @@ -328,6 +358,7 @@ fn append_hook_events( display_order: &mut i64, source: HookHandlerSource<'_>, hook_events: HookEventsToml, + hook_config_rules: &HookConfigRules, ) { for (event_name, groups) in hook_events.into_matcher_groups() { append_matcher_groups( @@ -337,6 +368,7 @@ fn append_hook_events( source.clone(), event_name, groups, + hook_config_rules, ); } } @@ -348,88 +380,93 @@ fn append_matcher_groups( source: HookHandlerSource<'_>, event_name: codex_protocol::protocol::HookEventName, groups: Vec, + hook_config_rules: &HookConfigRules, ) { - for group in groups { - append_group_handlers( - handlers, - warnings, - display_order, - source.clone(), - event_name, - matcher_pattern_for_event(event_name, group.matcher.as_deref()), - group.hooks, - ); - } -} - -fn append_group_handlers( - handlers: &mut Vec, - warnings: &mut Vec, - display_order: &mut i64, - source: HookHandlerSource<'_>, - event_name: codex_protocol::protocol::HookEventName, - matcher: Option<&str>, - group_handlers: Vec, -) { - if let Some(matcher) = matcher - && let Err(err) = validate_matcher_pattern(matcher) - { - warnings.push(format!( - "invalid matcher {matcher:?} in {}: {err}", - source.path.display() - )); - return; - } + for (group_index, group) in groups.into_iter().enumerate() { + let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref()); + if let Some(matcher) = matcher + && let Err(err) = validate_matcher_pattern(matcher) + { + warnings.push(format!( + "invalid matcher {matcher:?} in {}: {err}", + source.path.display() + )); + continue; + } - for handler in group_handlers { - match handler { - HookHandlerConfig::Command { - command, - timeout_sec, - r#async, - status_message, - } => { - if r#async { - warnings.push(format!( - "skipping async hook in {}: async hooks are not supported yet", - source.path.display() - )); - continue; - } - if command.trim().is_empty() { - warnings.push(format!( - "skipping empty hook command in {}", - source.path.display() - )); - continue; - } - let timeout_sec = timeout_sec.unwrap_or(600).max(1); - handlers.push(ConfiguredHandler { - event_name, - is_managed: source.is_managed, - matcher: matcher.map(ToOwned::to_owned), + for (handler_index, handler) in group.hooks.into_iter().enumerate() { + match handler { + HookHandlerConfig::Command { command, timeout_sec, + r#async, status_message, - source_path: source.path.clone(), - source: source.source, - display_order: *display_order, - env: source.env.clone(), - }); - *display_order += 1; + } => { + if r#async { + warnings.push(format!( + "skipping async hook in {}: async hooks are not supported yet", + source.path.display() + )); + continue; + } + if command.trim().is_empty() { + warnings.push(format!( + "skipping empty hook command in {}", + source.path.display() + )); + continue; + } + let timeout_sec = timeout_sec.unwrap_or(600).max(1); + let key = format!( + "{}:{}:{}:{}", + source.key_prefix, + hook_event_key_label(event_name), + group_index, + handler_index + ); + let enabled = hook_config_rules.is_enabled(&key); + handlers.push(ConfiguredHandler { + key, + event_name, + is_managed: source.is_managed, + matcher: matcher.map(ToOwned::to_owned), + command, + timeout_sec, + status_message, + source_path: source.path.clone(), + source: source.source, + display_order: *display_order, + env: source.env.clone(), + enabled, + plugin_id: source.plugin_id.clone(), + source_relative_path: source.source_relative_path.clone(), + }); + *display_order += 1; + } + HookHandlerConfig::Prompt {} => warnings.push(format!( + "skipping prompt hook in {}: prompt hooks are not supported yet", + source.path.display() + )), + HookHandlerConfig::Agent {} => warnings.push(format!( + "skipping agent hook in {}: agent hooks are not supported yet", + source.path.display() + )), } - HookHandlerConfig::Prompt {} => warnings.push(format!( - "skipping prompt hook in {}: prompt hooks are not supported yet", - source.path.display() - )), - HookHandlerConfig::Agent {} => warnings.push(format!( - "skipping agent hook in {}: agent hooks are not supported yet", - source.path.display() - )), } } } +fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { + match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "pre_tool_use", + codex_protocol::protocol::HookEventName::PermissionRequest => "permission_request", + codex_protocol::protocol::HookEventName::PostToolUse => "post_tool_use", + codex_protocol::protocol::HookEventName::SessionStart => "session_start", + codex_protocol::protocol::HookEventName::UserPromptSubmit => "user_prompt_submit", + codex_protocol::protocol::HookEventName::Stop => "stop", + } +} + fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource { match source { ConfigLayerSource::System { .. } => HookSource::System, @@ -472,6 +509,7 @@ mod tests { use super::ConfiguredHandler; use super::append_matcher_groups; + use crate::config_rules::HookConfigRules; use codex_config::HookHandlerConfig; use codex_config::MatcherGroup; @@ -486,9 +524,12 @@ mod tests { fn hook_handler_source(path: &AbsolutePathBuf) -> super::HookHandlerSource<'_> { super::HookHandlerSource { path, + key_prefix: format!("path:{}", path.display()), is_managed: false, source: hook_source(), env: std::collections::HashMap::new(), + plugin_id: None, + source_relative_path: None, } } @@ -518,12 +559,14 @@ mod tests { hook_handler_source(&source_path), HookEventName::UserPromptSubmit, vec![command_group(Some("["))], + &HookConfigRules::default(), ); assert_eq!(warnings, Vec::::new()); assert_eq!( handlers, vec![ConfiguredHandler { + key: format!("path:{}:user_prompt_submit:0:0", source_path.display()), event_name: HookEventName::UserPromptSubmit, is_managed: false, matcher: None, @@ -534,6 +577,9 @@ mod tests { source: hook_source(), display_order: 0, env: std::collections::HashMap::new(), + enabled: true, + plugin_id: None, + source_relative_path: None, }] ); } @@ -552,12 +598,14 @@ mod tests { hook_handler_source(&source_path), HookEventName::PreToolUse, vec![command_group(Some("^Bash$"))], + &HookConfigRules::default(), ); assert_eq!(warnings, Vec::::new()); assert_eq!( handlers, vec![ConfiguredHandler { + key: format!("path:{}:pre_tool_use:0:0", source_path.display()), event_name: HookEventName::PreToolUse, is_managed: false, matcher: Some("^Bash$".to_string()), @@ -568,6 +616,9 @@ mod tests { source: hook_source(), display_order: 0, env: std::collections::HashMap::new(), + enabled: true, + plugin_id: None, + source_relative_path: None, }] ); } @@ -586,6 +637,7 @@ mod tests { hook_handler_source(&source_path), HookEventName::PreToolUse, vec![command_group(Some("*"))], + &HookConfigRules::default(), ); assert_eq!(warnings, Vec::::new()); @@ -607,6 +659,7 @@ mod tests { hook_handler_source(&source_path), HookEventName::PostToolUse, vec![command_group(Some("Edit|Write"))], + &HookConfigRules::default(), ); assert_eq!(warnings, Vec::::new()); diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index c19b311843b..1100dcb79bf 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -155,6 +155,7 @@ mod tests { display_order: i64, ) -> ConfiguredHandler { ConfiguredHandler { + key: format!("test:{display_order}"), event_name, is_managed: false, matcher: matcher.map(str::to_owned), @@ -165,6 +166,9 @@ mod tests { source: HookSource::User, display_order, env: std::collections::HashMap::new(), + enabled: true, + plugin_id: None, + source_relative_path: None, } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 5c121136f7a..cc0c3974753 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -8,6 +8,8 @@ use std::collections::HashMap; use codex_config::ConfigLayerStack; use codex_plugin::PluginHookSource; +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookRunSummary; use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::AbsolutePathBuf; @@ -33,6 +35,7 @@ pub(crate) struct CommandShell { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ConfiguredHandler { + pub key: String, pub event_name: codex_protocol::protocol::HookEventName, pub is_managed: bool, pub matcher: Option, @@ -43,6 +46,9 @@ pub(crate) struct ConfiguredHandler { pub source: HookSource, pub display_order: i64, pub env: HashMap, + pub enabled: bool, + pub plugin_id: Option, + pub source_relative_path: Option, } impl ConfiguredHandler { @@ -67,9 +73,47 @@ impl ConfiguredHandler { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HookListEntry { + pub key: String, + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub matcher: Option, + pub command: Option, + pub timeout_sec: u64, + pub status_message: Option, + pub source_path: AbsolutePathBuf, + pub source: HookSource, + pub plugin_id: Option, + pub source_relative_path: Option, + pub display_order: i64, + pub enabled: bool, +} + +impl From<&ConfiguredHandler> for HookListEntry { + fn from(handler: &ConfiguredHandler) -> Self { + Self { + key: handler.key.clone(), + event_name: handler.event_name, + handler_type: HookHandlerType::Command, + matcher: handler.matcher.clone(), + command: Some(handler.command.clone()), + timeout_sec: handler.timeout_sec, + status_message: handler.status_message.clone(), + source_path: handler.source_path.clone(), + source: handler.source, + plugin_id: handler.plugin_id.clone(), + source_relative_path: handler.source_relative_path.clone(), + display_order: handler.display_order, + enabled: handler.enabled, + } + } +} + #[derive(Clone)] pub(crate) struct ClaudeHooksEngine { handlers: Vec, + configured_hooks: Vec, warnings: Vec, shell: CommandShell, } @@ -84,6 +128,7 @@ impl ClaudeHooksEngine { if !enabled { return Self { handlers: Vec::new(), + configured_hooks: Vec::new(), warnings: Vec::new(), shell, }; @@ -91,8 +136,19 @@ impl ClaudeHooksEngine { let _ = schema_loader::generated_hook_schemas(); let discovered = discovery::discover_handlers(config_layer_stack, plugin_hook_sources); + let configured_hooks = discovered + .handlers + .iter() + .map(HookListEntry::from) + .collect::>(); + let handlers = discovered + .handlers + .into_iter() + .filter(|handler| handler.enabled) + .collect(); Self { - handlers: discovered.handlers, + handlers, + configured_hooks, warnings: discovered.warnings, shell, } @@ -102,6 +158,10 @@ impl ClaudeHooksEngine { &self.warnings } + pub(crate) fn configured_hooks(&self) -> &[HookListEntry] { + &self.configured_hooks + } + pub(crate) fn preview_session_start( &self, request: &SessionStartRequest, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 245c31ba5fd..19afd1fdb36 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -332,6 +332,92 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { assert_eq!(preview[1].source_path, config_path); } +#[test] +fn hooks_config_disables_matching_discovered_hook() { + let temp = tempdir().expect("create temp dir"); + let config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let hook_key = format!("path:{}:pre_tool_use:0:0", config_path.display()); + let mut config_toml = TomlValue::Table(Default::default()); + let TomlValue::Table(config_table) = &mut config_toml else { + unreachable!("config TOML root should be a table"); + }; + let mut hooks_table = TomlValue::Table(Default::default()); + let TomlValue::Table(hooks_entries) = &mut hooks_table else { + unreachable!("hooks entry should be a table"); + }; + let mut pre_tool_use_group = TomlValue::Table(Default::default()); + let TomlValue::Table(pre_tool_use_group_entries) = &mut pre_tool_use_group else { + unreachable!("PreToolUse group should be a table"); + }; + pre_tool_use_group_entries.insert("matcher".to_string(), TomlValue::String("Bash".to_string())); + pre_tool_use_group_entries.insert( + "hooks".to_string(), + TomlValue::Array(vec![TomlValue::Table(Default::default())]), + ); + let Some(TomlValue::Array(hooks_array)) = pre_tool_use_group_entries.get_mut("hooks") else { + unreachable!("PreToolUse hooks should be an array"); + }; + let Some(TomlValue::Table(handler_entries)) = hooks_array.first_mut() else { + unreachable!("PreToolUse handler should be a table"); + }; + handler_entries.insert("type".to_string(), TomlValue::String("command".to_string())); + handler_entries.insert( + "command".to_string(), + TomlValue::String("python3 /tmp/disabled-hook.py".to_string()), + ); + hooks_entries.insert( + "PreToolUse".to_string(), + TomlValue::Array(vec![pre_tool_use_group]), + ); + let mut config_entry = TomlValue::Table(Default::default()); + let TomlValue::Table(config_entry_table) = &mut config_entry else { + unreachable!("hooks.config entry should be a table"); + }; + config_entry_table.insert("key".to_string(), TomlValue::String(hook_key.clone())); + config_entry_table.insert("enabled".to_string(), TomlValue::Boolean(false)); + hooks_entries.insert("config".to_string(), TomlValue::Array(vec![config_entry])); + config_table.insert("hooks".to_string(), hooks_table); + let config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { file: config_path }, + config_toml, + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + Some(&config_layer_stack), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + let configured_hooks = engine.configured_hooks(); + assert_eq!(configured_hooks.len(), 1); + assert_eq!(configured_hooks[0].key, hook_key); + assert_eq!(configured_hooks[0].enabled, false); + + 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, Vec::new()); +} + #[tokio::test] async fn plugin_hook_sources_run_with_plugin_env_and_plugin_source() { let temp = tempdir().expect("create temp dir"); diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index c01cebf78a2..4c87ead35b6 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -542,6 +542,7 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { + key: "test:post_tool_use".to_string(), event_name: HookEventName::PostToolUse, is_managed: false, matcher: Some("^Bash$".to_string()), @@ -552,6 +553,9 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + enabled: true, + plugin_id: None, + source_relative_path: None, } } diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 3b20c2c2c02..e4eea6e517b 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -533,6 +533,7 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { + key: "test:pre_tool_use".to_string(), event_name: HookEventName::PreToolUse, is_managed: false, matcher: Some("^Bash$".to_string()), @@ -543,6 +544,9 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + enabled: true, + plugin_id: None, + source_relative_path: None, } } diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 54c7f51732b..d0fae2b6a30 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -355,6 +355,7 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { + key: "test:session_start".to_string(), event_name: HookEventName::SessionStart, is_managed: false, matcher: None, @@ -365,6 +366,9 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + enabled: true, + plugin_id: None, + source_relative_path: None, } } diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 392f15eee24..219d8c87485 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -522,6 +522,7 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { + key: "test:stop".to_string(), event_name: HookEventName::Stop, is_managed: false, matcher: None, @@ -532,6 +533,9 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + enabled: true, + plugin_id: None, + source_relative_path: None, } } diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 8aaf3ad608e..759121220d2 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -413,6 +413,7 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { + key: "test:user_prompt_submit".to_string(), event_name: HookEventName::UserPromptSubmit, is_managed: false, matcher: None, @@ -423,6 +424,9 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), + enabled: true, + plugin_id: None, + source_relative_path: None, } } diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index c8358c678c7..f8d6d0794dd 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -1,3 +1,4 @@ +mod config_rules; mod engine; pub(crate) mod events; mod legacy_notify; @@ -5,6 +6,7 @@ mod registry; mod schema; mod types; +pub use engine::HookListEntry; pub use events::permission_request::PermissionRequestDecision; pub use events::permission_request::PermissionRequestOutcome; pub use events::permission_request::PermissionRequestRequest; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 4509a8a6318..a91ac966009 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -4,6 +4,7 @@ use tokio::process::Command; use crate::engine::ClaudeHooksEngine; use crate::engine::CommandShell; +use crate::engine::HookListEntry; use crate::events::permission_request::PermissionRequestOutcome; use crate::events::permission_request::PermissionRequestRequest; use crate::events::post_tool_use::PostToolUseOutcome; @@ -72,6 +73,10 @@ impl Hooks { self.engine.warnings() } + pub fn configured_hooks(&self) -> &[HookListEntry] { + self.engine.configured_hooks() + } + fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] { match hook_event { HookEvent::AfterAgent { .. } => &self.after_agent, From 38c5e3a106534ad24f638735263571244be79c9d Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 09:38:25 -0700 Subject: [PATCH 08/64] Document hook config APIs --- codex-rs/app-server/src/codex_message_processor.rs | 13 +++++++++++++ codex-rs/core/src/config/edit.rs | 9 +++++++++ codex-rs/hooks/src/config_rules.rs | 9 +++++++++ 3 files changed, 31 insertions(+) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index ce83a22bb26..c86e05698f5 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6910,6 +6910,11 @@ impl CodexMessageProcessor { .await; } + /// Handle `hooks/list` by resolving hooks for each requested cwd. + /// + /// The response includes every discovered hook, including hooks disabled by + /// user-level hook config, so clients can render disabled entries and allow + /// users to re-enable them later. async fn hooks_list(&self, request_id: ConnectionRequestId, params: HooksListParams) { let HooksListParams { cwds } = params; let cwds = if cwds.is_empty() { @@ -6968,6 +6973,9 @@ impl CodexMessageProcessor { continue; } }; + // Plugin hook sources are discovered from the same effective plugin + // view used by runtime loading, but only when both plugin feature + // gates are enabled for this workspace. let plugin_hook_sources = plugins_manager .effective_plugin_hook_sources_for_layer_stack( &config_layer_stack, @@ -7174,6 +7182,11 @@ impl CodexMessageProcessor { } } + /// Handle `hooks/config/write` by updating user-level hook enablement. + /// + /// Hook config is keyed by the stable key returned from `hooks/list`. A + /// disabled hook is persisted in `config.toml`; enabling the hook removes + /// the user override so future discovery falls back to the default. async fn hooks_config_write( &self, request_id: ConnectionRequestId, diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 3821f049b10..7cfefaae3bb 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -724,6 +724,11 @@ impl ConfigDocument { mutated } + /// Set or clear a `[[hooks.config]]` entry by hook key. + /// + /// Disabled state is represented explicitly as `enabled = false`. Enabled + /// state is represented by removing the matching override, matching the + /// skills config behavior. fn set_hook_config(&mut self, key: String, enabled: bool) -> bool { let key = key.trim().to_string(); if key.is_empty() { @@ -786,6 +791,8 @@ impl ConfigDocument { return false; }; + // Only persist negative overrides. Re-enabling removes the entry so + // the hook's default discovered state applies again. let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { hook_config_key_from_table(table) .filter(|value| value == &key) @@ -822,6 +829,8 @@ impl ConfigDocument { } } + // Defer removing the parent table until the nested borrows above are + // dropped. if remove_hooks_table { let root = self.doc.as_table_mut(); root.remove("hooks"); diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index 1dd7ac9757c..0dc10cf9340 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -17,6 +17,13 @@ impl HookConfigRules { } } +/// Build hook enablement rules from config layers that are allowed to override +/// user preferences. +/// +/// This intentionally reads only user and session flag layers, including +/// disabled layers, to match the skills config behavior. Project, managed, and +/// plugin layers can discover hooks, but they do not get to write user +/// enablement state. pub(crate) fn hook_config_rules_from_stack( config_layer_stack: Option<&ConfigLayerStack>, ) -> HookConfigRules { @@ -50,6 +57,8 @@ pub(crate) fn hook_config_rules_from_stack( let Some(key) = hook_config_key(&entry) else { continue; }; + // Later layers win: an enabled entry removes a disabled override + // for the same key, while a disabled entry inserts it. if entry.enabled { disabled_keys.remove(&key); } else { From 6d03af75a701b0af24e68a2088555526099fda59 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 09:57:54 -0700 Subject: [PATCH 09/64] Refactor plugin hook loader test assertions Co-authored-by: Codex --- codex-rs/core-plugins/src/loader_tests.rs | 72 +++++++++-------------- 1 file changed, 29 insertions(+), 43 deletions(-) diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index 92ac4160693..bbc0642cb59 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -136,6 +136,30 @@ fn load_sources(plugin_root: &AbsolutePathBuf) -> Vec { load_plugin_hooks(plugin_root, &plugin_id(), &manifest.paths) } +fn assert_sources(sources: &[PluginHookSource], expected_relative_paths: &[&str]) { + assert_eq!( + sources + .iter() + .map(|source| source.plugin_id.clone()) + .collect::>(), + vec![plugin_id(); expected_relative_paths.len()] + ); + assert_eq!( + sources + .iter() + .map(|source| source.source_relative_path.as_str()) + .collect::>(), + expected_relative_paths + ); + assert_eq!( + sources + .iter() + .map(|source| source.hooks.handler_count()) + .collect::>(), + vec![1; expected_relative_paths.len()] + ); +} + #[test] fn load_plugin_hooks_discovers_default_hooks_file() { let (_tmp, plugin_root) = plugin_root(); @@ -157,10 +181,7 @@ fn load_plugin_hooks_discovers_default_hooks_file() { let sources = load_sources(&plugin_root); - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].plugin_id, plugin_id()); - assert_eq!(sources[0].source_relative_path, "hooks/hooks.json"); - assert_eq!(sources[0].hooks.handler_count(), 1); + assert_sources(&sources, &["hooks/hooks.json"]); } #[test] @@ -177,14 +198,7 @@ fn load_plugin_hooks_supports_manifest_hook_path() { let sources = load_sources(&plugin_root); - assert_eq!( - sources - .iter() - .map(|source| source.source_relative_path.as_str()) - .collect::>(), - vec!["hooks/one.json"] - ); - assert_eq!(sources[0].hooks.handler_count(), 1); + assert_sources(&sources, &["hooks/one.json"]); } #[test] @@ -208,20 +222,7 @@ fn load_plugin_hooks_manifest_paths_replace_default_hooks_file() { let sources = load_sources(&plugin_root); - assert_eq!( - sources - .iter() - .map(|source| source.source_relative_path.as_str()) - .collect::>(), - vec!["hooks/one.json", "hooks/two.json"] - ); - assert_eq!( - sources - .iter() - .map(|source| source.hooks.handler_count()) - .collect::>(), - vec![1, 1] - ); + assert_sources(&sources, &["hooks/one.json", "hooks/two.json"]); } #[test] @@ -246,9 +247,7 @@ fn load_plugin_hooks_supports_inline_manifest_hooks() { let sources = load_sources(&plugin_root); - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].source_relative_path, "plugin.json#hooks[0]"); - assert_eq!(sources[0].hooks.handler_count(), 1); + assert_sources(&sources, &["plugin.json#hooks[0]"]); } #[test] @@ -283,20 +282,7 @@ fn load_plugin_hooks_supports_inline_manifest_hook_list() { let sources = load_sources(&plugin_root); - assert_eq!( - sources - .iter() - .map(|source| source.source_relative_path.as_str()) - .collect::>(), - vec!["plugin.json#hooks[0]", "plugin.json#hooks[1]"] - ); - assert_eq!( - sources - .iter() - .map(|source| source.hooks.handler_count()) - .collect::>(), - vec![1, 1] - ); + assert_sources(&sources, &["plugin.json#hooks[0]", "plugin.json#hooks[1]"]); } #[test] From 06d95b2a548d28f91baba371eb309652487a788d Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 10:35:01 -0700 Subject: [PATCH 10/64] Reduce hooks list PR to inventory only --- .../schema/json/ClientRequest.json | 39 ------ .../codex_app_server_protocol.schemas.json | 62 --------- .../codex_app_server_protocol.v2.schemas.json | 62 --------- .../json/v2/HooksConfigWriteParams.json | 17 --- .../json/v2/HooksConfigWriteResponse.json | 13 -- .../schema/json/v2/HooksListResponse.json | 8 -- .../schema/typescript/ClientRequest.ts | 3 +- .../schema/typescript/v2/HookMetadata.ts | 2 +- .../typescript/v2/HooksConfigWriteParams.ts | 5 - .../typescript/v2/HooksConfigWriteResponse.ts | 5 - .../schema/typescript/v2/index.ts | 2 - .../src/protocol/common.rs | 4 - .../app-server-protocol/src/protocol/v2.rs | 17 --- codex-rs/app-server/README.md | 22 +-- .../app-server/src/codex_message_processor.rs | 67 +-------- codex-rs/app-server/src/config_api.rs | 1 - .../app-server/tests/common/mcp_process.rs | 10 -- .../app-server/tests/suite/v2/hooks_list.rs | 34 +---- codex-rs/config/src/hook_config.rs | 11 -- codex-rs/config/src/lib.rs | 1 - codex-rs/config/src/types.rs | 1 - codex-rs/core/config.schema.json | 20 --- codex-rs/core/src/config/edit.rs | 129 ------------------ codex-rs/core/src/config/edit_tests.rs | 46 ------- codex-rs/core/src/hooks.rs | 2 + codex-rs/hooks/src/config_rules.rs | 80 ----------- codex-rs/hooks/src/engine/discovery.rs | 108 +++++++-------- codex-rs/hooks/src/engine/dispatcher.rs | 4 - codex-rs/hooks/src/engine/mod.rs | 45 +----- codex-rs/hooks/src/engine/mod_tests.rs | 86 ------------ codex-rs/hooks/src/events/post_tool_use.rs | 4 - codex-rs/hooks/src/events/pre_tool_use.rs | 4 - codex-rs/hooks/src/events/session_start.rs | 4 - codex-rs/hooks/src/events/stop.rs | 4 - .../hooks/src/events/user_prompt_submit.rs | 4 - codex-rs/hooks/src/lib.rs | 3 +- codex-rs/hooks/src/registry.rs | 25 +++- 37 files changed, 81 insertions(+), 873 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts delete mode 100644 codex-rs/hooks/src/config_rules.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 8dc1e5e5d87..11be794498d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1447,21 +1447,6 @@ ], "type": "object" }, - "HooksConfigWriteParams": { - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - }, - "required": [ - "enabled", - "key" - ], - "type": "object" - }, "HooksListParams": { "properties": { "cwds": { @@ -5387,30 +5372,6 @@ "title": "Skills/config/writeRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "hooks/config/write" - ], - "title": "Hooks/config/writeRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/HooksConfigWriteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Hooks/config/writeRequest", - "type": "object" - }, { "properties": { "id": { 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 3f780790d69..c4db23df2d8 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 @@ -1122,30 +1122,6 @@ "title": "Skills/config/writeRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/v2/RequestId" - }, - "method": { - "enum": [ - "hooks/config/write" - ], - "title": "Hooks/config/writeRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/HooksConfigWriteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Hooks/config/writeRequest", - "type": "object" - }, { "properties": { "id": { @@ -9668,18 +9644,12 @@ "format": "int64", "type": "integer" }, - "enabled": { - "type": "boolean" - }, "eventName": { "$ref": "#/definitions/v2/HookEventName" }, "handlerType": { "$ref": "#/definitions/v2/HookHandlerType" }, - "key": { - "type": "string" - }, "matcher": { "type": [ "string", @@ -9718,10 +9688,8 @@ }, "required": [ "displayOrder", - "enabled", "eventName", "handlerType", - "key", "source", "sourcePath", "timeoutSec" @@ -9902,36 +9870,6 @@ "title": "HookStartedNotification", "type": "object" }, - "HooksConfigWriteParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - }, - "required": [ - "enabled", - "key" - ], - "title": "HooksConfigWriteParams", - "type": "object" - }, - "HooksConfigWriteResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "effectiveEnabled": { - "type": "boolean" - } - }, - "required": [ - "effectiveEnabled" - ], - "title": "HooksConfigWriteResponse", - "type": "object" - }, "HooksListEntry": { "properties": { "cwd": { 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 70c8f84bf7d..fc2e4e9ca23 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 @@ -1828,30 +1828,6 @@ "title": "Skills/config/writeRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "hooks/config/write" - ], - "title": "Hooks/config/writeRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/HooksConfigWriteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Hooks/config/writeRequest", - "type": "object" - }, { "properties": { "id": { @@ -6298,18 +6274,12 @@ "format": "int64", "type": "integer" }, - "enabled": { - "type": "boolean" - }, "eventName": { "$ref": "#/definitions/HookEventName" }, "handlerType": { "$ref": "#/definitions/HookHandlerType" }, - "key": { - "type": "string" - }, "matcher": { "type": [ "string", @@ -6348,10 +6318,8 @@ }, "required": [ "displayOrder", - "enabled", "eventName", "handlerType", - "key", "source", "sourcePath", "timeoutSec" @@ -6532,36 +6500,6 @@ "title": "HookStartedNotification", "type": "object" }, - "HooksConfigWriteParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - }, - "required": [ - "enabled", - "key" - ], - "title": "HooksConfigWriteParams", - "type": "object" - }, - "HooksConfigWriteResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "effectiveEnabled": { - "type": "boolean" - } - }, - "required": [ - "effectiveEnabled" - ], - "title": "HooksConfigWriteResponse", - "type": "object" - }, "HooksListEntry": { "properties": { "cwd": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json deleted file mode 100644 index da575768df9..00000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - }, - "required": [ - "enabled", - "key" - ], - "title": "HooksConfigWriteParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json deleted file mode 100644 index 6016edad4f9..00000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "effectiveEnabled": { - "type": "boolean" - } - }, - "required": [ - "effectiveEnabled" - ], - "title": "HooksConfigWriteResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index c758455bc52..889a99bcd71 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -51,18 +51,12 @@ "format": "int64", "type": "integer" }, - "enabled": { - "type": "boolean" - }, "eventName": { "$ref": "#/definitions/HookEventName" }, "handlerType": { "$ref": "#/definitions/HookHandlerType" }, - "key": { - "type": "string" - }, "matcher": { "type": [ "string", @@ -101,10 +95,8 @@ }, "required": [ "displayOrder", - "enabled", "eventName", "handlerType", - "key", "source", "sourcePath", "timeoutSec" diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 2a28d77b8ce..82e313e4236 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -34,7 +34,6 @@ import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; -import type { HooksConfigWriteParams } from "./v2/HooksConfigWriteParams"; import type { HooksListParams } from "./v2/HooksListParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; @@ -78,4 +77,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "hooks/config/write", id: RequestId, params: HooksConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index c56eb004ada..eb12a2a6962 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -6,4 +6,4 @@ import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; -export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, sourceRelativePath: string | null, displayOrder: bigint, enabled: boolean, }; +export type HookMetadata = { eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, sourceRelativePath: string | null, displayOrder: bigint, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts deleted file mode 100644 index d7f7394a339..00000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type HooksConfigWriteParams = { key: string, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts deleted file mode 100644 index 10b3b73da45..00000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type HooksConfigWriteResponse = { effectiveEnabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index fcc9881dfb3..eb2a43e9481 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -164,8 +164,6 @@ export type { HookRunSummary } from "./HookRunSummary"; export type { HookScope } from "./HookScope"; export type { HookSource } from "./HookSource"; export type { HookStartedNotification } from "./HookStartedNotification"; -export type { HooksConfigWriteParams } from "./HooksConfigWriteParams"; -export type { HooksConfigWriteResponse } from "./HooksConfigWriteResponse"; export type { HooksListEntry } from "./HooksListEntry"; export type { HooksListParams } from "./HooksListParams"; export type { HooksListResponse } from "./HooksListResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 219e806bdb5..5699dd46812 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -444,10 +444,6 @@ client_request_definitions! { params: v2::SkillsConfigWriteParams, response: v2::SkillsConfigWriteResponse, }, - HooksConfigWrite => "hooks/config/write" { - params: v2::HooksConfigWriteParams, - response: v2::HooksConfigWriteResponse, - }, PluginInstall => "plugin/install" { params: v2::PluginInstallParams, response: v2::PluginInstallResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 93d6a2d529d..a89b8fa7f94 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4487,7 +4487,6 @@ pub struct HooksListEntry { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct HookMetadata { - pub key: String, pub event_name: HookEventName, pub handler_type: HookHandlerType, pub matcher: Option, @@ -4499,7 +4498,6 @@ pub struct HookMetadata { pub plugin_id: Option, pub source_relative_path: Option, pub display_order: i64, - pub enabled: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -4665,21 +4663,6 @@ pub struct SkillsConfigWriteResponse { pub effective_enabled: bool, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksConfigWriteParams { - pub key: String, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksConfigWriteResponse { - pub effective_enabled: bool, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 3a2dbdf3e02..682d739aa0f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -195,7 +195,7 @@ Example with notification opt-out: - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). -- `hooks/list` — list discovered hooks for one or more `cwd` values, including hooks disabled by user config. +- `hooks/list` — list discovered hooks for one or more `cwd` values. - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. @@ -207,7 +207,6 @@ Example with notification opt-out: - `device/key/public` — return a device key's SPKI DER public key as base64 plus its `algorithm` and `protectionClass`. - `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API. - `skills/config/write` — write user-level skill config by name or absolute path. -- `hooks/config/write` — write user-level hook config by stable hook key. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. @@ -1452,7 +1451,7 @@ To enable or disable a skill by name: } ``` -Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. +Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. ```json { @@ -1471,7 +1470,6 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "data": [{ "cwd": "/Users/me/project", "hooks": [{ - "key": "path:/Users/me/.codex/config.toml:pre_tool_use:0:0", "eventName": "pre_tool_use", "handlerType": "command", "matcher": "Bash", @@ -1482,8 +1480,7 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "source": "user", "pluginId": null, "sourceRelativePath": null, - "displayOrder": 0, - "enabled": true + "displayOrder": 0 }], "warnings": [], "errors": [] @@ -1492,19 +1489,6 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled } ``` -To enable or disable a hook, write the hook key returned by `hooks/list`: - -```json -{ - "method": "hooks/config/write", - "id": 29, - "params": { - "key": "path:/Users/me/.codex/config.toml:pre_tool_use:0:0", - "enabled": false - } -} -``` - ## Apps Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c86e05698f5..3bee2b14f38 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -76,8 +76,6 @@ use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::HookMetadata; -use codex_app_server_protocol::HooksConfigWriteParams; -use codex_app_server_protocol::HooksConfigWriteResponse; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCErrorError; @@ -1079,10 +1077,6 @@ impl CodexMessageProcessor { self.skills_config_write(to_connection_request_id(request_id), params) .await; } - ClientRequest::HooksConfigWrite { request_id, params } => { - self.hooks_config_write(to_connection_request_id(request_id), params) - .await; - } ClientRequest::PluginInstall { request_id, params } => { self.plugin_install(to_connection_request_id(request_id), params) .await; @@ -6911,10 +6905,6 @@ impl CodexMessageProcessor { } /// Handle `hooks/list` by resolving hooks for each requested cwd. - /// - /// The response includes every discovered hook, including hooks disabled by - /// user-level hook config, so clients can render disabled entries and allow - /// users to re-enable them later. async fn hooks_list(&self, request_id: ConnectionRequestId, params: HooksListParams) { let HooksListParams { cwds } = params; let cwds = if cwds.is_empty() { @@ -6983,7 +6973,7 @@ impl CodexMessageProcessor { config.features.enabled(Feature::PluginHooks), ) .await; - let hooks = codex_core::hooks::Hooks::new(codex_core::hooks::HooksConfig { + let hooks = codex_core::hooks::list_hooks(codex_core::hooks::HooksConfig { legacy_notify_argv: None, feature_enabled: config.features.enabled(Feature::CodexHooks), config_layer_stack: Some(config_layer_stack), @@ -6993,8 +6983,8 @@ impl CodexMessageProcessor { }); data.push(codex_app_server_protocol::HooksListEntry { cwd, - hooks: hooks_to_info(hooks.configured_hooks()), - warnings: hooks.startup_warnings().to_vec(), + hooks: hooks_to_info(&hooks.hooks), + warnings: hooks.warnings, errors: Vec::new(), }); } @@ -7182,55 +7172,6 @@ impl CodexMessageProcessor { } } - /// Handle `hooks/config/write` by updating user-level hook enablement. - /// - /// Hook config is keyed by the stable key returned from `hooks/list`. A - /// disabled hook is persisted in `config.toml`; enabling the hook removes - /// the user override so future discovery falls back to the default. - async fn hooks_config_write( - &self, - request_id: ConnectionRequestId, - params: HooksConfigWriteParams, - ) { - let HooksConfigWriteParams { key, enabled } = params; - if key.trim().is_empty() { - let error = JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: "hooks/config/write requires a non-empty key".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - let result = ConfigEditsBuilder::new(&self.config.codex_home) - .with_edits(vec![ConfigEdit::SetHookConfig { key, enabled }]) - .apply() - .await; - - match result { - Ok(()) => { - self.clear_plugin_related_caches(); - self.outgoing - .send_response( - request_id, - HooksConfigWriteResponse { - effective_enabled: enabled, - }, - ) - .await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to update hook settings: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - async fn turn_start( &self, request_id: ConnectionRequestId, @@ -9521,7 +9462,6 @@ fn hooks_to_info(hooks: &[codex_core::hooks::HookListEntry]) -> Vec Vec Managed session_start, user_prompt_submit, stop, - config: _, } = hooks; ManagedHooksRequirements { diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 82b7bbe9703..20ed8cea42e 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -37,7 +37,6 @@ use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetConversationSummaryParams; -use codex_app_server_protocol::HooksConfigWriteParams; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; @@ -559,15 +558,6 @@ impl McpProcess { self.send_request("hooks/list", params).await } - /// Send a `hooks/config/write` JSON-RPC request. - pub async fn send_hooks_config_write_request( - &mut self, - params: HooksConfigWriteParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("hooks/config/write", params).await - } - /// Send a `marketplace/add` JSON-RPC request. pub async fn send_marketplace_add_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 2522aef33d9..be378fd0090 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -5,8 +5,6 @@ use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookSource; -use codex_app_server_protocol::HooksConfigWriteParams; -use codex_app_server_protocol::HooksConfigWriteResponse; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCResponse; @@ -36,7 +34,7 @@ statusMessage = "running listed hook" } #[tokio::test] -async fn hooks_list_shows_discovered_hook_and_config_write_disables_it() -> Result<()> { +async fn hooks_list_shows_discovered_hook() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; write_user_hook_config(codex_home.path())?; @@ -65,35 +63,5 @@ async fn hooks_list_shows_discovered_hook_and_config_write_disables_it() -> Resu assert_eq!(hook.timeout_sec, 5); assert_eq!(hook.status_message.as_deref(), Some("running listed hook")); assert_eq!(hook.source, HookSource::User); - assert_eq!(hook.enabled, true); - - let write_id = mcp - .send_hooks_config_write_request(HooksConfigWriteParams { - key: hook.key.clone(), - enabled: false, - }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(write_id)), - ) - .await??; - let HooksConfigWriteResponse { effective_enabled } = to_response(response)?; - assert_eq!(effective_enabled, false); - - let request_id = mcp - .send_hooks_list_request(HooksListParams { - cwds: vec![cwd.path().to_path_buf()], - }) - .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let HooksListResponse { data } = to_response(response)?; - assert_eq!(data[0].hooks.len(), 1); - assert_eq!(data[0].hooks[0].key, hook.key); - assert_eq!(data[0].hooks[0].enabled, false); Ok(()) } diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index b5107e47d63..8a5c73d6b9b 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -26,15 +26,6 @@ pub struct HookEventsToml { pub user_prompt_submit: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub config: Vec, -} - -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct HookConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub key: Option, - pub enabled: bool, } impl HookEventsToml { @@ -46,7 +37,6 @@ impl HookEventsToml { session_start, user_prompt_submit, stop, - config: _, } = self; pre_tool_use.is_empty() && permission_request.is_empty() @@ -64,7 +54,6 @@ impl HookEventsToml { session_start, user_prompt_submit, stop, - config: _, } = self; [ pre_tool_use, diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index ba5b511ab70..e3d95acb866 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -68,7 +68,6 @@ pub use diagnostics::format_config_error; pub use diagnostics::format_config_error_with_source; pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; -pub use hook_config::HookConfig; pub use hook_config::HookEventsToml; pub use hook_config::HookHandlerConfig; pub use hook_config::HooksFile; diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 0785b9e2ef1..6668e25318b 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -642,7 +642,6 @@ pub struct Notice { pub external_config_migration_prompts: ExternalConfigMigrationPrompts, } -pub use crate::hook_config::HookConfig; pub use crate::skills_config::BundledSkillsConfig; pub use crate::skills_config::SkillConfig; pub use crate::skills_config::SkillsConfig; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6a6364a3156..fc314b3cdbe 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -859,20 +859,6 @@ } ] }, - "HookConfig": { - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "HookEventsToml": { "properties": { "PermissionRequest": { @@ -916,12 +902,6 @@ "$ref": "#/definitions/MatcherGroup" }, "type": "array" - }, - "config": { - "items": { - "$ref": "#/definitions/HookConfig" - }, - "type": "array" } }, "type": "object" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 7cfefaae3bb..e49dc9dc08d 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -61,8 +61,6 @@ pub enum ConfigEdit { SetSkillConfig { path: PathBuf, enabled: bool }, /// Set or clear a skill config entry under `[[skills.config]]` by name. SetSkillConfigByName { name: String, enabled: bool }, - /// Set or clear a hook config entry under `[[hooks.config]]` by key. - SetHookConfig { key: String, enabled: bool }, /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, @@ -521,9 +519,6 @@ impl ConfigDocument { ConfigEdit::SetSkillConfigByName { name, enabled } => { Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) } - ConfigEdit::SetHookConfig { key, enabled } => { - Ok(self.set_hook_config(key.clone(), *enabled)) - } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), ConfigEdit::SetProjectTrustLevel { path, level } => { @@ -724,121 +719,6 @@ impl ConfigDocument { mutated } - /// Set or clear a `[[hooks.config]]` entry by hook key. - /// - /// Disabled state is represented explicitly as `enabled = false`. Enabled - /// state is represented by removing the matching override, matching the - /// skills config behavior. - fn set_hook_config(&mut self, key: String, enabled: bool) -> bool { - let key = key.trim().to_string(); - if key.is_empty() { - return false; - } - let mut remove_hooks_table = false; - let mut mutated = false; - - { - let root = self.doc.as_table_mut(); - let hooks_item = match root.get_mut("hooks") { - Some(item) => item, - None => { - if enabled { - return false; - } - root.insert( - "hooks", - TomlItem::Table(document_helpers::new_implicit_table()), - ); - let Some(item) = root.get_mut("hooks") else { - return false; - }; - item - } - }; - - if document_helpers::ensure_table_for_write(hooks_item).is_none() { - if enabled { - return false; - } - *hooks_item = TomlItem::Table(document_helpers::new_implicit_table()); - } - let Some(hooks_table) = hooks_item.as_table_mut() else { - return false; - }; - - let config_item = match hooks_table.get_mut("config") { - Some(item) => item, - None => { - if enabled { - return false; - } - hooks_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new())); - let Some(item) = hooks_table.get_mut("config") else { - return false; - }; - item - } - }; - - if !matches!(config_item, TomlItem::ArrayOfTables(_)) { - if enabled { - return false; - } - *config_item = TomlItem::ArrayOfTables(ArrayOfTables::new()); - } - - let TomlItem::ArrayOfTables(overrides) = config_item else { - return false; - }; - - // Only persist negative overrides. Re-enabling removes the entry so - // the hook's default discovered state applies again. - let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { - hook_config_key_from_table(table) - .filter(|value| value == &key) - .map(|_| idx) - }); - - if enabled { - if let Some(index) = existing_index { - overrides.remove(index); - mutated = true; - if overrides.is_empty() { - hooks_table.remove("config"); - if hooks_table.is_empty() { - remove_hooks_table = true; - } - } - } - } else if let Some(index) = existing_index { - for (idx, table) in overrides.iter_mut().enumerate() { - if idx == index { - table["key"] = value(key); - table["enabled"] = value(false); - mutated = true; - break; - } - } - } else { - let mut entry = TomlTable::new(); - entry.set_implicit(false); - entry["key"] = value(key); - entry["enabled"] = value(false); - overrides.push(entry); - mutated = true; - } - } - - // Defer removing the parent table until the nested borrows above are - // dropped. - if remove_hooks_table { - let root = self.doc.as_table_mut(); - root.remove("hooks"); - } - - mutated - } - fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { let resolved: Vec = segments .iter() @@ -985,15 +865,6 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele } } -fn hook_config_key_from_table(table: &TomlTable) -> Option { - table - .get("key") - .and_then(|item| item.as_str()) - .map(str::trim) - .filter(|key| !key.is_empty()) - .map(str::to_string) -} - /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 6461c8e8c06..ec81c7c06dc 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -133,52 +133,6 @@ enabled = false assert_eq!(contents, expected); } -#[test] -fn set_hook_config_writes_disabled_entry() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - ConfigEditsBuilder::new(codex_home) - .with_edits([ConfigEdit::SetHookConfig { - key: "path:/tmp/hooks.json:pre_tool_use:0:0".to_string(), - enabled: false, - }]) - .apply_blocking() - .expect("persist"); - - let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[[hooks.config]] -key = "path:/tmp/hooks.json:pre_tool_use:0:0" -enabled = false -"#; - assert_eq!(contents, expected); -} - -#[test] -fn set_hook_config_removes_entry_when_enabled() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[[hooks.config]] -key = "path:/tmp/hooks.json:pre_tool_use:0:0" -enabled = false -"#, - ) - .expect("seed config"); - - ConfigEditsBuilder::new(codex_home) - .with_edits([ConfigEdit::SetHookConfig { - key: "path:/tmp/hooks.json:pre_tool_use:0:0".to_string(), - enabled: true, - }]) - .apply_blocking() - .expect("persist"); - - let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - assert_eq!(contents, ""); -} - #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/hooks.rs b/codex-rs/core/src/hooks.rs index 25b5aec89f3..2f1a46e0d12 100644 --- a/codex-rs/core/src/hooks.rs +++ b/codex-rs/core/src/hooks.rs @@ -1,3 +1,5 @@ pub use codex_hooks::HookListEntry; +pub use codex_hooks::HookListOutcome; pub use codex_hooks::Hooks; pub use codex_hooks::HooksConfig; +pub use codex_hooks::list_hooks; diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs deleted file mode 100644 index 0dc10cf9340..00000000000 --- a/codex-rs/hooks/src/config_rules.rs +++ /dev/null @@ -1,80 +0,0 @@ -use std::collections::HashSet; - -use codex_config::ConfigLayerSource; -use codex_config::ConfigLayerStack; -use codex_config::ConfigLayerStackOrdering; -use codex_config::HookConfig; -use codex_config::HookEventsToml; - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct HookConfigRules { - disabled_keys: HashSet, -} - -impl HookConfigRules { - pub(crate) fn is_enabled(&self, key: &str) -> bool { - !self.disabled_keys.contains(key) - } -} - -/// Build hook enablement rules from config layers that are allowed to override -/// user preferences. -/// -/// This intentionally reads only user and session flag layers, including -/// disabled layers, to match the skills config behavior. Project, managed, and -/// plugin layers can discover hooks, but they do not get to write user -/// enablement state. -pub(crate) fn hook_config_rules_from_stack( - config_layer_stack: Option<&ConfigLayerStack>, -) -> HookConfigRules { - let Some(config_layer_stack) = config_layer_stack else { - return HookConfigRules::default(); - }; - - let mut disabled_keys = HashSet::new(); - for layer in config_layer_stack.get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ true, - ) { - if !matches!( - layer.name, - ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags - ) { - continue; - } - - let Some(hooks_value) = layer.config.get("hooks") else { - continue; - }; - let hooks: HookEventsToml = match hooks_value.clone().try_into() { - Ok(hooks) => hooks, - Err(_) => { - continue; - } - }; - - for entry in hooks.config { - let Some(key) = hook_config_key(&entry) else { - continue; - }; - // Later layers win: an enabled entry removes a disabled override - // for the same key, while a disabled entry inserts it. - if entry.enabled { - disabled_keys.remove(&key); - } else { - disabled_keys.insert(key); - } - } - } - - HookConfigRules { disabled_keys } -} - -fn hook_config_key(entry: &HookConfig) -> Option { - let key = entry.key.as_deref().map(str::trim).unwrap_or_default(); - if key.is_empty() { - None - } else { - Some(key.to_string()) - } -} diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index cb7ea1caf87..2017160c681 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -18,21 +18,21 @@ use serde::Deserialize; use std::collections::HashMap; use super::ConfiguredHandler; -use crate::config_rules::HookConfigRules; -use crate::config_rules::hook_config_rules_from_stack; +use super::HookListEntry; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; +use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookSource; pub(crate) struct DiscoveryResult { pub handlers: Vec, + pub hook_entries: Vec, pub warnings: Vec, } #[derive(Clone)] struct HookHandlerSource<'a> { path: &'a AbsolutePathBuf, - key_prefix: String, is_managed: bool, source: HookSource, env: HashMap, @@ -46,30 +46,34 @@ pub(crate) fn discover_handlers( ) -> DiscoveryResult { let Some(config_layer_stack) = config_layer_stack else { let mut handlers = Vec::new(); + let mut hook_entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0_i64; - let hook_config_rules = HookConfigRules::default(); append_plugin_hook_sources( &mut handlers, + &mut hook_entries, &mut warnings, &mut display_order, plugin_hook_sources, - &hook_config_rules, ); - return DiscoveryResult { handlers, warnings }; + return DiscoveryResult { + handlers, + hook_entries, + warnings, + }; }; let mut handlers = Vec::new(); + let mut hook_entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0_i64; - let hook_config_rules = hook_config_rules_from_stack(Some(config_layer_stack)); append_managed_requirement_handlers( &mut handlers, + &mut hook_entries, &mut warnings, &mut display_order, config_layer_stack, - &hook_config_rules, ); for layer in config_layer_stack.get_layers( @@ -95,11 +99,11 @@ pub(crate) fn discover_handlers( if let Some((source_path, hook_events)) = json_hooks { append_hook_events( &mut handlers, + &mut hook_entries, &mut warnings, &mut display_order, HookHandlerSource { path: &source_path, - key_prefix: format!("path:{}", source_path.display()), is_managed: false, source: hook_source, env: HashMap::new(), @@ -107,18 +111,17 @@ pub(crate) fn discover_handlers( source_relative_path: None, }, hook_events, - &hook_config_rules, ); } if let Some((source_path, hook_events)) = toml_hooks { append_hook_events( &mut handlers, + &mut hook_entries, &mut warnings, &mut display_order, HookHandlerSource { path: &source_path, - key_prefix: format!("path:{}", source_path.display()), is_managed: false, source: hook_source, env: HashMap::new(), @@ -126,28 +129,31 @@ pub(crate) fn discover_handlers( source_relative_path: None, }, hook_events, - &hook_config_rules, ); } } append_plugin_hook_sources( &mut handlers, + &mut hook_entries, &mut warnings, &mut display_order, plugin_hook_sources, - &hook_config_rules, ); - DiscoveryResult { handlers, warnings } + DiscoveryResult { + handlers, + hook_entries, + warnings, + } } fn append_managed_requirement_handlers( handlers: &mut Vec, + hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, config_layer_stack: &ConfigLayerStack, - hook_config_rules: &HookConfigRules, ) { let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else { return; @@ -159,11 +165,11 @@ fn append_managed_requirement_handlers( }; append_hook_events( handlers, + hook_entries, warnings, display_order, HookHandlerSource { path: &source_path, - key_prefix: format!("path:{}", source_path.display()), is_managed: true, source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), env: HashMap::new(), @@ -171,16 +177,15 @@ fn append_managed_requirement_handlers( source_relative_path: None, }, managed_hooks.get().hooks.clone(), - hook_config_rules, ); } fn append_plugin_hook_sources( handlers: &mut Vec, + hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, plugin_hook_sources: Vec, - hook_config_rules: &HookConfigRules, ) { // TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable. for source in plugin_hook_sources { @@ -199,11 +204,11 @@ fn append_plugin_hook_sources( let plugin_id = plugin_id.as_key(); append_hook_events( handlers, + hook_entries, warnings, display_order, HookHandlerSource { path: &source_path, - key_prefix: format!("plugin:{plugin_id}:{source_relative_path}"), is_managed: false, source: HookSource::Plugin, env, @@ -211,7 +216,6 @@ fn append_plugin_hook_sources( source_relative_path: Some(source_relative_path), }, hooks, - hook_config_rules, ); } } @@ -354,35 +358,35 @@ fn synthetic_layer_path(path: &str) -> AbsolutePathBuf { fn append_hook_events( handlers: &mut Vec, + hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, source: HookHandlerSource<'_>, hook_events: HookEventsToml, - hook_config_rules: &HookConfigRules, ) { for (event_name, groups) in hook_events.into_matcher_groups() { append_matcher_groups( handlers, + hook_entries, warnings, display_order, source.clone(), event_name, groups, - hook_config_rules, ); } } fn append_matcher_groups( handlers: &mut Vec, + hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, source: HookHandlerSource<'_>, event_name: codex_protocol::protocol::HookEventName, groups: Vec, - hook_config_rules: &HookConfigRules, ) { - for (group_index, group) in groups.into_iter().enumerate() { + for group in groups { let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref()); if let Some(matcher) = matcher && let Err(err) = validate_matcher_pattern(matcher) @@ -394,7 +398,7 @@ fn append_matcher_groups( continue; } - for (handler_index, handler) in group.hooks.into_iter().enumerate() { + for handler in group.hooks { match handler { HookHandlerConfig::Command { command, @@ -417,16 +421,20 @@ fn append_matcher_groups( continue; } let timeout_sec = timeout_sec.unwrap_or(600).max(1); - let key = format!( - "{}:{}:{}:{}", - source.key_prefix, - hook_event_key_label(event_name), - group_index, - handler_index - ); - let enabled = hook_config_rules.is_enabled(&key); + hook_entries.push(HookListEntry { + event_name, + handler_type: HookHandlerType::Command, + matcher: matcher.map(ToOwned::to_owned), + command: Some(command.clone()), + timeout_sec, + status_message: status_message.clone(), + source_path: source.path.clone(), + source: source.source, + plugin_id: source.plugin_id.clone(), + source_relative_path: source.source_relative_path.clone(), + display_order: *display_order, + }); handlers.push(ConfiguredHandler { - key, event_name, is_managed: source.is_managed, matcher: matcher.map(ToOwned::to_owned), @@ -437,9 +445,6 @@ fn append_matcher_groups( source: source.source, display_order: *display_order, env: source.env.clone(), - enabled, - plugin_id: source.plugin_id.clone(), - source_relative_path: source.source_relative_path.clone(), }); *display_order += 1; } @@ -456,17 +461,6 @@ fn append_matcher_groups( } } -fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { - match event_name { - codex_protocol::protocol::HookEventName::PreToolUse => "pre_tool_use", - codex_protocol::protocol::HookEventName::PermissionRequest => "permission_request", - codex_protocol::protocol::HookEventName::PostToolUse => "post_tool_use", - codex_protocol::protocol::HookEventName::SessionStart => "session_start", - codex_protocol::protocol::HookEventName::UserPromptSubmit => "user_prompt_submit", - codex_protocol::protocol::HookEventName::Stop => "stop", - } -} - fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource { match source { ConfigLayerSource::System { .. } => HookSource::System, @@ -509,7 +503,6 @@ mod tests { use super::ConfiguredHandler; use super::append_matcher_groups; - use crate::config_rules::HookConfigRules; use codex_config::HookHandlerConfig; use codex_config::MatcherGroup; @@ -524,7 +517,6 @@ mod tests { fn hook_handler_source(path: &AbsolutePathBuf) -> super::HookHandlerSource<'_> { super::HookHandlerSource { path, - key_prefix: format!("path:{}", path.display()), is_managed: false, source: hook_source(), env: std::collections::HashMap::new(), @@ -554,19 +546,18 @@ mod tests { append_matcher_groups( &mut handlers, + &mut Vec::new(), &mut warnings, &mut display_order, hook_handler_source(&source_path), HookEventName::UserPromptSubmit, vec![command_group(Some("["))], - &HookConfigRules::default(), ); assert_eq!(warnings, Vec::::new()); assert_eq!( handlers, vec![ConfiguredHandler { - key: format!("path:{}:user_prompt_submit:0:0", source_path.display()), event_name: HookEventName::UserPromptSubmit, is_managed: false, matcher: None, @@ -577,9 +568,6 @@ mod tests { source: hook_source(), display_order: 0, env: std::collections::HashMap::new(), - enabled: true, - plugin_id: None, - source_relative_path: None, }] ); } @@ -593,19 +581,18 @@ mod tests { append_matcher_groups( &mut handlers, + &mut Vec::new(), &mut warnings, &mut display_order, hook_handler_source(&source_path), HookEventName::PreToolUse, vec![command_group(Some("^Bash$"))], - &HookConfigRules::default(), ); assert_eq!(warnings, Vec::::new()); assert_eq!( handlers, vec![ConfiguredHandler { - key: format!("path:{}:pre_tool_use:0:0", source_path.display()), event_name: HookEventName::PreToolUse, is_managed: false, matcher: Some("^Bash$".to_string()), @@ -616,9 +603,6 @@ mod tests { source: hook_source(), display_order: 0, env: std::collections::HashMap::new(), - enabled: true, - plugin_id: None, - source_relative_path: None, }] ); } @@ -632,12 +616,12 @@ mod tests { append_matcher_groups( &mut handlers, + &mut Vec::new(), &mut warnings, &mut display_order, hook_handler_source(&source_path), HookEventName::PreToolUse, vec![command_group(Some("*"))], - &HookConfigRules::default(), ); assert_eq!(warnings, Vec::::new()); @@ -654,12 +638,12 @@ mod tests { append_matcher_groups( &mut handlers, + &mut Vec::new(), &mut warnings, &mut display_order, hook_handler_source(&source_path), HookEventName::PostToolUse, vec![command_group(Some("Edit|Write"))], - &HookConfigRules::default(), ); assert_eq!(warnings, Vec::::new()); diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 1100dcb79bf..c19b311843b 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -155,7 +155,6 @@ mod tests { display_order: i64, ) -> ConfiguredHandler { ConfiguredHandler { - key: format!("test:{display_order}"), event_name, is_managed: false, matcher: matcher.map(str::to_owned), @@ -166,9 +165,6 @@ mod tests { source: HookSource::User, display_order, env: std::collections::HashMap::new(), - enabled: true, - plugin_id: None, - source_relative_path: None, } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index cc0c3974753..9af71c19fbc 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -35,7 +35,6 @@ pub(crate) struct CommandShell { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ConfiguredHandler { - pub key: String, pub event_name: codex_protocol::protocol::HookEventName, pub is_managed: bool, pub matcher: Option, @@ -46,9 +45,6 @@ pub(crate) struct ConfiguredHandler { pub source: HookSource, pub display_order: i64, pub env: HashMap, - pub enabled: bool, - pub plugin_id: Option, - pub source_relative_path: Option, } impl ConfiguredHandler { @@ -75,7 +71,6 @@ impl ConfiguredHandler { #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookListEntry { - pub key: String, pub event_name: HookEventName, pub handler_type: HookHandlerType, pub matcher: Option, @@ -87,33 +82,11 @@ pub struct HookListEntry { pub plugin_id: Option, pub source_relative_path: Option, pub display_order: i64, - pub enabled: bool, -} - -impl From<&ConfiguredHandler> for HookListEntry { - fn from(handler: &ConfiguredHandler) -> Self { - Self { - key: handler.key.clone(), - event_name: handler.event_name, - handler_type: HookHandlerType::Command, - matcher: handler.matcher.clone(), - command: Some(handler.command.clone()), - timeout_sec: handler.timeout_sec, - status_message: handler.status_message.clone(), - source_path: handler.source_path.clone(), - source: handler.source, - plugin_id: handler.plugin_id.clone(), - source_relative_path: handler.source_relative_path.clone(), - display_order: handler.display_order, - enabled: handler.enabled, - } - } } #[derive(Clone)] pub(crate) struct ClaudeHooksEngine { handlers: Vec, - configured_hooks: Vec, warnings: Vec, shell: CommandShell, } @@ -128,7 +101,6 @@ impl ClaudeHooksEngine { if !enabled { return Self { handlers: Vec::new(), - configured_hooks: Vec::new(), warnings: Vec::new(), shell, }; @@ -136,19 +108,8 @@ impl ClaudeHooksEngine { let _ = schema_loader::generated_hook_schemas(); let discovered = discovery::discover_handlers(config_layer_stack, plugin_hook_sources); - let configured_hooks = discovered - .handlers - .iter() - .map(HookListEntry::from) - .collect::>(); - let handlers = discovered - .handlers - .into_iter() - .filter(|handler| handler.enabled) - .collect(); Self { - handlers, - configured_hooks, + handlers: discovered.handlers, warnings: discovered.warnings, shell, } @@ -158,10 +119,6 @@ impl ClaudeHooksEngine { &self.warnings } - pub(crate) fn configured_hooks(&self) -> &[HookListEntry] { - &self.configured_hooks - } - pub(crate) fn preview_session_start( &self, request: &SessionStartRequest, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 19afd1fdb36..245c31ba5fd 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -332,92 +332,6 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { assert_eq!(preview[1].source_path, config_path); } -#[test] -fn hooks_config_disables_matching_discovered_hook() { - let temp = tempdir().expect("create temp dir"); - let config_path = - AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); - let hook_key = format!("path:{}:pre_tool_use:0:0", config_path.display()); - let mut config_toml = TomlValue::Table(Default::default()); - let TomlValue::Table(config_table) = &mut config_toml else { - unreachable!("config TOML root should be a table"); - }; - let mut hooks_table = TomlValue::Table(Default::default()); - let TomlValue::Table(hooks_entries) = &mut hooks_table else { - unreachable!("hooks entry should be a table"); - }; - let mut pre_tool_use_group = TomlValue::Table(Default::default()); - let TomlValue::Table(pre_tool_use_group_entries) = &mut pre_tool_use_group else { - unreachable!("PreToolUse group should be a table"); - }; - pre_tool_use_group_entries.insert("matcher".to_string(), TomlValue::String("Bash".to_string())); - pre_tool_use_group_entries.insert( - "hooks".to_string(), - TomlValue::Array(vec![TomlValue::Table(Default::default())]), - ); - let Some(TomlValue::Array(hooks_array)) = pre_tool_use_group_entries.get_mut("hooks") else { - unreachable!("PreToolUse hooks should be an array"); - }; - let Some(TomlValue::Table(handler_entries)) = hooks_array.first_mut() else { - unreachable!("PreToolUse handler should be a table"); - }; - handler_entries.insert("type".to_string(), TomlValue::String("command".to_string())); - handler_entries.insert( - "command".to_string(), - TomlValue::String("python3 /tmp/disabled-hook.py".to_string()), - ); - hooks_entries.insert( - "PreToolUse".to_string(), - TomlValue::Array(vec![pre_tool_use_group]), - ); - let mut config_entry = TomlValue::Table(Default::default()); - let TomlValue::Table(config_entry_table) = &mut config_entry else { - unreachable!("hooks.config entry should be a table"); - }; - config_entry_table.insert("key".to_string(), TomlValue::String(hook_key.clone())); - config_entry_table.insert("enabled".to_string(), TomlValue::Boolean(false)); - hooks_entries.insert("config".to_string(), TomlValue::Array(vec![config_entry])); - config_table.insert("hooks".to_string(), hooks_table); - let config_layer_stack = ConfigLayerStack::new( - vec![ConfigLayerEntry::new( - ConfigLayerSource::User { file: config_path }, - config_toml, - )], - ConfigRequirements::default(), - ConfigRequirementsToml::default(), - ) - .expect("config layer stack"); - - let engine = ClaudeHooksEngine::new( - /*enabled*/ true, - Some(&config_layer_stack), - Vec::new(), - CommandShell { - program: String::new(), - args: Vec::new(), - }, - ); - - let configured_hooks = engine.configured_hooks(); - assert_eq!(configured_hooks.len(), 1); - assert_eq!(configured_hooks[0].key, hook_key); - assert_eq!(configured_hooks[0].enabled, false); - - 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, Vec::new()); -} - #[tokio::test] async fn plugin_hook_sources_run_with_plugin_env_and_plugin_source() { let temp = tempdir().expect("create temp dir"); diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 4c87ead35b6..c01cebf78a2 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -542,7 +542,6 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { - key: "test:post_tool_use".to_string(), event_name: HookEventName::PostToolUse, is_managed: false, matcher: Some("^Bash$".to_string()), @@ -553,9 +552,6 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), - enabled: true, - plugin_id: None, - source_relative_path: None, } } diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index e4eea6e517b..3b20c2c2c02 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -533,7 +533,6 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { - key: "test:pre_tool_use".to_string(), event_name: HookEventName::PreToolUse, is_managed: false, matcher: Some("^Bash$".to_string()), @@ -544,9 +543,6 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), - enabled: true, - plugin_id: None, - source_relative_path: None, } } diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index d0fae2b6a30..54c7f51732b 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -355,7 +355,6 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { - key: "test:session_start".to_string(), event_name: HookEventName::SessionStart, is_managed: false, matcher: None, @@ -366,9 +365,6 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), - enabled: true, - plugin_id: None, - source_relative_path: None, } } diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 219d8c87485..392f15eee24 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -522,7 +522,6 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { - key: "test:stop".to_string(), event_name: HookEventName::Stop, is_managed: false, matcher: None, @@ -533,9 +532,6 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), - enabled: true, - plugin_id: None, - source_relative_path: None, } } diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 759121220d2..8aaf3ad608e 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -413,7 +413,6 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { - key: "test:user_prompt_submit".to_string(), event_name: HookEventName::UserPromptSubmit, is_managed: false, matcher: None, @@ -424,9 +423,6 @@ mod tests { source: codex_protocol::protocol::HookSource::User, display_order: 0, env: std::collections::HashMap::new(), - enabled: true, - plugin_id: None, - source_relative_path: None, } } diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index f8d6d0794dd..e596b349f46 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -1,4 +1,3 @@ -mod config_rules; mod engine; pub(crate) mod events; mod legacy_notify; @@ -23,9 +22,11 @@ pub use events::user_prompt_submit::UserPromptSubmitOutcome; pub use events::user_prompt_submit::UserPromptSubmitRequest; pub use legacy_notify::legacy_notify_json; pub use legacy_notify::notify_hook; +pub use registry::HookListOutcome; pub use registry::Hooks; pub use registry::HooksConfig; pub use registry::command_from_argv; +pub use registry::list_hooks; pub use schema::write_schema_fixtures; pub use types::Hook; pub use types::HookEvent; diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index a91ac966009..8de7b69b2ae 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -32,6 +32,12 @@ pub struct HooksConfig { pub shell_args: Vec, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HookListOutcome { + pub hooks: Vec, + pub warnings: Vec, +} + #[derive(Clone)] pub struct Hooks { after_agent: Vec, @@ -73,10 +79,6 @@ impl Hooks { self.engine.warnings() } - pub fn configured_hooks(&self) -> &[HookListEntry] { - self.engine.configured_hooks() - } - fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] { match hook_event { HookEvent::AfterAgent { .. } => &self.after_agent, @@ -176,6 +178,21 @@ impl Hooks { } } +pub fn list_hooks(config: HooksConfig) -> HookListOutcome { + if !config.feature_enabled { + return HookListOutcome::default(); + } + + let discovered = crate::engine::discovery::discover_handlers( + config.config_layer_stack.as_ref(), + config.plugin_hook_sources, + ); + HookListOutcome { + hooks: discovered.hook_entries, + warnings: discovered.warnings, + } +} + pub fn command_from_argv(argv: &[String]) -> Option { let (program, args) = argv.split_first()?; if program.is_empty() { From b1307c251a50536fc041689800c8740a0b735695 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 10:50:34 -0700 Subject: [PATCH 11/64] Add hook config write support --- .../schema/json/ClientRequest.json | 39 ++++++ .../codex_app_server_protocol.schemas.json | 62 +++++++++ .../codex_app_server_protocol.v2.schemas.json | 62 +++++++++ .../json/v2/HooksConfigWriteParams.json | 17 +++ .../json/v2/HooksConfigWriteResponse.json | 13 ++ .../schema/json/v2/HooksListResponse.json | 8 ++ .../schema/typescript/ClientRequest.ts | 3 +- .../schema/typescript/v2/HookMetadata.ts | 2 +- .../typescript/v2/HooksConfigWriteParams.ts | 5 + .../typescript/v2/HooksConfigWriteResponse.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 17 +++ codex-rs/app-server/README.md | 22 ++- .../app-server/src/codex_message_processor.rs | 53 +++++++ codex-rs/app-server/src/config_api.rs | 1 + .../app-server/tests/common/mcp_process.rs | 10 ++ .../app-server/tests/suite/v2/hooks_list.rs | 56 ++++++++ codex-rs/config/src/hook_config.rs | 11 ++ codex-rs/config/src/lib.rs | 1 + codex-rs/config/src/types.rs | 1 + codex-rs/core/config.schema.json | 20 +++ codex-rs/core/src/config/edit.rs | 129 ++++++++++++++++++ codex-rs/core/src/config/edit_tests.rs | 46 +++++++ codex-rs/hooks/src/config_rules.rs | 80 +++++++++++ codex-rs/hooks/src/engine/discovery.rs | 91 +++++++++--- codex-rs/hooks/src/engine/mod.rs | 2 + codex-rs/hooks/src/engine/mod_tests.rs | 110 +++++++++++++++ codex-rs/hooks/src/lib.rs | 1 + 29 files changed, 849 insertions(+), 24 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts create mode 100644 codex-rs/hooks/src/config_rules.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 11be794498d..8dc1e5e5d87 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1447,6 +1447,21 @@ ], "type": "object" }, + "HooksConfigWriteParams": { + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + }, + "required": [ + "enabled", + "key" + ], + "type": "object" + }, "HooksListParams": { "properties": { "cwds": { @@ -5372,6 +5387,30 @@ "title": "Skills/config/writeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "hooks/config/write" + ], + "title": "Hooks/config/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HooksConfigWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/config/writeRequest", + "type": "object" + }, { "properties": { "id": { 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 c4db23df2d8..3f780790d69 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 @@ -1122,6 +1122,30 @@ "title": "Skills/config/writeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "hooks/config/write" + ], + "title": "Hooks/config/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/HooksConfigWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/config/writeRequest", + "type": "object" + }, { "properties": { "id": { @@ -9644,12 +9668,18 @@ "format": "int64", "type": "integer" }, + "enabled": { + "type": "boolean" + }, "eventName": { "$ref": "#/definitions/v2/HookEventName" }, "handlerType": { "$ref": "#/definitions/v2/HookHandlerType" }, + "key": { + "type": "string" + }, "matcher": { "type": [ "string", @@ -9688,8 +9718,10 @@ }, "required": [ "displayOrder", + "enabled", "eventName", "handlerType", + "key", "source", "sourcePath", "timeoutSec" @@ -9870,6 +9902,36 @@ "title": "HookStartedNotification", "type": "object" }, + "HooksConfigWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + }, + "required": [ + "enabled", + "key" + ], + "title": "HooksConfigWriteParams", + "type": "object" + }, + "HooksConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "effectiveEnabled": { + "type": "boolean" + } + }, + "required": [ + "effectiveEnabled" + ], + "title": "HooksConfigWriteResponse", + "type": "object" + }, "HooksListEntry": { "properties": { "cwd": { 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 fc2e4e9ca23..70c8f84bf7d 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 @@ -1828,6 +1828,30 @@ "title": "Skills/config/writeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "hooks/config/write" + ], + "title": "Hooks/config/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HooksConfigWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Hooks/config/writeRequest", + "type": "object" + }, { "properties": { "id": { @@ -6274,12 +6298,18 @@ "format": "int64", "type": "integer" }, + "enabled": { + "type": "boolean" + }, "eventName": { "$ref": "#/definitions/HookEventName" }, "handlerType": { "$ref": "#/definitions/HookHandlerType" }, + "key": { + "type": "string" + }, "matcher": { "type": [ "string", @@ -6318,8 +6348,10 @@ }, "required": [ "displayOrder", + "enabled", "eventName", "handlerType", + "key", "source", "sourcePath", "timeoutSec" @@ -6500,6 +6532,36 @@ "title": "HookStartedNotification", "type": "object" }, + "HooksConfigWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + }, + "required": [ + "enabled", + "key" + ], + "title": "HooksConfigWriteParams", + "type": "object" + }, + "HooksConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "effectiveEnabled": { + "type": "boolean" + } + }, + "required": [ + "effectiveEnabled" + ], + "title": "HooksConfigWriteResponse", + "type": "object" + }, "HooksListEntry": { "properties": { "cwd": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json new file mode 100644 index 00000000000..da575768df9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + }, + "required": [ + "enabled", + "key" + ], + "title": "HooksConfigWriteParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json new file mode 100644 index 00000000000..6016edad4f9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "effectiveEnabled": { + "type": "boolean" + } + }, + "required": [ + "effectiveEnabled" + ], + "title": "HooksConfigWriteResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index 889a99bcd71..c758455bc52 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -51,12 +51,18 @@ "format": "int64", "type": "integer" }, + "enabled": { + "type": "boolean" + }, "eventName": { "$ref": "#/definitions/HookEventName" }, "handlerType": { "$ref": "#/definitions/HookHandlerType" }, + "key": { + "type": "string" + }, "matcher": { "type": [ "string", @@ -95,8 +101,10 @@ }, "required": [ "displayOrder", + "enabled", "eventName", "handlerType", + "key", "source", "sourcePath", "timeoutSec" diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 82e313e4236..2a28d77b8ce 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -34,6 +34,7 @@ import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; +import type { HooksConfigWriteParams } from "./v2/HooksConfigWriteParams"; import type { HooksListParams } from "./v2/HooksListParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; @@ -77,4 +78,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "hooks/config/write", id: RequestId, params: HooksConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index eb12a2a6962..c56eb004ada 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -6,4 +6,4 @@ import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; -export type HookMetadata = { eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, sourceRelativePath: string | null, displayOrder: bigint, }; +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, sourceRelativePath: string | null, displayOrder: bigint, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts new file mode 100644 index 00000000000..d7f7394a339 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HooksConfigWriteParams = { key: string, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts new file mode 100644 index 00000000000..10b3b73da45 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HooksConfigWriteResponse = { effectiveEnabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index eb2a43e9481..fcc9881dfb3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -164,6 +164,8 @@ export type { HookRunSummary } from "./HookRunSummary"; export type { HookScope } from "./HookScope"; export type { HookSource } from "./HookSource"; export type { HookStartedNotification } from "./HookStartedNotification"; +export type { HooksConfigWriteParams } from "./HooksConfigWriteParams"; +export type { HooksConfigWriteResponse } from "./HooksConfigWriteResponse"; export type { HooksListEntry } from "./HooksListEntry"; export type { HooksListParams } from "./HooksListParams"; export type { HooksListResponse } from "./HooksListResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5699dd46812..219e806bdb5 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -444,6 +444,10 @@ client_request_definitions! { params: v2::SkillsConfigWriteParams, response: v2::SkillsConfigWriteResponse, }, + HooksConfigWrite => "hooks/config/write" { + params: v2::HooksConfigWriteParams, + response: v2::HooksConfigWriteResponse, + }, PluginInstall => "plugin/install" { params: v2::PluginInstallParams, response: v2::PluginInstallResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a89b8fa7f94..93d6a2d529d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4487,6 +4487,7 @@ pub struct HooksListEntry { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct HookMetadata { + pub key: String, pub event_name: HookEventName, pub handler_type: HookHandlerType, pub matcher: Option, @@ -4498,6 +4499,7 @@ pub struct HookMetadata { pub plugin_id: Option, pub source_relative_path: Option, pub display_order: i64, + pub enabled: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -4663,6 +4665,21 @@ pub struct SkillsConfigWriteResponse { pub effective_enabled: bool, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksConfigWriteParams { + pub key: String, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksConfigWriteResponse { + pub effective_enabled: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 682d739aa0f..e9b5587069c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -195,7 +195,7 @@ Example with notification opt-out: - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). -- `hooks/list` — list discovered hooks for one or more `cwd` values. +- `hooks/list` — list discovered hooks for one or more `cwd` values, including hooks disabled by user config. - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. @@ -207,6 +207,7 @@ Example with notification opt-out: - `device/key/public` — return a device key's SPKI DER public key as base64 plus its `algorithm` and `protectionClass`. - `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API. - `skills/config/write` — write user-level skill config by name or absolute path. +- `hooks/config/write` — write user-level hook config by hook key. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. @@ -1451,7 +1452,7 @@ To enable or disable a skill by name: } ``` -Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. +Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. ```json { @@ -1470,6 +1471,7 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. "data": [{ "cwd": "/Users/me/project", "hooks": [{ + "key": "path:/Users/me/.codex/config.toml:pre_tool_use:0:0", "eventName": "pre_tool_use", "handlerType": "command", "matcher": "Bash", @@ -1480,7 +1482,8 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. "source": "user", "pluginId": null, "sourceRelativePath": null, - "displayOrder": 0 + "displayOrder": 0, + "enabled": true }], "warnings": [], "errors": [] @@ -1489,6 +1492,19 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. } ``` +To enable or disable a hook, write the hook key returned by `hooks/list`: + +```json +{ + "method": "hooks/config/write", + "id": 29, + "params": { + "key": "path:/Users/me/.codex/config.toml:pre_tool_use:0:0", + "enabled": false + } +} +``` + ## Apps Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3bee2b14f38..102ffdaa16f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -76,6 +76,8 @@ use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::HookMetadata; +use codex_app_server_protocol::HooksConfigWriteParams; +use codex_app_server_protocol::HooksConfigWriteResponse; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCErrorError; @@ -1077,6 +1079,10 @@ impl CodexMessageProcessor { self.skills_config_write(to_connection_request_id(request_id), params) .await; } + ClientRequest::HooksConfigWrite { request_id, params } => { + self.hooks_config_write(to_connection_request_id(request_id), params) + .await; + } ClientRequest::PluginInstall { request_id, params } => { self.plugin_install(to_connection_request_id(request_id), params) .await; @@ -7172,6 +7178,51 @@ impl CodexMessageProcessor { } } + /// Handle `hooks/config/write` by updating user-level hook enablement. + async fn hooks_config_write( + &self, + request_id: ConnectionRequestId, + params: HooksConfigWriteParams, + ) { + let HooksConfigWriteParams { key, enabled } = params; + if key.trim().is_empty() { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: "hooks/config/write requires a non-empty key".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits(vec![ConfigEdit::SetHookConfig { key, enabled }]) + .apply() + .await; + + match result { + Ok(()) => { + self.clear_plugin_related_caches(); + self.outgoing + .send_response( + request_id, + HooksConfigWriteResponse { + effective_enabled: enabled, + }, + ) + .await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to update hook settings: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + async fn turn_start( &self, request_id: ConnectionRequestId, @@ -9462,6 +9513,7 @@ fn hooks_to_info(hooks: &[codex_core::hooks::HookListEntry]) -> Vec Vec Managed session_start, user_prompt_submit, stop, + config: _, } = hooks; ManagedHooksRequirements { diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 20ed8cea42e..82b7bbe9703 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetConversationSummaryParams; +use codex_app_server_protocol::HooksConfigWriteParams; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; @@ -558,6 +559,15 @@ impl McpProcess { self.send_request("hooks/list", params).await } + /// Send a `hooks/config/write` JSON-RPC request. + pub async fn send_hooks_config_write_request( + &mut self, + params: HooksConfigWriteParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("hooks/config/write", params).await + } + /// Send a `marketplace/add` JSON-RPC request. pub async fn send_marketplace_add_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index be378fd0090..160e049efe5 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -5,6 +5,8 @@ use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookSource; +use codex_app_server_protocol::HooksConfigWriteParams; +use codex_app_server_protocol::HooksConfigWriteResponse; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCResponse; @@ -65,3 +67,57 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { assert_eq!(hook.source, HookSource::User); Ok(()) } + +#[tokio::test] +async fn hooks_config_write_disables_user_hook() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_user_hook_config(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let hook = &data[0].hooks[0]; + assert_eq!(hook.enabled, true); + + let write_id = mcp + .send_hooks_config_write_request(HooksConfigWriteParams { + key: hook.key.clone(), + enabled: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let HooksConfigWriteResponse { effective_enabled } = to_response(response)?; + assert_eq!(effective_enabled, false); + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!(data[0].hooks.len(), 1); + assert_eq!(data[0].hooks[0].key, hook.key); + assert_eq!(data[0].hooks[0].enabled, false); + Ok(()) +} diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 8a5c73d6b9b..b5107e47d63 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -26,6 +26,15 @@ pub struct HookEventsToml { pub user_prompt_submit: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HookConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + pub enabled: bool, } impl HookEventsToml { @@ -37,6 +46,7 @@ impl HookEventsToml { session_start, user_prompt_submit, stop, + config: _, } = self; pre_tool_use.is_empty() && permission_request.is_empty() @@ -54,6 +64,7 @@ impl HookEventsToml { session_start, user_prompt_submit, stop, + config: _, } = self; [ pre_tool_use, diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e3d95acb866..ba5b511ab70 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -68,6 +68,7 @@ pub use diagnostics::format_config_error; pub use diagnostics::format_config_error_with_source; pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; +pub use hook_config::HookConfig; pub use hook_config::HookEventsToml; pub use hook_config::HookHandlerConfig; pub use hook_config::HooksFile; diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 6668e25318b..0785b9e2ef1 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -642,6 +642,7 @@ pub struct Notice { pub external_config_migration_prompts: ExternalConfigMigrationPrompts, } +pub use crate::hook_config::HookConfig; pub use crate::skills_config::BundledSkillsConfig; pub use crate::skills_config::SkillConfig; pub use crate::skills_config::SkillsConfig; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index fc314b3cdbe..6a6364a3156 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -859,6 +859,20 @@ } ] }, + "HookConfig": { + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "HookEventsToml": { "properties": { "PermissionRequest": { @@ -902,6 +916,12 @@ "$ref": "#/definitions/MatcherGroup" }, "type": "array" + }, + "config": { + "items": { + "$ref": "#/definitions/HookConfig" + }, + "type": "array" } }, "type": "object" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index e49dc9dc08d..7cfefaae3bb 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -61,6 +61,8 @@ pub enum ConfigEdit { SetSkillConfig { path: PathBuf, enabled: bool }, /// Set or clear a skill config entry under `[[skills.config]]` by name. SetSkillConfigByName { name: String, enabled: bool }, + /// Set or clear a hook config entry under `[[hooks.config]]` by key. + SetHookConfig { key: String, enabled: bool }, /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, @@ -519,6 +521,9 @@ impl ConfigDocument { ConfigEdit::SetSkillConfigByName { name, enabled } => { Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) } + ConfigEdit::SetHookConfig { key, enabled } => { + Ok(self.set_hook_config(key.clone(), *enabled)) + } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), ConfigEdit::SetProjectTrustLevel { path, level } => { @@ -719,6 +724,121 @@ impl ConfigDocument { mutated } + /// Set or clear a `[[hooks.config]]` entry by hook key. + /// + /// Disabled state is represented explicitly as `enabled = false`. Enabled + /// state is represented by removing the matching override, matching the + /// skills config behavior. + fn set_hook_config(&mut self, key: String, enabled: bool) -> bool { + let key = key.trim().to_string(); + if key.is_empty() { + return false; + } + let mut remove_hooks_table = false; + let mut mutated = false; + + { + let root = self.doc.as_table_mut(); + let hooks_item = match root.get_mut("hooks") { + Some(item) => item, + None => { + if enabled { + return false; + } + root.insert( + "hooks", + TomlItem::Table(document_helpers::new_implicit_table()), + ); + let Some(item) = root.get_mut("hooks") else { + return false; + }; + item + } + }; + + if document_helpers::ensure_table_for_write(hooks_item).is_none() { + if enabled { + return false; + } + *hooks_item = TomlItem::Table(document_helpers::new_implicit_table()); + } + let Some(hooks_table) = hooks_item.as_table_mut() else { + return false; + }; + + let config_item = match hooks_table.get_mut("config") { + Some(item) => item, + None => { + if enabled { + return false; + } + hooks_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new())); + let Some(item) = hooks_table.get_mut("config") else { + return false; + }; + item + } + }; + + if !matches!(config_item, TomlItem::ArrayOfTables(_)) { + if enabled { + return false; + } + *config_item = TomlItem::ArrayOfTables(ArrayOfTables::new()); + } + + let TomlItem::ArrayOfTables(overrides) = config_item else { + return false; + }; + + // Only persist negative overrides. Re-enabling removes the entry so + // the hook's default discovered state applies again. + let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { + hook_config_key_from_table(table) + .filter(|value| value == &key) + .map(|_| idx) + }); + + if enabled { + if let Some(index) = existing_index { + overrides.remove(index); + mutated = true; + if overrides.is_empty() { + hooks_table.remove("config"); + if hooks_table.is_empty() { + remove_hooks_table = true; + } + } + } + } else if let Some(index) = existing_index { + for (idx, table) in overrides.iter_mut().enumerate() { + if idx == index { + table["key"] = value(key); + table["enabled"] = value(false); + mutated = true; + break; + } + } + } else { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + entry["key"] = value(key); + entry["enabled"] = value(false); + overrides.push(entry); + mutated = true; + } + } + + // Defer removing the parent table until the nested borrows above are + // dropped. + if remove_hooks_table { + let root = self.doc.as_table_mut(); + root.remove("hooks"); + } + + mutated + } + fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { let resolved: Vec = segments .iter() @@ -865,6 +985,15 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele } } +fn hook_config_key_from_table(table: &TomlTable) -> Option { + table + .get("key") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string) +} + /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index ec81c7c06dc..6461c8e8c06 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -133,6 +133,52 @@ enabled = false assert_eq!(contents, expected); } +#[test] +fn set_hook_config_writes_disabled_entry() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetHookConfig { + key: "path:/tmp/hooks.json:pre_tool_use:0:0".to_string(), + enabled: false, + }]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[[hooks.config]] +key = "path:/tmp/hooks.json:pre_tool_use:0:0" +enabled = false +"#; + assert_eq!(contents, expected); +} + +#[test] +fn set_hook_config_removes_entry_when_enabled() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#"[[hooks.config]] +key = "path:/tmp/hooks.json:pre_tool_use:0:0" +enabled = false +"#, + ) + .expect("seed config"); + + ConfigEditsBuilder::new(codex_home) + .with_edits([ConfigEdit::SetHookConfig { + key: "path:/tmp/hooks.json:pre_tool_use:0:0".to_string(), + enabled: true, + }]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, ""); +} + #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs new file mode 100644 index 00000000000..0dc10cf9340 --- /dev/null +++ b/codex-rs/hooks/src/config_rules.rs @@ -0,0 +1,80 @@ +use std::collections::HashSet; + +use codex_config::ConfigLayerSource; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::HookConfig; +use codex_config::HookEventsToml; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct HookConfigRules { + disabled_keys: HashSet, +} + +impl HookConfigRules { + pub(crate) fn is_enabled(&self, key: &str) -> bool { + !self.disabled_keys.contains(key) + } +} + +/// Build hook enablement rules from config layers that are allowed to override +/// user preferences. +/// +/// This intentionally reads only user and session flag layers, including +/// disabled layers, to match the skills config behavior. Project, managed, and +/// plugin layers can discover hooks, but they do not get to write user +/// enablement state. +pub(crate) fn hook_config_rules_from_stack( + config_layer_stack: Option<&ConfigLayerStack>, +) -> HookConfigRules { + let Some(config_layer_stack) = config_layer_stack else { + return HookConfigRules::default(); + }; + + let mut disabled_keys = HashSet::new(); + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) { + if !matches!( + layer.name, + ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags + ) { + continue; + } + + let Some(hooks_value) = layer.config.get("hooks") else { + continue; + }; + let hooks: HookEventsToml = match hooks_value.clone().try_into() { + Ok(hooks) => hooks, + Err(_) => { + continue; + } + }; + + for entry in hooks.config { + let Some(key) = hook_config_key(&entry) else { + continue; + }; + // Later layers win: an enabled entry removes a disabled override + // for the same key, while a disabled entry inserts it. + if entry.enabled { + disabled_keys.remove(&key); + } else { + disabled_keys.insert(key); + } + } + } + + HookConfigRules { disabled_keys } +} + +fn hook_config_key(entry: &HookConfig) -> Option { + let key = entry.key.as_deref().map(str::trim).unwrap_or_default(); + if key.is_empty() { + None + } else { + Some(key.to_string()) + } +} diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 2017160c681..0749e2e3326 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -19,6 +19,8 @@ use std::collections::HashMap; use super::ConfiguredHandler; use super::HookListEntry; +use crate::config_rules::HookConfigRules; +use crate::config_rules::hook_config_rules_from_stack; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; use codex_protocol::protocol::HookHandlerType; @@ -33,8 +35,10 @@ pub(crate) struct DiscoveryResult { #[derive(Clone)] struct HookHandlerSource<'a> { path: &'a AbsolutePathBuf, + key_prefix: String, is_managed: bool, source: HookSource, + hook_config_rules: &'a HookConfigRules, env: HashMap, plugin_id: Option, source_relative_path: Option, @@ -49,12 +53,14 @@ pub(crate) fn discover_handlers( let mut hook_entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0_i64; + let hook_config_rules = HookConfigRules::default(); append_plugin_hook_sources( &mut handlers, &mut hook_entries, &mut warnings, &mut display_order, plugin_hook_sources, + &hook_config_rules, ); return DiscoveryResult { handlers, @@ -67,6 +73,7 @@ pub(crate) fn discover_handlers( let mut hook_entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0_i64; + let hook_config_rules = hook_config_rules_from_stack(Some(config_layer_stack)); append_managed_requirement_handlers( &mut handlers, @@ -74,6 +81,7 @@ pub(crate) fn discover_handlers( &mut warnings, &mut display_order, config_layer_stack, + &hook_config_rules, ); for layer in config_layer_stack.get_layers( @@ -104,8 +112,10 @@ pub(crate) fn discover_handlers( &mut display_order, HookHandlerSource { path: &source_path, + key_prefix: format!("path:{}", source_path.display()), is_managed: false, source: hook_source, + hook_config_rules: &hook_config_rules, env: HashMap::new(), plugin_id: None, source_relative_path: None, @@ -122,8 +132,10 @@ pub(crate) fn discover_handlers( &mut display_order, HookHandlerSource { path: &source_path, + key_prefix: format!("path:{}", source_path.display()), is_managed: false, source: hook_source, + hook_config_rules: &hook_config_rules, env: HashMap::new(), plugin_id: None, source_relative_path: None, @@ -139,6 +151,7 @@ pub(crate) fn discover_handlers( &mut warnings, &mut display_order, plugin_hook_sources, + &hook_config_rules, ); DiscoveryResult { @@ -154,6 +167,7 @@ fn append_managed_requirement_handlers( warnings: &mut Vec, display_order: &mut i64, config_layer_stack: &ConfigLayerStack, + hook_config_rules: &HookConfigRules, ) { let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else { return; @@ -170,8 +184,10 @@ fn append_managed_requirement_handlers( display_order, HookHandlerSource { path: &source_path, + key_prefix: format!("path:{}", source_path.display()), is_managed: true, source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), + hook_config_rules, env: HashMap::new(), plugin_id: None, source_relative_path: None, @@ -186,6 +202,7 @@ fn append_plugin_hook_sources( warnings: &mut Vec, display_order: &mut i64, plugin_hook_sources: Vec, + hook_config_rules: &HookConfigRules, ) { // TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable. for source in plugin_hook_sources { @@ -209,8 +226,10 @@ fn append_plugin_hook_sources( display_order, HookHandlerSource { path: &source_path, + key_prefix: format!("plugin:{plugin_id}:{source_relative_path}"), is_managed: false, source: HookSource::Plugin, + hook_config_rules, env, plugin_id: Some(plugin_id), source_relative_path: Some(source_relative_path), @@ -386,7 +405,7 @@ fn append_matcher_groups( event_name: codex_protocol::protocol::HookEventName, groups: Vec, ) { - for group in groups { + for (group_index, group) in groups.into_iter().enumerate() { let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref()); if let Some(matcher) = matcher && let Err(err) = validate_matcher_pattern(matcher) @@ -398,7 +417,7 @@ fn append_matcher_groups( continue; } - for handler in group.hooks { + for (handler_index, handler) in group.hooks.into_iter().enumerate() { match handler { HookHandlerConfig::Command { command, @@ -421,7 +440,17 @@ fn append_matcher_groups( continue; } let timeout_sec = timeout_sec.unwrap_or(600).max(1); + // TODO(abhinav): replace this positional selector with a durable hook id. + let key = format!( + "{}:{}:{}:{}", + source.key_prefix, + hook_event_key_label(event_name), + group_index, + handler_index + ); + let enabled = source.is_managed || source.hook_config_rules.is_enabled(&key); hook_entries.push(HookListEntry { + key, event_name, handler_type: HookHandlerType::Command, matcher: matcher.map(ToOwned::to_owned), @@ -433,19 +462,22 @@ fn append_matcher_groups( plugin_id: source.plugin_id.clone(), source_relative_path: source.source_relative_path.clone(), display_order: *display_order, + enabled, }); - handlers.push(ConfiguredHandler { - event_name, - is_managed: source.is_managed, - matcher: matcher.map(ToOwned::to_owned), - command, - timeout_sec, - status_message, - source_path: source.path.clone(), - source: source.source, - display_order: *display_order, - env: source.env.clone(), - }); + if enabled { + handlers.push(ConfiguredHandler { + event_name, + is_managed: source.is_managed, + matcher: matcher.map(ToOwned::to_owned), + command, + timeout_sec, + status_message, + source_path: source.path.clone(), + source: source.source, + display_order: *display_order, + env: source.env.clone(), + }); + } *display_order += 1; } HookHandlerConfig::Prompt {} => warnings.push(format!( @@ -461,6 +493,17 @@ fn append_matcher_groups( } } +fn hook_event_key_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str { + match event_name { + codex_protocol::protocol::HookEventName::PreToolUse => "pre_tool_use", + codex_protocol::protocol::HookEventName::PermissionRequest => "permission_request", + codex_protocol::protocol::HookEventName::PostToolUse => "post_tool_use", + codex_protocol::protocol::HookEventName::SessionStart => "session_start", + codex_protocol::protocol::HookEventName::UserPromptSubmit => "user_prompt_submit", + codex_protocol::protocol::HookEventName::Stop => "stop", + } +} + fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource { match source { ConfigLayerSource::System { .. } => HookSource::System, @@ -503,6 +546,7 @@ mod tests { use super::ConfiguredHandler; use super::append_matcher_groups; + use crate::config_rules::HookConfigRules; use codex_config::HookHandlerConfig; use codex_config::MatcherGroup; @@ -514,11 +558,16 @@ mod tests { HookSource::User } - fn hook_handler_source(path: &AbsolutePathBuf) -> super::HookHandlerSource<'_> { + fn hook_handler_source<'a>( + path: &'a AbsolutePathBuf, + hook_config_rules: &'a HookConfigRules, + ) -> super::HookHandlerSource<'a> { super::HookHandlerSource { path, + key_prefix: format!("path:{}", path.display()), is_managed: false, source: hook_source(), + hook_config_rules, env: std::collections::HashMap::new(), plugin_id: None, source_relative_path: None, @@ -543,13 +592,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); + let hook_config_rules = HookConfigRules::default(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path), + hook_handler_source(&source_path, &hook_config_rules), HookEventName::UserPromptSubmit, vec![command_group(Some("["))], ); @@ -578,13 +628,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); + let hook_config_rules = HookConfigRules::default(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path), + hook_handler_source(&source_path, &hook_config_rules), HookEventName::PreToolUse, vec![command_group(Some("^Bash$"))], ); @@ -613,13 +664,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); + let hook_config_rules = HookConfigRules::default(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path), + hook_handler_source(&source_path, &hook_config_rules), HookEventName::PreToolUse, vec![command_group(Some("*"))], ); @@ -635,13 +687,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); + let hook_config_rules = HookConfigRules::default(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path), + hook_handler_source(&source_path, &hook_config_rules), HookEventName::PostToolUse, vec![command_group(Some("Edit|Write"))], ); diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 9af71c19fbc..6dfe8926aad 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -71,6 +71,7 @@ impl ConfiguredHandler { #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookListEntry { + pub key: String, pub event_name: HookEventName, pub handler_type: HookHandlerType, pub matcher: Option, @@ -82,6 +83,7 @@ pub struct HookListEntry { pub plugin_id: Option, pub source_relative_path: Option, pub display_order: i64, + pub enabled: bool, } #[derive(Clone)] diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 245c31ba5fd..6f46f723853 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -154,6 +154,116 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\"")); } +#[test] +fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { + let temp = tempdir().expect("create temp dir"); + let managed_dir = + AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path"); + fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); + let managed_hooks = managed_hooks_for_current_platform( + managed_dir.clone(), + HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "python3 /tmp/managed.py".to_string(), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + ..Default::default() + }, + ); + let config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path"); + let managed_disabled_key = format!("path:{}:pre_tool_use:0:0", managed_dir.display()); + let user_disabled_key = format!("path:{}:pre_tool_use:0:0", config_path.display()); + let mut user_config = TomlValue::Table(Default::default()); + let TomlValue::Table(user_config_entries) = &mut user_config else { + unreachable!("config TOML root should be a table"); + }; + let mut hooks = TomlValue::Table(Default::default()); + let TomlValue::Table(hooks_entries) = &mut hooks else { + unreachable!("hooks should be a table"); + }; + let mut managed_config = TomlValue::Table(Default::default()); + let TomlValue::Table(managed_config_entries) = &mut managed_config else { + unreachable!("hook config should be a table"); + }; + managed_config_entries.insert("key".to_string(), TomlValue::String(managed_disabled_key)); + managed_config_entries.insert("enabled".to_string(), TomlValue::Boolean(false)); + let mut user_hook_config = TomlValue::Table(Default::default()); + let TomlValue::Table(user_hook_config_entries) = &mut user_hook_config else { + unreachable!("hook config should be a table"); + }; + user_hook_config_entries.insert("key".to_string(), TomlValue::String(user_disabled_key)); + user_hook_config_entries.insert("enabled".to_string(), TomlValue::Boolean(false)); + hooks_entries.insert( + "config".to_string(), + TomlValue::Array(vec![managed_config, user_hook_config]), + ); + let mut user_hook_group = TomlValue::Table(Default::default()); + let TomlValue::Table(user_hook_group_entries) = &mut user_hook_group else { + unreachable!("user hook group should be a table"); + }; + user_hook_group_entries.insert( + "hooks".to_string(), + TomlValue::Array(vec![TomlValue::Table(Default::default())]), + ); + let Some(TomlValue::Array(user_hooks)) = user_hook_group_entries.get_mut("hooks") else { + unreachable!("user hooks should be an array"); + }; + let Some(TomlValue::Table(user_handler_entries)) = user_hooks.first_mut() else { + unreachable!("user hook handler should be a table"); + }; + user_handler_entries.insert("type".to_string(), TomlValue::String("command".to_string())); + user_handler_entries.insert( + "command".to_string(), + TomlValue::String("python3 /tmp/user.py".to_string()), + ); + hooks_entries.insert( + "PreToolUse".to_string(), + TomlValue::Array(vec![user_hook_group]), + ); + user_config_entries.insert("hooks".to_string(), hooks); + let config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { file: config_path }, + user_config, + )], + ConfigRequirements { + managed_hooks: Some(ConstrainedWithSource::new( + Constrained::allow_any(managed_hooks.clone()), + Some(RequirementSource::CloudRequirements), + )), + ..ConfigRequirements::default() + }, + ConfigRequirementsToml { + hooks: Some(managed_hooks), + ..ConfigRequirementsToml::default() + }, + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + Some(&config_layer_stack), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + assert_eq!(engine.handlers.len(), 1); + assert!(engine.handlers[0].is_managed); + let discovered = super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new()); + assert_eq!(discovered.hook_entries.len(), 2); + assert_eq!(discovered.hook_entries[0].enabled, true); + assert_eq!(discovered.hook_entries[1].enabled, false); +} + #[test] fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { let temp = tempdir().expect("create temp dir"); diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index e596b349f46..462a0126b76 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -1,3 +1,4 @@ +mod config_rules; mod engine; pub(crate) mod events; mod legacy_notify; From a6df152a9257e6a65050c39204908a5bcab3e88f Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 11:25:24 -0700 Subject: [PATCH 12/64] Simplify shared cwd config loading --- .../app-server/src/codex_message_processor.rs | 77 +++++++------------ 1 file changed, 27 insertions(+), 50 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3bee2b14f38..e0d88c30ee9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -253,6 +253,7 @@ use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config_loader::CloudRequirementsLoadError; use codex_core::config_loader::CloudRequirementsLoadErrorCode; +use codex_core::config_loader::ConfigLayerStack; use codex_core::config_loader::project_trust_key; use codex_core::exec::ExecCapturePolicy; use codex_core::exec::ExecExpiration; @@ -706,6 +707,21 @@ impl CodexMessageProcessor { .await } + async fn resolve_cwd_config( + &self, + cwd: &Path, + ) -> Result<(AbsolutePathBuf, ConfigLayerStack), String> { + let cwd_abs = + AbsolutePathBuf::relative_to_current_dir(cwd).map_err(|err| err.to_string())?; + let config_layer_stack = self + .config_manager + .load_config_layers_for_cwd(cwd_abs.clone()) + .await + .map_err(|err| err.to_string())?; + + Ok((cwd_abs, config_layer_stack)) + } + pub(crate) fn handle_config_mutation(&self) { self.clear_plugin_related_caches(); } @@ -6834,43 +6850,24 @@ impl CodexMessageProcessor { .map(|environment| environment.get_filesystem()); let mut data = Vec::new(); for cwd in cwds { - let extra_roots = extra_roots_by_cwd - .get(&cwd) - .map_or(&[][..], std::vec::Vec::as_slice); - let cwd_abs = match AbsolutePathBuf::relative_to_current_dir(cwd.as_path()) { - Ok(path) => path, - Err(err) => { - let error_path = cwd.clone(); - data.push(codex_app_server_protocol::SkillsListEntry { - cwd, - skills: Vec::new(), - errors: vec![codex_app_server_protocol::SkillErrorInfo { - path: error_path, - message: err.to_string(), - }], - }); - continue; - } - }; - let config_layer_stack = match self - .config_manager - .load_config_layers_for_cwd(cwd_abs.clone()) - .await - { - Ok(config_layer_stack) => config_layer_stack, - Err(err) => { + let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await { + Ok(resolved) => resolved, + Err(message) => { let error_path = cwd.clone(); data.push(codex_app_server_protocol::SkillsListEntry { cwd, skills: Vec::new(), errors: vec![codex_app_server_protocol::SkillErrorInfo { path: error_path, - message: err.to_string(), + message, }], }); continue; } }; + let extra_roots = extra_roots_by_cwd + .get(&cwd) + .map_or(&[][..], std::vec::Vec::as_slice); let effective_skill_roots = plugins_manager .effective_skill_roots_for_layer_stack( &config_layer_stack, @@ -6927,29 +6924,9 @@ impl CodexMessageProcessor { let plugins_manager = self.thread_manager.plugins_manager(); let mut data = Vec::new(); for cwd in cwds { - let cwd_abs = match AbsolutePathBuf::relative_to_current_dir(cwd.as_path()) { - Ok(path) => path, - Err(err) => { - let error_path = cwd.clone(); - data.push(codex_app_server_protocol::HooksListEntry { - cwd, - hooks: Vec::new(), - warnings: Vec::new(), - errors: vec![codex_app_server_protocol::HookErrorInfo { - path: error_path, - message: err.to_string(), - }], - }); - continue; - } - }; - let config_layer_stack = match self - .config_manager - .load_config_layers_for_cwd(cwd_abs.clone()) - .await - { - Ok(config_layer_stack) => config_layer_stack, - Err(err) => { + let (_, config_layer_stack) = match self.resolve_cwd_config(&cwd).await { + Ok(resolved) => resolved, + Err(message) => { let error_path = cwd.clone(); data.push(codex_app_server_protocol::HooksListEntry { cwd, @@ -6957,7 +6934,7 @@ impl CodexMessageProcessor { warnings: Vec::new(), errors: vec![codex_app_server_protocol::HookErrorInfo { path: error_path, - message: err.to_string(), + message, }], }); continue; From 44789f9daf1c43caf0a3960f9a5403ca3bf32b2c Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 14:57:41 -0700 Subject: [PATCH 13/64] Add hooks browser menu --- .../codex_app_server_protocol.schemas.json | 10 + .../codex_app_server_protocol.v2.schemas.json | 10 + .../schema/json/v2/HooksListResponse.json | 10 + .../schema/typescript/v2/HookMetadata.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 2 + codex-rs/app-server/README.md | 2 + .../app-server/src/codex_message_processor.rs | 2 + .../app-server/tests/suite/v2/hooks_list.rs | 1 + codex-rs/core-plugins/src/loader.rs | 23 +- codex-rs/core-plugins/src/loader_tests.rs | 2 +- codex-rs/hooks/src/engine/discovery.rs | 9 + codex-rs/hooks/src/engine/mod.rs | 2 + codex-rs/hooks/src/engine/mod_tests.rs | 24 +- codex-rs/plugin/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 4 +- codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/app/background_requests.rs | 57 ++ codex-rs/tui/src/app/event_dispatch.rs | 14 + codex-rs/tui/src/app_event.rs | 22 + .../tui/src/bottom_pane/hooks_browser_view.rs | 805 ++++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 2 + ...__tests__hooks_browser_empty_handlers.snap | 11 + ...ser_view__tests__hooks_browser_events.snap | 18 + ...r_view__tests__hooks_browser_handlers.snap | 19 + ..._tests__hooks_browser_managed_handler.snap | 18 + codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/hooks.rs | 41 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 4 + codex-rs/tui/src/slash_command.rs | 3 + 29 files changed, 1115 insertions(+), 7 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/hooks_browser_view.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_empty_handlers.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap create mode 100644 codex-rs/tui/src/chatwidget/hooks.rs 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 3f780790d69..392c8ca3f6e 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 @@ -9677,6 +9677,9 @@ "handlerType": { "$ref": "#/definitions/v2/HookHandlerType" }, + "isManaged": { + "type": "boolean" + }, "key": { "type": "string" }, @@ -9686,6 +9689,12 @@ "null" ] }, + "pluginDescription": { + "type": [ + "string", + "null" + ] + }, "pluginId": { "type": [ "string", @@ -9721,6 +9730,7 @@ "enabled", "eventName", "handlerType", + "isManaged", "key", "source", "sourcePath", 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 70c8f84bf7d..87075e66f3c 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 @@ -6307,6 +6307,9 @@ "handlerType": { "$ref": "#/definitions/HookHandlerType" }, + "isManaged": { + "type": "boolean" + }, "key": { "type": "string" }, @@ -6316,6 +6319,12 @@ "null" ] }, + "pluginDescription": { + "type": [ + "string", + "null" + ] + }, "pluginId": { "type": [ "string", @@ -6351,6 +6360,7 @@ "enabled", "eventName", "handlerType", + "isManaged", "key", "source", "sourcePath", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index c758455bc52..d3a1ce029b0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -60,6 +60,9 @@ "handlerType": { "$ref": "#/definitions/HookHandlerType" }, + "isManaged": { + "type": "boolean" + }, "key": { "type": "string" }, @@ -69,6 +72,12 @@ "null" ] }, + "pluginDescription": { + "type": [ + "string", + "null" + ] + }, "pluginId": { "type": [ "string", @@ -104,6 +113,7 @@ "enabled", "eventName", "handlerType", + "isManaged", "key", "source", "sourcePath", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index c56eb004ada..600f567f7c1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -6,4 +6,4 @@ import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; -export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, sourceRelativePath: string | null, displayOrder: bigint, enabled: boolean, }; +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, isManaged: boolean, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, pluginDescription: string | null, sourceRelativePath: string | null, displayOrder: bigint, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 93d6a2d529d..fbff9feac5d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4490,6 +4490,7 @@ pub struct HookMetadata { pub key: String, pub event_name: HookEventName, pub handler_type: HookHandlerType, + pub is_managed: bool, pub matcher: Option, pub command: Option, pub timeout_sec: u64, @@ -4497,6 +4498,7 @@ pub struct HookMetadata { pub source_path: AbsolutePathBuf, pub source: HookSource, pub plugin_id: Option, + pub plugin_description: Option, pub source_relative_path: Option, pub display_order: i64, pub enabled: bool, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e9b5587069c..e04a8bbe313 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1474,6 +1474,7 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "key": "path:/Users/me/.codex/config.toml:pre_tool_use:0:0", "eventName": "pre_tool_use", "handlerType": "command", + "isManaged": false, "matcher": "Bash", "command": "python3 /Users/me/hook.py", "timeoutSec": 5, @@ -1481,6 +1482,7 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "sourcePath": "/Users/me/.codex/config.toml", "source": "user", "pluginId": null, + "pluginDescription": null, "sourceRelativePath": null, "displayOrder": 0, "enabled": true diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 102ffdaa16f..f6b6b05453f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9516,6 +9516,7 @@ fn hooks_to_info(hooks: &[codex_core::hooks::HookListEntry]) -> Vec Vec Result<()> { assert_eq!(hook.timeout_sec, 5); assert_eq!(hook.status_message.as_deref(), Some("running listed hook")); assert_eq!(hook.source, HookSource::User); + assert!(!hook.is_managed); Ok(()) } diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 41217666ae9..02b814c8d83 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -26,6 +26,7 @@ use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_plugin::PluginLoadOutcome; use codex_plugin::PluginTelemetryMetadata; +use codex_plugin::prompt_safe_plugin_description; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -551,7 +552,12 @@ async fn load_plugin( } loaded_plugin.mcp_servers = mcp_servers; loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; - loaded_plugin.hook_sources = load_plugin_hooks(&plugin_root, &loaded_plugin_id, manifest_paths); + loaded_plugin.hook_sources = load_plugin_hooks( + &plugin_root, + &loaded_plugin_id, + manifest_paths, + prompt_safe_plugin_description(loaded_plugin.manifest_description.as_deref()), + ); loaded_plugin } @@ -688,12 +694,15 @@ pub fn load_plugin_hooks( plugin_root: &AbsolutePathBuf, plugin_id: &PluginId, manifest_paths: &PluginManifestPaths, + plugin_description: Option, ) -> Vec { let mut sources = Vec::new(); match &manifest_paths.hooks { Some(PluginManifestHooks::Paths(paths)) => { for path in paths { - if let Some(source) = load_plugin_hook_file(plugin_root, plugin_id, path) { + if let Some(source) = + load_plugin_hook_file(plugin_root, plugin_id, path, plugin_description.clone()) + { sources.push(source); } } @@ -711,6 +720,7 @@ pub fn load_plugin_hooks( plugin_root: plugin_root.clone(), source_path: manifest_path.clone(), source_relative_path: format!("plugin.json#hooks[{index}]"), + plugin_description: plugin_description.clone(), hooks: hooks_file.hooks.clone(), }); } @@ -718,7 +728,12 @@ pub fn load_plugin_hooks( None => { let default_path = plugin_root.join(DEFAULT_HOOKS_CONFIG_FILE); if default_path.as_path().is_file() - && let Some(source) = load_plugin_hook_file(plugin_root, plugin_id, &default_path) + && let Some(source) = load_plugin_hook_file( + plugin_root, + plugin_id, + &default_path, + plugin_description.clone(), + ) { sources.push(source); } @@ -733,6 +748,7 @@ fn load_plugin_hook_file( plugin_root: &AbsolutePathBuf, plugin_id: &PluginId, path: &AbsolutePathBuf, + plugin_description: Option, ) -> Option { let contents = match fs::read_to_string(path.as_path()) { Ok(contents) => contents, @@ -770,6 +786,7 @@ fn load_plugin_hook_file( plugin_root: plugin_root.clone(), source_path: path.clone(), source_relative_path, + plugin_description, hooks: parsed.hooks, }) } diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index 92ac4160693..6007f1abfcb 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -133,7 +133,7 @@ fn write_hook_file(plugin_root: &AbsolutePathBuf, relative_path: &str, event: &s fn load_sources(plugin_root: &AbsolutePathBuf) -> Vec { let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); - load_plugin_hooks(plugin_root, &plugin_id(), &manifest.paths) + load_plugin_hooks(plugin_root, &plugin_id(), &manifest.paths, None) } #[test] diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 0749e2e3326..f2b0bcec40d 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -41,6 +41,7 @@ struct HookHandlerSource<'a> { hook_config_rules: &'a HookConfigRules, env: HashMap, plugin_id: Option, + plugin_description: Option, source_relative_path: Option, } @@ -118,6 +119,7 @@ pub(crate) fn discover_handlers( hook_config_rules: &hook_config_rules, env: HashMap::new(), plugin_id: None, + plugin_description: None, source_relative_path: None, }, hook_events, @@ -138,6 +140,7 @@ pub(crate) fn discover_handlers( hook_config_rules: &hook_config_rules, env: HashMap::new(), plugin_id: None, + plugin_description: None, source_relative_path: None, }, hook_events, @@ -190,6 +193,7 @@ fn append_managed_requirement_handlers( hook_config_rules, env: HashMap::new(), plugin_id: None, + plugin_description: None, source_relative_path: None, }, managed_hooks.get().hooks.clone(), @@ -211,6 +215,7 @@ fn append_plugin_hook_sources( plugin_id, source_path, source_relative_path, + plugin_description, hooks, } = source; let mut env = HashMap::new(); @@ -232,6 +237,7 @@ fn append_plugin_hook_sources( hook_config_rules, env, plugin_id: Some(plugin_id), + plugin_description, source_relative_path: Some(source_relative_path), }, hooks, @@ -453,6 +459,7 @@ fn append_matcher_groups( key, event_name, handler_type: HookHandlerType::Command, + is_managed: source.is_managed, matcher: matcher.map(ToOwned::to_owned), command: Some(command.clone()), timeout_sec, @@ -460,6 +467,7 @@ fn append_matcher_groups( source_path: source.path.clone(), source: source.source, plugin_id: source.plugin_id.clone(), + plugin_description: source.plugin_description.clone(), source_relative_path: source.source_relative_path.clone(), display_order: *display_order, enabled, @@ -570,6 +578,7 @@ mod tests { hook_config_rules, env: std::collections::HashMap::new(), plugin_id: None, + plugin_description: None, source_relative_path: None, } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 6dfe8926aad..708ed520097 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -74,6 +74,7 @@ pub struct HookListEntry { pub key: String, pub event_name: HookEventName, pub handler_type: HookHandlerType, + pub is_managed: bool, pub matcher: Option, pub command: Option, pub timeout_sec: u64, @@ -81,6 +82,7 @@ pub struct HookListEntry { pub source_path: AbsolutePathBuf, pub source: HookSource, pub plugin_id: Option, + pub plugin_description: Option, pub source_relative_path: Option, pub display_order: i64, pub enabled: bool, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 6f46f723853..846638037ba 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -118,6 +118,15 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(engine.warnings().is_empty()); assert_eq!(engine.handlers.len(), 1); assert!(engine.handlers[0].is_managed); + let listed = crate::list_hooks(crate::HooksConfig { + legacy_notify_argv: None, + feature_enabled: true, + config_layer_stack: Some(config_layer_stack.clone()), + plugin_hook_sources: Vec::new(), + shell_program: None, + shell_args: Vec::new(), + }); + assert!(listed.hooks[0].is_managed); let cwd = cwd(); let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), @@ -473,6 +482,7 @@ Path(r"{log_path}").write_text(json.dumps({{ plugin_root: plugin_root.clone(), source_path: source_path.clone(), source_relative_path: "hooks/hooks.json".to_string(), + plugin_description: Some("Demo plugin hook support.".to_string()), hooks: HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("Bash".to_string()), @@ -489,7 +499,7 @@ Path(r"{log_path}").write_text(json.dumps({{ let engine = ClaudeHooksEngine::new( /*enabled*/ true, /*config_layer_stack*/ None, - plugin_hook_sources, + plugin_hook_sources.clone(), CommandShell { program: String::new(), args: Vec::new(), @@ -511,6 +521,18 @@ Path(r"{log_path}").write_text(json.dumps({{ assert_eq!(preview.len(), 1); assert_eq!(preview[0].source, HookSource::Plugin); assert_eq!(preview[0].source_path, source_path); + let listed = crate::list_hooks(crate::HooksConfig { + legacy_notify_argv: None, + feature_enabled: true, + config_layer_stack: None, + plugin_hook_sources, + shell_program: None, + shell_args: Vec::new(), + }); + assert_eq!( + listed.hooks[0].plugin_description.as_deref(), + Some("Demo plugin hook support.") + ); let outcome = engine .run_pre_tool_use(PreToolUseRequest { diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 31ecf560152..70317ce199f 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -35,6 +35,7 @@ pub struct PluginHookSource { pub plugin_root: AbsolutePathBuf, pub source_path: AbsolutePathBuf, pub source_relative_path: String, + pub plugin_description: Option, pub hooks: HookEventsToml, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c80b15be986..ea0fc985a9a 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -13,6 +13,8 @@ use std::path::PathBuf; use std::str::FromStr; use std::time::Duration; +use strum_macros::EnumIter; + use crate::AgentPath; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; @@ -1604,7 +1606,7 @@ pub enum EventMsg { CollabResumeEnd(CollabResumeEndEvent), } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, EnumIter)] #[serde(rename_all = "snake_case")] pub enum HookEventName { PreToolUse, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 77c1f52775c..d25d505de01 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -88,6 +88,9 @@ use codex_app_server_protocol::ConfigWriteResponse; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::HooksConfigWriteParams; +use codex_app_server_protocol::HooksListParams; +use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatus; diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 80879dbd79a..87c6acb9fc7 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -89,6 +89,17 @@ impl App { }); } + pub(super) fn fetch_hooks_list(&mut self, app_server: &AppServerSession, cwd: PathBuf) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = fetch_hooks_list(request_handle, cwd.clone()) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::HooksLoaded { cwd, result }); + }); + } + pub(super) fn fetch_plugin_detail( &mut self, app_server: &AppServerSession, @@ -198,6 +209,23 @@ impl App { }); } + pub(super) fn set_hook_enabled( + &mut self, + app_server: &AppServerSession, + key: String, + enabled: bool, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = write_hook_enabled(request_handle, key, enabled) + .await + .map(|_| ()) + .map_err(|err| format!("Failed to update hook config: {err}")); + app_event_tx.send(AppEvent::HookEnabledSet { result }); + }); + } + pub(super) fn refresh_plugin_mentions(&mut self) { let config = self.config.clone(); let app_event_tx = self.app_event_tx.clone(); @@ -490,6 +518,20 @@ pub(super) async fn fetch_plugins_list( Ok(response) } +pub(super) async fn fetch_hooks_list( + request_handle: AppServerRequestHandle, + cwd: PathBuf, +) -> Result { + let request_id = RequestId::String(format!("hooks-list-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::HooksList { + request_id, + params: HooksListParams { cwds: vec![cwd] }, + }) + .await + .wrap_err("hooks/list failed in TUI") +} + const CLI_HIDDEN_PLUGIN_MARKETPLACES: &[&str] = &["openai-bundled"]; pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListResponse) { @@ -563,6 +605,21 @@ pub(super) async fn write_plugin_enabled( .wrap_err("config/value/write failed while updating plugin enablement in TUI") } +pub(super) async fn write_hook_enabled( + request_handle: AppServerRequestHandle, + key: String, + enabled: bool, +) -> Result { + let request_id = RequestId::String(format!("hooks-config-write-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::HooksConfigWrite { + request_id, + params: HooksConfigWriteParams { key, enabled }, + }) + .await + .wrap_err("hooks/config/write failed while updating hook enablement in TUI") +} + pub(super) fn build_feedback_upload_params( origin_thread_id: Option, rollout_path: Option, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 7e096c6b927..2a6e26437c8 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -380,6 +380,9 @@ impl App { AppEvent::FetchPluginsList { cwd } => { self.fetch_plugins_list(app_server, cwd); } + AppEvent::FetchHooksList { cwd } => { + self.fetch_hooks_list(app_server, cwd); + } AppEvent::OpenPluginDetailLoading { plugin_display_name, } => { @@ -401,6 +404,9 @@ impl App { AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } + AppEvent::HooksLoaded { cwd, result } => { + self.chat_widget.on_hooks_loaded(cwd, result); + } AppEvent::FetchPluginDetail { cwd, params } => { self.fetch_plugin_detail(app_server, cwd, params); } @@ -1548,6 +1554,14 @@ impl App { } } } + AppEvent::SetHookEnabled { key, enabled } => { + self.set_hook_enabled(app_server, key, enabled); + } + AppEvent::HookEnabledSet { result } => { + if let Err(err) = result { + self.chat_widget.add_error_message(err); + } + } AppEvent::OpenPermissionsPopup => { self.chat_widget.open_permissions_popup(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 7df90020e04..d324c27f091 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -268,12 +268,23 @@ pub(crate) enum AppEvent { cwd: PathBuf, }, + /// Fetch lifecycle hook inventory for the provided working directory. + FetchHooksList { + cwd: PathBuf, + }, + /// Result of fetching plugin marketplace state. PluginsLoaded { cwd: PathBuf, result: Result, }, + /// Result of fetching lifecycle hook inventory. + HooksLoaded { + cwd: PathBuf, + result: Result, + }, + /// Replace the plugins popup with a plugin-detail loading state. OpenPluginDetailLoading { plugin_display_name: String, @@ -645,6 +656,17 @@ pub(crate) enum AppEvent { enabled: bool, }, + /// Enable or disable a hook by stable hook key. + SetHookEnabled { + key: String, + enabled: bool, + }, + + /// Result of persisting hook enabled state. + HookEnabledSet { + result: Result<(), String>, + }, + /// Notify that the manage skills popup was closed. ManageSkillsClosed, diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs new file mode 100644 index 00000000000..d9ddba14d3c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -0,0 +1,805 @@ +use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::HookMetadata; +use codex_app_server_protocol::HookSource; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use strum::IntoEnumIterator; +use unicode_width::UnicodeWidthStr; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::render_menu_surface; +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::renderable::Renderable; + +const EVENT_COLUMN_WIDTH: usize = 22; +const COUNT_COLUMN_WIDTH: usize = 12; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HooksBrowserPage { + Events, + Handlers(HookEventName), +} + +pub(crate) struct HooksBrowserView { + hooks: Vec, + page: HooksBrowserPage, + state: ScrollState, + complete: bool, + app_event_tx: AppEventSender, +} + +impl HooksBrowserView { + pub(crate) fn new(mut hooks: Vec, app_event_tx: AppEventSender) -> Self { + hooks.sort_by_key(|hook| hook.display_order); + let mut view = Self { + hooks, + page: HooksBrowserPage::Events, + state: ScrollState::new(), + complete: false, + app_event_tx, + }; + if view.page_len() > 0 { + view.state.selected_idx = Some(0); + } + view + } + + fn event_rows(&self) -> Vec { + codex_protocol::protocol::HookEventName::iter() + .map(|event_name| { + let event_name: HookEventName = event_name.into(); + let installed = self + .hooks + .iter() + .filter(|hook| hook.event_name == event_name) + .count(); + let active = self + .hooks + .iter() + .filter(|hook| hook.event_name == event_name && hook.enabled) + .count(); + EventRow { + event_name, + installed, + active, + } + }) + .collect() + } + + fn handlers_for_event(&self, event_name: HookEventName) -> Vec<&HookMetadata> { + self.hooks + .iter() + .filter(|hook| hook.event_name == event_name) + .collect() + } + + fn selected_event(&self) -> Option { + self.state + .selected_idx + .and_then(|idx| self.event_rows().get(idx).map(|row| row.event_name)) + } + + fn selected_hook_index(&self, event_name: HookEventName) -> Option { + let selected_visible_idx = self.state.selected_idx?; + self.hooks + .iter() + .enumerate() + .filter(|(_, hook)| hook.event_name == event_name) + .nth(selected_visible_idx) + .map(|(idx, _)| idx) + } + + fn selected_hook(&self, event_name: HookEventName) -> Option<&HookMetadata> { + self.selected_hook_index(event_name) + .and_then(|idx| self.hooks.get(idx)) + } + + fn move_up(&mut self) { + let len = self.page_len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, self.max_visible_rows()); + } + + fn move_down(&mut self) { + let len = self.page_len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, self.max_visible_rows()); + } + + fn page_len(&self) -> usize { + match self.page { + HooksBrowserPage::Events => self.event_rows().len(), + HooksBrowserPage::Handlers(event_name) => self.handlers_for_event(event_name).len(), + } + } + + fn max_visible_rows(&self) -> usize { + MAX_POPUP_ROWS.min(self.page_len().max(1)) + } + + fn open_selected_event(&mut self) { + let Some(event_name) = self.selected_event() else { + return; + }; + self.page = HooksBrowserPage::Handlers(event_name); + self.state = ScrollState::new(); + if self.page_len() > 0 { + self.state.selected_idx = Some(0); + } + } + + fn toggle_selected_hook(&mut self, event_name: HookEventName) { + let Some(idx) = self.selected_hook_index(event_name) else { + return; + }; + let Some(hook) = self.hooks.get_mut(idx) else { + return; + }; + if hook.is_managed { + return; + } + + hook.enabled = !hook.enabled; + self.app_event_tx.send(AppEvent::SetHookEnabled { + key: hook.key.clone(), + enabled: hook.enabled, + }); + } + + fn close(&mut self) { + self.complete = true; + } + + fn return_to_events(&mut self) { + let selected_event_name = match self.page { + HooksBrowserPage::Events => None, + HooksBrowserPage::Handlers(event_name) => Some(event_name), + }; + self.page = HooksBrowserPage::Events; + self.state = ScrollState::new(); + self.state.selected_idx = selected_event_name + .and_then(|event_name| { + self.event_rows() + .iter() + .position(|row| row.event_name == event_name) + }) + .or_else(|| (self.page_len() > 0).then_some(0)); + } + + fn event_header_lines() -> Vec> { + vec![ + "Hooks".bold().into(), + "Lifecycle hooks from config and enabled plugins." + .dim() + .into(), + ] + } + + fn handler_header_lines(&self, event_name: HookEventName) -> Vec> { + vec![ + format!("{} hooks", event_label(event_name)).bold().into(), + "Turn hooks on or off. Your changes are saved automatically." + .dim() + .into(), + ] + } + + fn event_table_lines(&self) -> Vec> { + let mut lines = Vec::new(); + lines.push(Line::from(vec![ + format!("{: Vec> { + self.handlers_for_event(event_name) + .into_iter() + .enumerate() + .map(|(idx, hook)| { + let marker = if hook.enabled { 'x' } else { ' ' }; + let row = if hook.is_managed { + format!("[x] Admin managed {}", summary_source(hook, idx)) + } else { + format!("[{marker}] {}", summary_source(hook, idx)) + }; + let mut line = Line::from(row); + line = truncate_line_with_ellipsis_if_overflow(line, width); + if hook.is_managed { + line = line.dim(); + } + if self.state.selected_idx == Some(idx) && !hook.is_managed { + line = line.cyan().bold(); + } + line + }) + .collect() + } + + fn detail_lines(&self, event_name: HookEventName, width: usize) -> Vec> { + let Some(hook) = self.selected_hook(event_name) else { + return vec!["No hooks installed for this event.".dim().into()]; + }; + + let mut lines = vec![detail_line("Event", event_label(event_name))]; + if let Some(matcher) = hook.matcher.as_deref() { + lines.extend(detail_wrapped_lines("Matcher", matcher, width)); + } + lines.extend(detail_wrapped_lines("Source", &detail_source(hook), width)); + lines.extend(detail_wrapped_lines( + "File", + &hook.source_path.display().to_string(), + width, + )); + lines.extend(detail_wrapped_lines( + "Command", + hook.command.as_deref().unwrap_or("-"), + width, + )); + lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec))); + lines + } + + fn render_footer(&self, area: Rect, buf: &mut Buffer) { + let hint_area = Rect { + x: area.x + 2, + y: area.y, + width: area.width.saturating_sub(2), + height: area.height, + }; + let footer = match self.page { + HooksBrowserPage::Events => Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to view hooks; ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to close".into(), + ]), + HooksBrowserPage::Handlers(event_name) => { + if self.selected_hook(event_name).is_none() { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) + } else if self + .selected_hook(event_name) + .is_some_and(|hook| hook.is_managed) + { + Line::from(vec![ + "Admin managed; press ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) + } else { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Char(' ')).into(), + " or ".into(), + key_hint::plain(KeyCode::Enter).into(), + " to toggle; ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to go back".into(), + ]) + } + } + }; + footer.dim().render(hint_area, buf); + } +} + +impl BottomPaneView for HooksBrowserView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.move_up(), + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => self.move_down(), + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } if self.page == HooksBrowserPage::Events => self.open_selected_event(), + KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + if let HooksBrowserPage::Handlers(event_name) = self.page { + self.toggle_selected_hook(event_name); + } + } + KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + .. + } => { + if let HooksBrowserPage::Handlers(event_name) = self.page { + self.toggle_selected_hook(event_name); + } + } + KeyEvent { + code: KeyCode::Esc, .. + } => match self.page { + HooksBrowserPage::Events => self.close(), + HooksBrowserPage::Handlers(_) => self.return_to_events(), + }, + _ => {} + } + } + + fn is_complete(&self) -> bool { + self.complete + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.close(); + CancellationEvent::Handled + } + + fn prefer_esc_to_handle_key_event(&self) -> bool { + true + } +} + +impl Renderable for HooksBrowserView { + fn desired_height(&self, width: u16) -> u16 { + let content_width = width.saturating_sub(4) as usize; + let height = match self.page { + HooksBrowserPage::Events => { + Self::event_header_lines().len() + 2 + self.event_table_lines().len() + } + HooksBrowserPage::Handlers(event_name) => { + let row_count = self.handler_row_lines(event_name, content_width).len(); + if row_count == 0 { + self.handler_header_lines(event_name).len() + 2 + } else { + self.handler_header_lines(event_name).len() + + 1 + + row_count + + 1 + + self.detail_lines(event_name, content_width).len() + } + } + }; + (height + 3).try_into().unwrap_or(u16::MAX) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + let content_area = render_menu_surface(content_area, buf); + let width = content_area.width as usize; + let mut lines = match self.page { + HooksBrowserPage::Events => { + let mut lines = Self::event_header_lines(); + lines.push(Line::default()); + lines.extend(self.event_table_lines()); + lines + } + HooksBrowserPage::Handlers(event_name) => { + let mut lines = self.handler_header_lines(event_name); + let rows = self.handler_row_lines(event_name, width); + if rows.is_empty() { + lines.push(Line::default()); + lines.push(Line::from( + "No hooks installed for this event.".dim().italic(), + )); + lines.push(Line::default()); + Paragraph::new(lines).render(content_area, buf); + self.render_footer(footer_area, buf); + return; + } + let list_height = rows.len().clamp(1, MAX_POPUP_ROWS) as u16; + lines.push(Line::default()); + let header_height = lines.len() as u16; + let [header_area, list_area, detail_area] = Layout::vertical([ + Constraint::Length(header_height), + Constraint::Length(list_height), + Constraint::Fill(1), + ]) + .areas(content_area); + Paragraph::new(lines.clone()).render(header_area, buf); + Paragraph::new(rows).render(list_area, buf); + let mut detail_lines = vec![Line::default()]; + detail_lines.extend(self.detail_lines(event_name, width)); + Paragraph::new(detail_lines).render(detail_area, buf); + self.render_footer(footer_area, buf); + return; + } + }; + lines.push(Line::default()); + Paragraph::new(lines).render(content_area, buf); + self.render_footer(footer_area, buf); + } +} + +struct EventRow { + event_name: HookEventName, + installed: usize, + active: usize, +} + +fn event_label(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "PreToolUse", + HookEventName::PermissionRequest => "PermissionRequest", + HookEventName::PostToolUse => "PostToolUse", + HookEventName::SessionStart => "SessionStart", + HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::Stop => "Stop", + } +} + +fn event_description(event_name: HookEventName) -> &'static str { + match event_name { + HookEventName::PreToolUse => "Before a tool executes", + HookEventName::PermissionRequest => "When permission is requested", + HookEventName::PostToolUse => "After a tool executes", + HookEventName::SessionStart => "When a new session starts", + HookEventName::UserPromptSubmit => "When the user submits a prompt", + HookEventName::Stop => "Before Codex concludes a response", + } +} + +fn summary_source(hook: &HookMetadata, idx: usize) -> String { + let hook_label = hook + .status_message + .as_deref() + .filter(|message| !message.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("Hook {}", idx + 1)); + match hook.source { + HookSource::Plugin => format!( + "{hook_label} - {}", + hook.plugin_id.as_deref().unwrap_or("unknown plugin") + ), + _ => format!("{hook_label} - [Config]"), + } +} + +fn detail_source(hook: &HookMetadata) -> String { + match hook.source { + HookSource::Plugin => format!( + "[Plugin] {}", + hook.plugin_id.as_deref().unwrap_or("unknown plugin") + ), + _ => format!("[Config] {}", hook.source_path.display()), + } +} + +fn detail_line(label: &str, value: &str) -> Line<'static> { + Line::from(vec![format!("{label:<10}").into(), value.to_string().dim()]) +} + +fn detail_wrapped_lines(label: &str, value: &str, width: usize) -> Vec> { + let prefix = format!("{label:<10}"); + let available = width.saturating_sub(prefix.width()).max(1); + let mut wrapped = textwrap::wrap(value, available).into_iter(); + let first = wrapped.next().unwrap_or_default().into_owned(); + let mut lines = vec![Line::from(vec![prefix.into(), first.dim()])]; + lines + .extend(wrapped.map(|line| Line::from(vec![" ".into(), line.into_owned().dim()]))); + lines +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::bottom_pane_view::BottomPaneView; + use crate::render::renderable::Renderable; + use crate::test_support::PathBufExt; + use crate::test_support::test_path_buf; + use codex_app_server_protocol::HookEventName; + use codex_app_server_protocol::HookHandlerType; + use codex_app_server_protocol::HookMetadata; + use codex_app_server_protocol::HookSource; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + fn render_lines(view: &HooksBrowserView, width: u16) -> String { + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + (0..area.height) + .map(|row| { + (0..area.width) + .map(|col| { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + " ".to_string() + } else { + symbol.to_string() + } + }) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[allow(clippy::too_many_arguments)] + fn hook( + key: &str, + event_name: HookEventName, + source: HookSource, + plugin_id: Option<&str>, + plugin_description: Option<&str>, + command: &str, + enabled: bool, + is_managed: bool, + display_order: i64, + ) -> HookMetadata { + HookMetadata { + key: key.to_string(), + event_name, + handler_type: HookHandlerType::Command, + is_managed, + matcher: Some("Bash".to_string()), + command: Some(command.to_string()), + timeout_sec: 30, + status_message: None, + source_path: test_path_buf("/tmp/hooks.json").abs(), + source, + plugin_id: plugin_id.map(str::to_string), + plugin_description: plugin_description.map(str::to_string), + source_relative_path: None, + display_order, + enabled, + } + } + + fn view() -> HooksBrowserView { + let (tx_raw, _rx) = unbounded_channel::(); + HooksBrowserView::new( + vec![ + hook( + "plugin:superpowers", + HookEventName::PreToolUse, + HookSource::Plugin, + Some("superpowers@openai-curated"), + Some( + "An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows.", + ), + "${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ), + hook( + "path:user-config", + HookEventName::PreToolUse, + HookSource::User, + None, + None, + "~/bin/check-shell-with-a-command-that-is-way-too-long-for-the-summary-column.sh", + /*enabled*/ false, + /*is_managed*/ false, + /*display_order*/ 1, + ), + hook( + "path:managed", + HookEventName::PermissionRequest, + HookSource::System, + None, + None, + "/enterprise/hooks/permission-check.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 2, + ), + ], + AppEventSender::new(tx_raw), + ) + } + + #[test] + fn renders_event_browser() { + let view = view(); + assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112)); + } + + #[test] + fn renders_handler_browser_with_details() { + let mut view = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!("hooks_browser_handlers", render_lines(&view, /*width*/ 112)); + } + + #[test] + fn renders_managed_handler_without_toggle_hint() { + let mut view = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!( + "hooks_browser_managed_handler", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_empty_handler_browser_message() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new(Vec::new(), AppEventSender::new(tx_raw)); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!( + "hooks_browser_empty_handlers", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn space_toggles_unmanaged_handler() { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![hook( + "plugin:superpowers", + HookEventName::PreToolUse, + HookSource::Plugin, + Some("superpowers@openai-curated"), + None, + "hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + )], + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Char(' '))); + + match rx.try_recv().expect("toggle event") { + AppEvent::SetHookEnabled { key, enabled } => { + assert_eq!(key, "plugin:superpowers"); + assert!(!enabled); + } + other => panic!("expected hook toggle event, got {other:?}"), + } + } + + #[test] + fn enter_toggles_unmanaged_handler() { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![hook( + "plugin:superpowers", + HookEventName::PreToolUse, + HookSource::Plugin, + Some("superpowers@openai-curated"), + None, + "hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + )], + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv().expect("toggle event") { + AppEvent::SetHookEnabled { key, enabled } => { + assert_eq!(key, "plugin:superpowers"); + assert!(!enabled); + } + other => panic!("expected hook toggle event, got {other:?}"), + } + } + + #[test] + fn space_does_not_toggle_managed_handler() { + let (tx_raw, mut rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![hook( + "path:managed", + HookEventName::PreToolUse, + HookSource::System, + None, + None, + "/enterprise/hooks/pre-tool-use-check.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 0, + )], + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Char(' '))); + + assert!(rx.try_recv().is_err()); + } + + #[test] + fn escape_returns_to_the_selected_event() { + let mut view = view(); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Esc)); + + assert_eq!(view.page, HooksBrowserPage::Events); + assert_eq!( + view.selected_event(), + Some(HookEventName::PermissionRequest) + ); + } + + #[test] + fn esc_routes_through_the_view() { + assert!(view().prefer_esc_to_handle_key_event()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a2067fb7e7a..28d415ef177 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -102,6 +102,7 @@ pub(crate) use list_selection_view::popup_content_width; pub(crate) use list_selection_view::side_by_side_layout_widths; pub(crate) use memories_settings_view::MemoriesSettingsView; mod feedback_view; +mod hooks_browser_view; pub(crate) use feedback_view::FeedbackAudience; pub(crate) use feedback_view::feedback_classification; pub(crate) use feedback_view::feedback_disabled_params; @@ -128,6 +129,7 @@ mod selection_tabs; mod textarea; mod unified_exec_footer; pub(crate) use feedback_view::FeedbackNoteView; +pub(crate) use hooks_browser_view::HooksBrowserView; pub(crate) use selection_tabs::SelectionTab; /// How long the "press again to quit" hint stays visible. diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_empty_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_empty_handlers.snap new file mode 100644 index 00000000000..33321eec2be --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_empty_handlers.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PermissionRequest hooks + Turn hooks on or off. Your changes are saved automatically. + + No hooks installed for this event. + + Press esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap new file mode 100644 index 00000000000..d5e421a623c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + Hooks + Lifecycle hooks from config and enabled plugins. + + Event Installed Active Description + PreToolUse 2 1 Before a tool executes + PermissionRequest 1 1 When permission is requested + PostToolUse 0 0 After a tool executes + SessionStart 0 0 When a new session starts + UserPromptSubmit 0 0 When the user submits a prompt + Stop 0 0 Before Codex concludes a response + + + Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap new file mode 100644 index 00000000000..2e9b9048840 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 1 - superpowers@openai-curated + [ ] Hook 2 - [Config] + + Event PreToolUse + Matcher Bash + Source [Plugin] superpowers@openai-curated + File /tmp/hooks.json + Command ${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh + Timeout 30s + + Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap new file mode 100644 index 00000000000..ad9aaf58f3c --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PermissionRequest hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Admin managed Hook 1 - [Config] + + Event PermissionRequest + Matcher Bash + Source [Config] /tmp/hooks.json + File /tmp/hooks.json + Command /enterprise/hooks/permission-check.sh + Timeout 30s + + Admin managed; press esc to go back diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6d2450ecea0..8d430c5c70c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -381,6 +381,7 @@ mod interrupts; use self::interrupts::InterruptManager; mod session_header; use self::session_header::SessionHeader; +mod hooks; mod skills; mod slash_dispatch; use self::skills::collect_tool_mentions; diff --git a/codex-rs/tui/src/chatwidget/hooks.rs b/codex-rs/tui/src/chatwidget/hooks.rs new file mode 100644 index 00000000000..48902f1463e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/hooks.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +use super::ChatWidget; +use crate::app_event::AppEvent; +use crate::bottom_pane::HooksBrowserView; +use codex_app_server_protocol::HooksListResponse; + +impl ChatWidget { + pub(crate) fn add_hooks_output(&mut self) { + self.app_event_tx.send(AppEvent::FetchHooksList { + cwd: self.config.cwd.to_path_buf(), + }); + } + + pub(crate) fn on_hooks_loaded( + &mut self, + cwd: PathBuf, + result: Result, + ) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + match result { + Ok(response) => { + let hooks = response + .data + .into_iter() + .find(|entry| entry.cwd.as_path() == cwd.as_path()) + .map(|entry| entry.hooks) + .unwrap_or_default(); + self.bottom_pane.show_view(Box::new(HooksBrowserView::new( + hooks, + self.app_event_tx.clone(), + ))); + self.request_redraw(); + } + Err(err) => self.add_error_message(format!("Failed to load hooks: {err}")), + } + } +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index bbc23b9309f..3810b5c39d2 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -339,6 +339,9 @@ impl ChatWidget { SlashCommand::Skills => { self.open_skills_menu(); } + SlashCommand::Hooks => { + self.add_hooks_output(); + } SlashCommand::Status => { if self.should_prefetch_rate_limits() { let request_id = self.next_status_refresh_request_id; @@ -870,6 +873,7 @@ impl ChatWidget { | SlashCommand::Logout | SlashCommand::Mention | SlashCommand::Skills + | SlashCommand::Hooks | SlashCommand::Title | SlashCommand::Statusline | SlashCommand::Theme => QueueDrain::Stop, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 28df384b021..47b918e8adf 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -23,6 +23,7 @@ pub enum SlashCommand { Experimental, Memories, Skills, + Hooks, Review, Rename, New, @@ -88,6 +89,7 @@ impl SlashCommand { SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", + SlashCommand::Hooks => "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", @@ -185,6 +187,7 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Mention | SlashCommand::Skills + | SlashCommand::Hooks | SlashCommand::Status | SlashCommand::DebugConfig | SlashCommand::Ps From 7bf49865f249fd8b84ae897f75ea4604fa254384 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 15:10:33 -0700 Subject: [PATCH 14/64] Clean up hooks browser metadata --- .../codex_app_server_protocol.schemas.json | 6 ----- .../codex_app_server_protocol.v2.schemas.json | 6 ----- .../schema/json/v2/HooksListResponse.json | 6 ----- .../schema/typescript/v2/HookMetadata.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 1 - codex-rs/app-server/README.md | 1 - .../app-server/src/codex_message_processor.rs | 1 - codex-rs/core-plugins/src/loader.rs | 23 +++---------------- codex-rs/core-plugins/src/loader_tests.rs | 2 +- codex-rs/hooks/src/engine/discovery.rs | 8 ------- codex-rs/hooks/src/engine/mod.rs | 1 - codex-rs/hooks/src/engine/mod_tests.rs | 5 ++-- codex-rs/plugin/src/lib.rs | 1 - .../tui/src/bottom_pane/hooks_browser_view.rs | 15 ++---------- ...ser_view__tests__hooks_browser_events.snap | 1 - 15 files changed, 9 insertions(+), 70 deletions(-) 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 392c8ca3f6e..63188c00870 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 @@ -9689,12 +9689,6 @@ "null" ] }, - "pluginDescription": { - "type": [ - "string", - "null" - ] - }, "pluginId": { "type": [ "string", 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 87075e66f3c..db686050000 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 @@ -6319,12 +6319,6 @@ "null" ] }, - "pluginDescription": { - "type": [ - "string", - "null" - ] - }, "pluginId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index d3a1ce029b0..683d23dbdf1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -72,12 +72,6 @@ "null" ] }, - "pluginDescription": { - "type": [ - "string", - "null" - ] - }, "pluginId": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index 600f567f7c1..7bd969f788e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -6,4 +6,4 @@ import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; -export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, isManaged: boolean, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, pluginDescription: string | null, sourceRelativePath: string | null, displayOrder: bigint, enabled: boolean, }; +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, isManaged: boolean, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, sourceRelativePath: string | null, displayOrder: bigint, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index fbff9feac5d..8e4883c4476 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4498,7 +4498,6 @@ pub struct HookMetadata { pub source_path: AbsolutePathBuf, pub source: HookSource, pub plugin_id: Option, - pub plugin_description: Option, pub source_relative_path: Option, pub display_order: i64, pub enabled: bool, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e04a8bbe313..8481c5b88d5 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1482,7 +1482,6 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "sourcePath": "/Users/me/.codex/config.toml", "source": "user", "pluginId": null, - "pluginDescription": null, "sourceRelativePath": null, "displayOrder": 0, "enabled": true diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f6b6b05453f..827b450b539 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9524,7 +9524,6 @@ fn hooks_to_info(hooks: &[codex_core::hooks::HookListEntry]) -> Vec, ) -> Vec { let mut sources = Vec::new(); match &manifest_paths.hooks { Some(PluginManifestHooks::Paths(paths)) => { for path in paths { - if let Some(source) = - load_plugin_hook_file(plugin_root, plugin_id, path, plugin_description.clone()) - { + if let Some(source) = load_plugin_hook_file(plugin_root, plugin_id, path) { sources.push(source); } } @@ -720,7 +711,6 @@ pub fn load_plugin_hooks( plugin_root: plugin_root.clone(), source_path: manifest_path.clone(), source_relative_path: format!("plugin.json#hooks[{index}]"), - plugin_description: plugin_description.clone(), hooks: hooks_file.hooks.clone(), }); } @@ -728,12 +718,7 @@ pub fn load_plugin_hooks( None => { let default_path = plugin_root.join(DEFAULT_HOOKS_CONFIG_FILE); if default_path.as_path().is_file() - && let Some(source) = load_plugin_hook_file( - plugin_root, - plugin_id, - &default_path, - plugin_description.clone(), - ) + && let Some(source) = load_plugin_hook_file(plugin_root, plugin_id, &default_path) { sources.push(source); } @@ -748,7 +733,6 @@ fn load_plugin_hook_file( plugin_root: &AbsolutePathBuf, plugin_id: &PluginId, path: &AbsolutePathBuf, - plugin_description: Option, ) -> Option { let contents = match fs::read_to_string(path.as_path()) { Ok(contents) => contents, @@ -786,7 +770,6 @@ fn load_plugin_hook_file( plugin_root: plugin_root.clone(), source_path: path.clone(), source_relative_path, - plugin_description, hooks: parsed.hooks, }) } diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index 6007f1abfcb..92ac4160693 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -133,7 +133,7 @@ fn write_hook_file(plugin_root: &AbsolutePathBuf, relative_path: &str, event: &s fn load_sources(plugin_root: &AbsolutePathBuf) -> Vec { let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); - load_plugin_hooks(plugin_root, &plugin_id(), &manifest.paths, None) + load_plugin_hooks(plugin_root, &plugin_id(), &manifest.paths) } #[test] diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index f2b0bcec40d..9ee56abb2ab 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -41,7 +41,6 @@ struct HookHandlerSource<'a> { hook_config_rules: &'a HookConfigRules, env: HashMap, plugin_id: Option, - plugin_description: Option, source_relative_path: Option, } @@ -119,7 +118,6 @@ pub(crate) fn discover_handlers( hook_config_rules: &hook_config_rules, env: HashMap::new(), plugin_id: None, - plugin_description: None, source_relative_path: None, }, hook_events, @@ -140,7 +138,6 @@ pub(crate) fn discover_handlers( hook_config_rules: &hook_config_rules, env: HashMap::new(), plugin_id: None, - plugin_description: None, source_relative_path: None, }, hook_events, @@ -193,7 +190,6 @@ fn append_managed_requirement_handlers( hook_config_rules, env: HashMap::new(), plugin_id: None, - plugin_description: None, source_relative_path: None, }, managed_hooks.get().hooks.clone(), @@ -215,7 +211,6 @@ fn append_plugin_hook_sources( plugin_id, source_path, source_relative_path, - plugin_description, hooks, } = source; let mut env = HashMap::new(); @@ -237,7 +232,6 @@ fn append_plugin_hook_sources( hook_config_rules, env, plugin_id: Some(plugin_id), - plugin_description, source_relative_path: Some(source_relative_path), }, hooks, @@ -467,7 +461,6 @@ fn append_matcher_groups( source_path: source.path.clone(), source: source.source, plugin_id: source.plugin_id.clone(), - plugin_description: source.plugin_description.clone(), source_relative_path: source.source_relative_path.clone(), display_order: *display_order, enabled, @@ -578,7 +571,6 @@ mod tests { hook_config_rules, env: std::collections::HashMap::new(), plugin_id: None, - plugin_description: None, source_relative_path: None, } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 708ed520097..91615015580 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -82,7 +82,6 @@ pub struct HookListEntry { pub source_path: AbsolutePathBuf, pub source: HookSource, pub plugin_id: Option, - pub plugin_description: Option, pub source_relative_path: Option, pub display_order: i64, pub enabled: bool, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 846638037ba..3a6c56c4e9b 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -482,7 +482,6 @@ Path(r"{log_path}").write_text(json.dumps({{ plugin_root: plugin_root.clone(), source_path: source_path.clone(), source_relative_path: "hooks/hooks.json".to_string(), - plugin_description: Some("Demo plugin hook support.".to_string()), hooks: HookEventsToml { pre_tool_use: vec![MatcherGroup { matcher: Some("Bash".to_string()), @@ -530,8 +529,8 @@ Path(r"{log_path}").write_text(json.dumps({{ shell_args: Vec::new(), }); assert_eq!( - listed.hooks[0].plugin_description.as_deref(), - Some("Demo plugin hook support.") + listed.hooks[0].plugin_id.as_deref(), + Some("demo-plugin@test-marketplace") ); let outcome = engine diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 70317ce199f..31ecf560152 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -35,7 +35,6 @@ pub struct PluginHookSource { pub plugin_root: AbsolutePathBuf, pub source_path: AbsolutePathBuf, pub source_relative_path: String, - pub plugin_description: Option, pub hooks: HookEventsToml, } diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index d9ddba14d3c..1a69e6efea9 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -409,7 +409,7 @@ impl Renderable for HooksBrowserView { let content_width = width.saturating_sub(4) as usize; let height = match self.page { HooksBrowserPage::Events => { - Self::event_header_lines().len() + 2 + self.event_table_lines().len() + Self::event_header_lines().len() + 1 + self.event_table_lines().len() } HooksBrowserPage::Handlers(event_name) => { let row_count = self.handler_row_lines(event_name, content_width).len(); @@ -436,7 +436,7 @@ impl Renderable for HooksBrowserView { Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); let content_area = render_menu_surface(content_area, buf); let width = content_area.width as usize; - let mut lines = match self.page { + let lines = match self.page { HooksBrowserPage::Events => { let mut lines = Self::event_header_lines(); lines.push(Line::default()); @@ -474,7 +474,6 @@ impl Renderable for HooksBrowserView { return; } }; - lines.push(Line::default()); Paragraph::new(lines).render(content_area, buf); self.render_footer(footer_area, buf); } @@ -598,7 +597,6 @@ mod tests { event_name: HookEventName, source: HookSource, plugin_id: Option<&str>, - plugin_description: Option<&str>, command: &str, enabled: bool, is_managed: bool, @@ -616,7 +614,6 @@ mod tests { source_path: test_path_buf("/tmp/hooks.json").abs(), source, plugin_id: plugin_id.map(str::to_string), - plugin_description: plugin_description.map(str::to_string), source_relative_path: None, display_order, enabled, @@ -632,9 +629,6 @@ mod tests { HookEventName::PreToolUse, HookSource::Plugin, Some("superpowers@openai-curated"), - Some( - "An agentic skills framework & software development methodology that works: planning, TDD, debugging, and collaboration workflows.", - ), "${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh", /*enabled*/ true, /*is_managed*/ false, @@ -645,7 +639,6 @@ mod tests { HookEventName::PreToolUse, HookSource::User, None, - None, "~/bin/check-shell-with-a-command-that-is-way-too-long-for-the-summary-column.sh", /*enabled*/ false, /*is_managed*/ false, @@ -656,7 +649,6 @@ mod tests { HookEventName::PermissionRequest, HookSource::System, None, - None, "/enterprise/hooks/permission-check.sh", /*enabled*/ true, /*is_managed*/ true, @@ -712,7 +704,6 @@ mod tests { HookEventName::PreToolUse, HookSource::Plugin, Some("superpowers@openai-curated"), - None, "hooks/pre-tool-use-check.sh", /*enabled*/ true, /*is_managed*/ false, @@ -741,7 +732,6 @@ mod tests { HookEventName::PreToolUse, HookSource::Plugin, Some("superpowers@openai-curated"), - None, "hooks/pre-tool-use-check.sh", /*enabled*/ true, /*is_managed*/ false, @@ -770,7 +760,6 @@ mod tests { HookEventName::PreToolUse, HookSource::System, None, - None, "/enterprise/hooks/pre-tool-use-check.sh", /*enabled*/ true, /*is_managed*/ true, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap index d5e421a623c..74b0b0e9880 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -14,5 +14,4 @@ expression: "render_lines(&view, 112)" UserPromptSubmit 0 0 When the user submits a prompt Stop 0 0 Before Codex concludes a response - Press enter to view hooks; esc to close From 30f22ed394e480f03d18ec3785ef6beb06c2df48 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 15:32:53 -0700 Subject: [PATCH 15/64] Fix hooks browser list navigation --- .../tui/src/bottom_pane/hooks_browser_view.rs | 73 ++++++++++++++++++- ...ests__hooks_browser_scrolled_handlers.snap | 26 +++++++ ...ooks_browser_selected_managed_handler.snap | 19 +++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 1a69e6efea9..ac36ba3e0a6 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -257,7 +257,7 @@ impl HooksBrowserView { if hook.is_managed { line = line.dim(); } - if self.state.selected_idx == Some(idx) && !hook.is_managed { + if self.state.selected_idx == Some(idx) { line = line.cyan().bold(); } line @@ -466,7 +466,12 @@ impl Renderable for HooksBrowserView { ]) .areas(content_area); Paragraph::new(lines.clone()).render(header_area, buf); - Paragraph::new(rows).render(list_area, buf); + let visible_rows = rows + .into_iter() + .skip(self.state.scroll_top) + .take(list_height as usize) + .collect::>(); + Paragraph::new(visible_rows).render(list_area, buf); let mut detail_lines = vec![Line::default()]; detail_lines.extend(self.detail_lines(event_name, width)); Paragraph::new(detail_lines).render(detail_area, buf); @@ -683,6 +688,70 @@ mod tests { ); } + #[test] + fn renders_selected_managed_handler() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![ + hook( + "path:managed-1", + HookEventName::PreToolUse, + HookSource::System, + None, + "/enterprise/hooks/pre-tool-use-1.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 0, + ), + hook( + "path:managed-2", + HookEventName::PreToolUse, + HookSource::System, + None, + "/enterprise/hooks/pre-tool-use-2.sh", + /*enabled*/ true, + /*is_managed*/ true, + /*display_order*/ 1, + ), + ], + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_snapshot!( + "hooks_browser_selected_managed_handler", + render_lines(&view, /*width*/ 112) + ); + } + + #[test] + fn renders_scrolled_handler_window() { + let (tx_raw, _rx) = unbounded_channel::(); + let hooks = (0..=MAX_POPUP_ROWS) + .map(|idx| { + hook( + &format!("path:hook-{idx}"), + HookEventName::PreToolUse, + HookSource::User, + None, + &format!("/tmp/hook-{idx}.sh"), + /*enabled*/ true, + /*is_managed*/ false, + idx as i64, + ) + }) + .collect(); + let mut view = HooksBrowserView::new(hooks, AppEventSender::new(tx_raw)); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + for _ in 0..MAX_POPUP_ROWS { + view.handle_key_event(KeyEvent::from(KeyCode::Down)); + } + assert_snapshot!( + "hooks_browser_scrolled_handlers", + render_lines(&view, /*width*/ 112) + ); + } + #[test] fn renders_empty_handler_browser_message() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap new file mode 100644 index 00000000000..dffa83663ea --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap @@ -0,0 +1,26 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Hook 2 - [Config] + [x] Hook 3 - [Config] + [x] Hook 4 - [Config] + [x] Hook 5 - [Config] + [x] Hook 6 - [Config] + [x] Hook 7 - [Config] + [x] Hook 8 - [Config] + [x] Hook 9 - [Config] + + Event PreToolUse + Matcher Bash + Source [Config] /tmp/hooks.json + File /tmp/hooks.json + Command /tmp/hook-8.sh + Timeout 30s + + + Press space or enter to toggle; esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap new file mode 100644 index 00000000000..3060b2385a9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are saved automatically. + + [x] Admin managed Hook 1 - [Config] + [x] Admin managed Hook 2 - [Config] + + Event PreToolUse + Matcher Bash + Source [Config] /tmp/hooks.json + File /tmp/hooks.json + Command /enterprise/hooks/pre-tool-use-2.sh + Timeout 30s + + Admin managed; press esc to go back From 0a9499db3122aa69b11c2d55a3b3b6f825e98397 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 15:44:12 -0700 Subject: [PATCH 16/64] Tighten hooks browser tall layouts --- codex-rs/tui/src/bottom_pane/hooks_browser_view.rs | 3 ++- ...s_browser_view__tests__hooks_browser_scrolled_handlers.snap | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index ac36ba3e0a6..2e03122e6cd 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -416,9 +416,10 @@ impl Renderable for HooksBrowserView { if row_count == 0 { self.handler_header_lines(event_name).len() + 2 } else { + let visible_row_count = row_count.min(MAX_POPUP_ROWS); self.handler_header_lines(event_name).len() + 1 - + row_count + + visible_row_count + 1 + self.detail_lines(event_name, content_width).len() } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap index dffa83663ea..8a92f2a254f 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap @@ -22,5 +22,4 @@ expression: "render_lines(&view, 112)" Command /tmp/hook-8.sh Timeout 30s - Press space or enter to toggle; esc to go back From 7656221d30ec942212a856e96b75cc1d98c6ce46 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 15:47:47 -0700 Subject: [PATCH 17/64] Simplify hook source display --- .../tui/src/bottom_pane/hooks_browser_view.rs | 21 +++++++++++-------- ...r_view__tests__hooks_browser_handlers.snap | 3 +-- ..._tests__hooks_browser_managed_handler.snap | 3 +-- ...ests__hooks_browser_scrolled_handlers.snap | 17 +++++++-------- ...ooks_browser_selected_managed_handler.snap | 5 ++--- 5 files changed, 24 insertions(+), 25 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 2e03122e6cd..63b3296d34d 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -274,7 +274,6 @@ impl HooksBrowserView { if let Some(matcher) = hook.matcher.as_deref() { lines.extend(detail_wrapped_lines("Matcher", matcher, width)); } - lines.extend(detail_wrapped_lines("Source", &detail_source(hook), width)); lines.extend(detail_wrapped_lines( "File", &hook.source_path.display().to_string(), @@ -525,17 +524,21 @@ fn summary_source(hook: &HookMetadata, idx: usize) -> String { "{hook_label} - {}", hook.plugin_id.as_deref().unwrap_or("unknown plugin") ), - _ => format!("{hook_label} - [Config]"), + _ => format!("{hook_label} - {}", config_source_label(hook.source)), } } -fn detail_source(hook: &HookMetadata) -> String { - match hook.source { - HookSource::Plugin => format!( - "[Plugin] {}", - hook.plugin_id.as_deref().unwrap_or("unknown plugin") - ), - _ => format!("[Config] {}", hook.source_path.display()), +fn config_source_label(source: HookSource) -> &'static str { + match source { + HookSource::System => "System Config", + HookSource::User => "User Config", + HookSource::Project => "Project Config", + HookSource::Mdm => "MDM", + HookSource::SessionFlags => "Session Flags", + HookSource::Plugin => "Plugin", + HookSource::LegacyManagedConfigFile => "Legacy Managed Config", + HookSource::LegacyManagedConfigMdm => "Legacy Managed MDM", + HookSource::Unknown => "Unknown Source", } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap index 2e9b9048840..b868c29ef6d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap @@ -7,11 +7,10 @@ expression: "render_lines(&view, 112)" Turn hooks on or off. Your changes are saved automatically. [x] Hook 1 - superpowers@openai-curated - [ ] Hook 2 - [Config] + [ ] Hook 2 - User Config Event PreToolUse Matcher Bash - Source [Plugin] superpowers@openai-curated File /tmp/hooks.json Command ${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh Timeout 30s diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap index ad9aaf58f3c..ab9bbbe571f 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap @@ -6,11 +6,10 @@ expression: "render_lines(&view, 112)" PermissionRequest hooks Turn hooks on or off. Your changes are saved automatically. - [x] Admin managed Hook 1 - [Config] + [x] Admin managed Hook 1 - System Config Event PermissionRequest Matcher Bash - Source [Config] /tmp/hooks.json File /tmp/hooks.json Command /enterprise/hooks/permission-check.sh Timeout 30s diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap index 8a92f2a254f..479de39e68c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap @@ -6,18 +6,17 @@ expression: "render_lines(&view, 112)" PreToolUse hooks Turn hooks on or off. Your changes are saved automatically. - [x] Hook 2 - [Config] - [x] Hook 3 - [Config] - [x] Hook 4 - [Config] - [x] Hook 5 - [Config] - [x] Hook 6 - [Config] - [x] Hook 7 - [Config] - [x] Hook 8 - [Config] - [x] Hook 9 - [Config] + [x] Hook 2 - User Config + [x] Hook 3 - User Config + [x] Hook 4 - User Config + [x] Hook 5 - User Config + [x] Hook 6 - User Config + [x] Hook 7 - User Config + [x] Hook 8 - User Config + [x] Hook 9 - User Config Event PreToolUse Matcher Bash - Source [Config] /tmp/hooks.json File /tmp/hooks.json Command /tmp/hook-8.sh Timeout 30s diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap index 3060b2385a9..569b138b8d4 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap @@ -6,12 +6,11 @@ expression: "render_lines(&view, 112)" PreToolUse hooks Turn hooks on or off. Your changes are saved automatically. - [x] Admin managed Hook 1 - [Config] - [x] Admin managed Hook 2 - [Config] + [x] Admin managed Hook 1 - System Config + [x] Admin managed Hook 2 - System Config Event PreToolUse Matcher Bash - Source [Config] /tmp/hooks.json File /tmp/hooks.json Command /enterprise/hooks/pre-tool-use-2.sh Timeout 30s From 5622cb9c5fa6dfa264b8a8feefacb8c073fb16e0 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 15:54:33 -0700 Subject: [PATCH 18/64] Cap hook command details --- .../tui/src/bottom_pane/hooks_browser_view.rs | 64 ++++++++++++++++++- ..._hooks_browser_capped_command_details.snap | 19 ++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 63b3296d34d..f0aa99e5855 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -29,6 +29,7 @@ use crate::render::renderable::Renderable; const EVENT_COLUMN_WIDTH: usize = 22; const COUNT_COLUMN_WIDTH: usize = 12; +const MAX_COMMAND_DETAIL_LINES: usize = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum HooksBrowserPage { @@ -279,10 +280,11 @@ impl HooksBrowserView { &hook.source_path.display().to_string(), width, )); - lines.extend(detail_wrapped_lines( + lines.extend(detail_wrapped_lines_limited( "Command", hook.command.as_deref().unwrap_or("-"), width, + MAX_COMMAND_DETAIL_LINES, )); lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec))); lines @@ -522,7 +524,7 @@ fn summary_source(hook: &HookMetadata, idx: usize) -> String { match hook.source { HookSource::Plugin => format!( "{hook_label} - {}", - hook.plugin_id.as_deref().unwrap_or("unknown plugin") + hook.plugin_id.as_deref().unwrap_or("Plugin") ), _ => format!("{hook_label} - {}", config_source_label(hook.source)), } @@ -557,6 +559,41 @@ fn detail_wrapped_lines(label: &str, value: &str, width: usize) -> Vec Vec> { + let mut lines = detail_wrapped_lines(label, value, width); + if lines.len() <= max_lines { + return lines; + } + + lines.truncate(max_lines); + if let Some(last_line) = lines.last_mut() { + let prefix_width = last_line.spans[..last_line.spans.len().saturating_sub(1)] + .iter() + .map(ratatui::prelude::Span::width) + .sum::(); + let max_width = width.saturating_sub(prefix_width); + let Some(last_span) = last_line.spans.last_mut() else { + return lines; + }; + let truncated = truncate_line_with_ellipsis_if_overflow( + Line::from(format!("{}…", last_span.content)), + max_width, + ); + let content = truncated + .spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::(); + last_span.content = content.into(); + } + lines +} + #[cfg(test)] mod tests { use super::*; @@ -756,6 +793,29 @@ mod tests { ); } + #[test] + fn renders_command_details_with_three_line_cap() { + let (tx_raw, _rx) = unbounded_channel::(); + let mut view = HooksBrowserView::new( + vec![hook( + "path:long-command", + HookEventName::PreToolUse, + HookSource::User, + None, + "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + )], + AppEventSender::new(tx_raw), + ); + view.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_snapshot!( + "hooks_browser_capped_command_details", + render_lines(&view, /*width*/ 44) + ); + } + #[test] fn renders_empty_handler_browser_message() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap new file mode 100644 index 00000000000..1d189341c60 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 44)" +--- + + PreToolUse hooks + Turn hooks on or off. Your changes are s + + [x] Hook 1 - User Config + + Event PreToolUse + Matcher Bash + File /tmp/hooks.json + Command one two three four five six + seven eight nine ten eleven + twelve thirteen fourteen… + Timeout 30s + + Press space or enter to toggle; esc to go From 4f042351b4d820275e37ec6760c6344d4608984e Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 16:00:28 -0700 Subject: [PATCH 19/64] Simplify hook detail wrapping --- .../tui/src/bottom_pane/hooks_browser_view.rs | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index f0aa99e5855..c746b7ce335 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -273,18 +273,19 @@ impl HooksBrowserView { let mut lines = vec![detail_line("Event", event_label(event_name))]; if let Some(matcher) = hook.matcher.as_deref() { - lines.extend(detail_wrapped_lines("Matcher", matcher, width)); + lines.extend(detail_wrapped_lines("Matcher", matcher, width, None)); } lines.extend(detail_wrapped_lines( "File", &hook.source_path.display().to_string(), width, + None, )); - lines.extend(detail_wrapped_lines_limited( + lines.extend(detail_wrapped_lines( "Command", hook.command.as_deref().unwrap_or("-"), width, - MAX_COMMAND_DETAIL_LINES, + Some(MAX_COMMAND_DETAIL_LINES), )); lines.push(detail_line("Timeout", &format!("{}s", hook.timeout_sec))); lines @@ -548,7 +549,12 @@ fn detail_line(label: &str, value: &str) -> Line<'static> { Line::from(vec![format!("{label:<10}").into(), value.to_string().dim()]) } -fn detail_wrapped_lines(label: &str, value: &str, width: usize) -> Vec> { +fn detail_wrapped_lines( + label: &str, + value: &str, + width: usize, + max_lines: Option, +) -> Vec> { let prefix = format!("{label:<10}"); let available = width.saturating_sub(prefix.width()).max(1); let mut wrapped = textwrap::wrap(value, available).into_iter(); @@ -556,16 +562,9 @@ fn detail_wrapped_lines(label: &str, value: &str, width: usize) -> Vec Vec> { - let mut lines = detail_wrapped_lines(label, value, width); + let Some(max_lines) = max_lines else { + return lines; + }; if lines.len() <= max_lines { return lines; } From 3197091b56190e29cc5c5f6c743af0c971e7fa2c Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 16:06:36 -0700 Subject: [PATCH 20/64] Polish hook source display --- codex-rs/tui/src/bottom_pane/hooks_browser_view.rs | 5 +++-- ...er_view__tests__hooks_browser_capped_command_details.snap | 2 +- ...e__hooks_browser_view__tests__hooks_browser_handlers.snap | 2 +- ...s_browser_view__tests__hooks_browser_managed_handler.snap | 2 +- ...browser_view__tests__hooks_browser_scrolled_handlers.snap | 2 +- ..._view__tests__hooks_browser_selected_managed_handler.snap | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index c746b7ce335..92e6889e663 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -26,6 +26,7 @@ use crate::app_event_sender::AppEventSender; use crate::key_hint; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::render::renderable::Renderable; +use crate::status::format_directory_display; const EVENT_COLUMN_WIDTH: usize = 22; const COUNT_COLUMN_WIDTH: usize = 12; @@ -276,8 +277,8 @@ impl HooksBrowserView { lines.extend(detail_wrapped_lines("Matcher", matcher, width, None)); } lines.extend(detail_wrapped_lines( - "File", - &hook.source_path.display().to_string(), + "Source", + &format_directory_display(&hook.source_path, /*max_width*/ None), width, None, )); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap index 1d189341c60..ee6fbc2a370 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap @@ -10,7 +10,7 @@ expression: "render_lines(&view, 44)" Event PreToolUse Matcher Bash - File /tmp/hooks.json + Source /tmp/hooks.json Command one two three four five six seven eight nine ten eleven twelve thirteen fourteen… diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap index b868c29ef6d..1ccb4814340 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap @@ -11,7 +11,7 @@ expression: "render_lines(&view, 112)" Event PreToolUse Matcher Bash - File /tmp/hooks.json + Source /tmp/hooks.json Command ${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh Timeout 30s diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap index ab9bbbe571f..eee32f4ba71 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap @@ -10,7 +10,7 @@ expression: "render_lines(&view, 112)" Event PermissionRequest Matcher Bash - File /tmp/hooks.json + Source /tmp/hooks.json Command /enterprise/hooks/permission-check.sh Timeout 30s diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap index 479de39e68c..29f58bb8052 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap @@ -17,7 +17,7 @@ expression: "render_lines(&view, 112)" Event PreToolUse Matcher Bash - File /tmp/hooks.json + Source /tmp/hooks.json Command /tmp/hook-8.sh Timeout 30s diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap index 569b138b8d4..c673a1b86d7 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap @@ -11,7 +11,7 @@ expression: "render_lines(&view, 112)" Event PreToolUse Matcher Bash - File /tmp/hooks.json + Source /tmp/hooks.json Command /enterprise/hooks/pre-tool-use-2.sh Timeout 30s From 05a5d5d6727066b0e273875611a2a9fc141fd0c0 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 16:40:00 -0700 Subject: [PATCH 21/64] Fix hooks browser CI --- .../tui/src/bottom_pane/hooks_browser_view.rs | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 92e6889e663..27de53469bb 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -274,13 +274,15 @@ impl HooksBrowserView { let mut lines = vec![detail_line("Event", event_label(event_name))]; if let Some(matcher) = hook.matcher.as_deref() { - lines.extend(detail_wrapped_lines("Matcher", matcher, width, None)); + lines.extend(detail_wrapped_lines( + "Matcher", matcher, width, /*max_lines*/ None, + )); } lines.extend(detail_wrapped_lines( "Source", &format_directory_display(&hook.source_path, /*max_width*/ None), width, - None, + /*max_lines*/ None, )); lines.extend(detail_wrapped_lines( "Command", @@ -603,6 +605,7 @@ mod tests { use crate::render::renderable::Renderable; use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; + use crate::test_support::test_path_display; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookHandlerType; use codex_app_server_protocol::HookMetadata; @@ -620,7 +623,7 @@ mod tests { let mut buf = Buffer::empty(area); view.render(area, &mut buf); - (0..area.height) + let rendered = (0..area.height) .map(|row| { (0..area.width) .map(|col| { @@ -634,7 +637,8 @@ mod tests { .collect::() }) .collect::>() - .join("\n") + .join("\n"); + rendered.replace(&test_path_display("/tmp/hooks.json"), "/tmp/hooks.json") } #[allow(clippy::too_many_arguments)] @@ -684,7 +688,7 @@ mod tests { "path:user-config", HookEventName::PreToolUse, HookSource::User, - None, + /*plugin_id*/ None, "~/bin/check-shell-with-a-command-that-is-way-too-long-for-the-summary-column.sh", /*enabled*/ false, /*is_managed*/ false, @@ -694,7 +698,7 @@ mod tests { "path:managed", HookEventName::PermissionRequest, HookSource::System, - None, + /*plugin_id*/ None, "/enterprise/hooks/permission-check.sh", /*enabled*/ true, /*is_managed*/ true, @@ -738,7 +742,7 @@ mod tests { "path:managed-1", HookEventName::PreToolUse, HookSource::System, - None, + /*plugin_id*/ None, "/enterprise/hooks/pre-tool-use-1.sh", /*enabled*/ true, /*is_managed*/ true, @@ -748,7 +752,7 @@ mod tests { "path:managed-2", HookEventName::PreToolUse, HookSource::System, - None, + /*plugin_id*/ None, "/enterprise/hooks/pre-tool-use-2.sh", /*enabled*/ true, /*is_managed*/ true, @@ -774,7 +778,7 @@ mod tests { &format!("path:hook-{idx}"), HookEventName::PreToolUse, HookSource::User, - None, + /*plugin_id*/ None, &format!("/tmp/hook-{idx}.sh"), /*enabled*/ true, /*is_managed*/ false, @@ -801,7 +805,7 @@ mod tests { "path:long-command", HookEventName::PreToolUse, HookSource::User, - None, + /*plugin_id*/ None, "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty", /*enabled*/ true, /*is_managed*/ false, @@ -892,7 +896,7 @@ mod tests { "path:managed", HookEventName::PreToolUse, HookSource::System, - None, + /*plugin_id*/ None, "/enterprise/hooks/pre-tool-use-check.sh", /*enabled*/ true, /*is_managed*/ true, From 98efa54667a14b775b5b9129541e4bf06d710c95 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 17:01:22 -0700 Subject: [PATCH 22/64] Trim hooks list metadata --- .../schema/json/codex_app_server_protocol.schemas.json | 6 ------ .../json/codex_app_server_protocol.v2.schemas.json | 6 ------ .../schema/json/v2/HooksListResponse.json | 6 ------ .../schema/typescript/v2/HookMetadata.ts | 2 +- codex-rs/app-server-protocol/src/protocol/v2.rs | 1 - codex-rs/app-server/README.md | 1 - codex-rs/app-server/src/codex_message_processor.rs | 3 ++- codex-rs/hooks/src/engine/discovery.rs | 9 +-------- codex-rs/hooks/src/engine/mod.rs | 1 - 9 files changed, 4 insertions(+), 31 deletions(-) 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 c5c98c7f7f0..5898252acf0 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 @@ -9653,12 +9653,6 @@ "sourcePath": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, - "sourceRelativePath": { - "type": [ - "string", - "null" - ] - }, "statusMessage": { "type": [ "string", 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 409bee534af..7d93aaa1cd1 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 @@ -6283,12 +6283,6 @@ "sourcePath": { "$ref": "#/definitions/AbsolutePathBuf" }, - "sourceRelativePath": { - "type": [ - "string", - "null" - ] - }, "statusMessage": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index 889a99bcd71..c345a35d0fa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -75,12 +75,6 @@ "sourcePath": { "$ref": "#/definitions/AbsolutePathBuf" }, - "sourceRelativePath": { - "type": [ - "string", - "null" - ] - }, "statusMessage": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index eb12a2a6962..074d0cfb6dc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -6,4 +6,4 @@ import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; -export type HookMetadata = { eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, sourceRelativePath: string | null, displayOrder: bigint, }; +export type HookMetadata = { eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8ef9bfc6a31..7a5d1fb67b2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4494,7 +4494,6 @@ pub struct HookMetadata { pub source_path: AbsolutePathBuf, pub source: HookSource, pub plugin_id: Option, - pub source_relative_path: Option, pub display_order: i64, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f514ea9c2be..1a7c31ce323 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1479,7 +1479,6 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. "sourcePath": "/Users/me/.codex/config.toml", "source": "user", "pluginId": null, - "sourceRelativePath": null, "displayOrder": 0 }], "warnings": [], diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 85521b82075..d6167190ebc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -714,6 +714,8 @@ impl CodexMessageProcessor { .await } + /// Resolve a caller-provided cwd into the absolute cwd and matching config layers + /// so list-style RPCs share the same per-cwd error handling. async fn resolve_cwd_config( &self, cwd: &Path, @@ -8608,7 +8610,6 @@ fn hooks_to_info(hooks: &[codex_core::hooks::HookListEntry]) -> Vec { source: HookSource, env: HashMap, plugin_id: Option, - source_relative_path: Option, } pub(crate) fn discover_handlers( @@ -108,7 +107,6 @@ pub(crate) fn discover_handlers( source: hook_source, env: HashMap::new(), plugin_id: None, - source_relative_path: None, }, hook_events, ); @@ -126,7 +124,6 @@ pub(crate) fn discover_handlers( source: hook_source, env: HashMap::new(), plugin_id: None, - source_relative_path: None, }, hook_events, ); @@ -174,7 +171,6 @@ fn append_managed_requirement_handlers( source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), env: HashMap::new(), plugin_id: None, - source_relative_path: None, }, managed_hooks.get().hooks.clone(), ); @@ -193,8 +189,8 @@ fn append_plugin_hook_sources( plugin_root, plugin_id, source_path, - source_relative_path, hooks, + .. } = source; let mut env = HashMap::new(); let plugin_root_value = plugin_root.display().to_string(); @@ -213,7 +209,6 @@ fn append_plugin_hook_sources( source: HookSource::Plugin, env, plugin_id: Some(plugin_id), - source_relative_path: Some(source_relative_path), }, hooks, ); @@ -431,7 +426,6 @@ fn append_matcher_groups( source_path: source.path.clone(), source: source.source, plugin_id: source.plugin_id.clone(), - source_relative_path: source.source_relative_path.clone(), display_order: *display_order, }); handlers.push(ConfiguredHandler { @@ -521,7 +515,6 @@ mod tests { source: hook_source(), env: std::collections::HashMap::new(), plugin_id: None, - source_relative_path: None, } } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 9af71c19fbc..3e859c54508 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -80,7 +80,6 @@ pub struct HookListEntry { pub source_path: AbsolutePathBuf, pub source: HookSource, pub plugin_id: Option, - pub source_relative_path: Option, pub display_order: i64, } From 85be136abcd8ac835afc326b7589cb1052584a4f Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 17:13:57 -0700 Subject: [PATCH 23/64] Gate plugin hook loading --- codex-rs/core-plugins/src/loader.rs | 8 +++++++- codex-rs/core/src/plugins/manager.rs | 23 ++++++++++++++++------ codex-rs/core/src/plugins/manager_tests.rs | 1 + 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 41217666ae9..edd82042e58 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -109,6 +109,7 @@ pub async fn load_plugins_from_layer_stack( config_layer_stack: &ConfigLayerStack, store: &PluginStore, restriction_product: Option, + plugin_hooks_enabled: bool, ) -> PluginLoadOutcome { let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); let mut configured_plugins: Vec<_> = configured_plugins_from_stack(config_layer_stack) @@ -125,6 +126,7 @@ pub async fn load_plugins_from_layer_stack( store, restriction_product, &skill_config_rules, + plugin_hooks_enabled, ) .await; for name in loaded_plugin.mcp_servers.keys() { @@ -459,6 +461,7 @@ async fn load_plugin( store: &PluginStore, restriction_product: Option, skill_config_rules: &SkillConfigRules, + plugin_hooks_enabled: bool, ) -> LoadedPlugin { let plugin_id = PluginId::parse(&config_name); let active_plugin_root = plugin_id @@ -551,7 +554,10 @@ async fn load_plugin( } loaded_plugin.mcp_servers = mcp_servers; loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; - loaded_plugin.hook_sources = load_plugin_hooks(&plugin_root, &loaded_plugin_id, manifest_paths); + if plugin_hooks_enabled { + loaded_plugin.hook_sources = + load_plugin_hooks(&plugin_root, &loaded_plugin_id, manifest_paths); + } loaded_plugin } diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index da95e1caed3..a8d90c1a012 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -409,6 +409,7 @@ impl PluginsManager { &config.config_layer_stack, &self.store, self.restriction_product, + config.features.enabled(Feature::PluginHooks), ) .await; log_plugin_load_errors(&outcome); @@ -442,9 +443,14 @@ impl PluginsManager { if !plugins_feature_enabled { return Vec::new(); } - load_plugins_from_layer_stack(config_layer_stack, &self.store, self.restriction_product) - .await - .effective_skill_roots() + load_plugins_from_layer_stack( + config_layer_stack, + &self.store, + self.restriction_product, + /*plugin_hooks_enabled*/ false, + ) + .await + .effective_skill_roots() } pub async fn effective_plugin_hook_sources_for_layer_stack( @@ -456,9 +462,14 @@ impl PluginsManager { if !plugins_feature_enabled || !plugin_hooks_feature_enabled { return Vec::new(); } - load_plugins_from_layer_stack(config_layer_stack, &self.store, self.restriction_product) - .await - .effective_plugin_hook_sources() + load_plugins_from_layer_stack( + config_layer_stack, + &self.store, + self.restriction_product, + /*plugin_hooks_enabled*/ true, + ) + .await + .effective_plugin_hook_sources() } fn cached_enabled_outcome(&self) -> Option { diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 8eb7f5b0960..ebe1ccd45dc 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -3299,6 +3299,7 @@ async fn load_plugins_ignores_project_config_files() { &stack, &PluginStore::new(codex_home.path().to_path_buf()), Some(Product::Codex), + /*plugin_hooks_enabled*/ false, ) .await; From e0ff2ad459296e6991bb4cade45c47781f9a7713 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 17:27:39 -0700 Subject: [PATCH 24/64] Simplify hooks list plumbing --- .../app-server/src/codex_message_processor.rs | 30 ++--- codex-rs/hooks/src/engine/discovery.rs | 120 +++++++----------- 2 files changed, 58 insertions(+), 92 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d6167190ebc..abae28d633a 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6200,8 +6200,16 @@ impl CodexMessageProcessor { Ok(SkillsListResponse { data }) } - /// Handle `hooks/list` by resolving hooks for each requested cwd. async fn hooks_list(&self, request_id: ConnectionRequestId, params: HooksListParams) { + let result = self.hooks_list_response(params).await; + self.outgoing.send_result(request_id, result).await; + } + + /// Handle `hooks/list` by resolving hooks for each requested cwd. + async fn hooks_list_response( + &self, + params: HooksListParams, + ) -> Result { let HooksListParams { cwds } = params; let cwds = if cwds.is_empty() { vec![self.config.cwd.to_path_buf()] @@ -6209,17 +6217,13 @@ impl CodexMessageProcessor { cwds }; - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; let auth = self.auth_manager.auth().await; let workspace_codex_plugins_enabled = self .workspace_codex_plugins_enabled(&config, auth.as_ref()) .await; + let plugins_enabled = + config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; let plugins_manager = self.thread_manager.plugins_manager(); let mut data = Vec::new(); for cwd in cwds { @@ -6245,17 +6249,15 @@ impl CodexMessageProcessor { let plugin_hook_sources = plugins_manager .effective_plugin_hook_sources_for_layer_stack( &config_layer_stack, - config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled, + plugins_enabled, config.features.enabled(Feature::PluginHooks), ) .await; let hooks = codex_core::hooks::list_hooks(codex_core::hooks::HooksConfig { - legacy_notify_argv: None, feature_enabled: config.features.enabled(Feature::CodexHooks), config_layer_stack: Some(config_layer_stack), plugin_hook_sources, - shell_program: None, - shell_args: Vec::new(), + ..Default::default() }); data.push(codex_app_server_protocol::HooksListEntry { cwd, @@ -6264,9 +6266,7 @@ impl CodexMessageProcessor { errors: Vec::new(), }); } - self.outgoing - .send_response(request_id, HooksListResponse { data }) - .await; + Ok(HooksListResponse { data }) } async fn marketplace_remove( diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index c69d4250645..10358677e9e 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -43,90 +43,56 @@ pub(crate) fn discover_handlers( config_layer_stack: Option<&ConfigLayerStack>, plugin_hook_sources: Vec, ) -> DiscoveryResult { - let Some(config_layer_stack) = config_layer_stack else { - let mut handlers = Vec::new(); - let mut hook_entries = Vec::new(); - let mut warnings = Vec::new(); - let mut display_order = 0_i64; - append_plugin_hook_sources( - &mut handlers, - &mut hook_entries, - &mut warnings, - &mut display_order, - plugin_hook_sources, - ); - return DiscoveryResult { - handlers, - hook_entries, - warnings, - }; - }; - let mut handlers = Vec::new(); let mut hook_entries = Vec::new(); let mut warnings = Vec::new(); let mut display_order = 0_i64; - append_managed_requirement_handlers( - &mut handlers, - &mut hook_entries, - &mut warnings, - &mut display_order, - config_layer_stack, - ); - - for layer in config_layer_stack.get_layers( - ConfigLayerStackOrdering::LowestPrecedenceFirst, - /*include_disabled*/ false, - ) { - let hook_source = hook_source_for_config_layer_source(&layer.name); - let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings); - let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings); - - if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) = - (&json_hooks, &toml_hooks) - && !json_events.is_empty() - && !toml_events.is_empty() - { - warnings.push(format!( - "loading hooks from both {} and {}; prefer a single representation for this layer", - json_source_path.display(), - toml_source_path.display() - )); - } + if let Some(config_layer_stack) = config_layer_stack { + append_managed_requirement_handlers( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + config_layer_stack, + ); - if let Some((source_path, hook_events)) = json_hooks { - append_hook_events( - &mut handlers, - &mut hook_entries, - &mut warnings, - &mut display_order, - HookHandlerSource { - path: &source_path, - is_managed: false, - source: hook_source, - env: HashMap::new(), - plugin_id: None, - }, - hook_events, - ); - } + for layer in config_layer_stack.get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) { + let hook_source = hook_source_for_config_layer_source(&layer.name); + let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings); + let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings); + + if let (Some((json_source_path, json_events)), Some((toml_source_path, toml_events))) = + (&json_hooks, &toml_hooks) + && !json_events.is_empty() + && !toml_events.is_empty() + { + warnings.push(format!( + "loading hooks from both {} and {}; prefer a single representation for this layer", + json_source_path.display(), + toml_source_path.display() + )); + } - if let Some((source_path, hook_events)) = toml_hooks { - append_hook_events( - &mut handlers, - &mut hook_entries, - &mut warnings, - &mut display_order, - HookHandlerSource { - path: &source_path, - is_managed: false, - source: hook_source, - env: HashMap::new(), - plugin_id: None, - }, - hook_events, - ); + for (source_path, hook_events) in [json_hooks, toml_hooks].into_iter().flatten() { + append_hook_events( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + HookHandlerSource { + path: &source_path, + is_managed: false, + source: hook_source, + env: HashMap::new(), + plugin_id: None, + }, + hook_events, + ); + } } } From 2c4c2c1030eb7a13095ef622fa57518ae46df761 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 19:25:03 -0700 Subject: [PATCH 25/64] Support plugin hook path substitution and data dirs --- codex-rs/core-plugins/src/loader.rs | 79 +++++++++++------- codex-rs/core-plugins/src/loader_tests.rs | 50 ++++++++++-- codex-rs/core-plugins/src/store.rs | 14 +++- codex-rs/core-plugins/src/store_tests.rs | 12 +++ codex-rs/core/src/plugins/manager_tests.rs | 3 + codex-rs/core/src/session/session.rs | 10 ++- codex-rs/hooks/src/engine/discovery.rs | 13 ++- codex-rs/hooks/src/engine/mod.rs | 7 +- codex-rs/hooks/src/engine/mod_tests.rs | 94 ++++++++++++++++++++++ codex-rs/hooks/src/registry.rs | 2 + codex-rs/plugin/src/lib.rs | 1 + codex-rs/plugin/src/load_outcome.rs | 9 +++ 12 files changed, 253 insertions(+), 41 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 41217666ae9..55b8c0b57c7 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -483,6 +483,7 @@ async fn load_plugin( mcp_servers: HashMap::new(), apps: Vec::new(), hook_sources: Vec::new(), + hook_load_warnings: Vec::new(), error: None, }; @@ -551,7 +552,14 @@ async fn load_plugin( } loaded_plugin.mcp_servers = mcp_servers; loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; - loaded_plugin.hook_sources = load_plugin_hooks(&plugin_root, &loaded_plugin_id, manifest_paths); + 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 } @@ -687,15 +695,22 @@ fn default_app_config_paths(plugin_root: &Path) -> Vec { pub fn load_plugin_hooks( plugin_root: &AbsolutePathBuf, plugin_id: &PluginId, + plugin_data_root: &AbsolutePathBuf, manifest_paths: &PluginManifestPaths, -) -> Vec { +) -> (Vec, Vec) { let mut sources = Vec::new(); + let mut warnings = Vec::new(); match &manifest_paths.hooks { Some(PluginManifestHooks::Paths(paths)) => { for path in paths { - if let Some(source) = load_plugin_hook_file(plugin_root, plugin_id, path) { - sources.push(source); - } + append_plugin_hook_file( + plugin_root, + plugin_id, + plugin_data_root, + path, + &mut sources, + &mut warnings, + ); } } Some(PluginManifestHooks::Inline(hooks_files)) => { @@ -709,6 +724,7 @@ pub fn load_plugin_hooks( 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(), @@ -717,45 +733,53 @@ pub fn load_plugin_hooks( } None => { let default_path = plugin_root.join(DEFAULT_HOOKS_CONFIG_FILE); - if default_path.as_path().is_file() - && let Some(source) = load_plugin_hook_file(plugin_root, plugin_id, &default_path) - { - sources.push(source); + 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 + (sources, warnings) } -// Load one resolved plugin hook file and keep source metadata with its parsed -// hook events so runtime discovery can report plugin-originated hook runs. -fn load_plugin_hook_file( +// 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, -) -> Option { + sources: &mut Vec, + warnings: &mut Vec, +) { let contents = match fs::read_to_string(path.as_path()) { Ok(contents) => contents, Err(err) => { - warn!( - path = %path.display(), - "failed to read plugin hooks config: {err}" - ); - return None; + 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) => { - warn!( - path = %path.display(), - "failed to parse plugin hooks config: {err}" - ); - return None; + warnings.push(format!( + "failed to parse plugin hooks config {}: {err}", + path.display() + )); + return; } }; if parsed.hooks.is_empty() { - return None; + return; } let source_relative_path = path @@ -765,13 +789,14 @@ fn load_plugin_hook_file( .to_string_lossy() .replace('\\', "/"); - Some(PluginHookSource { + 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( diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index bbc0642cb59..d9029c584eb 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -131,9 +131,22 @@ fn write_hook_file(plugin_root: &AbsolutePathBuf, relative_path: &str, event: &s .expect("write hooks"); } -fn load_sources(plugin_root: &AbsolutePathBuf) -> Vec { +fn load_sources(plugin_root: &AbsolutePathBuf) -> (Vec, Vec) { let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest"); - load_plugin_hooks(plugin_root, &plugin_id(), &manifest.paths) + 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]) { @@ -179,8 +192,9 @@ fn load_plugin_hooks_discovers_default_hooks_file() { ) .expect("write hooks"); - let sources = load_sources(&plugin_root); + let (sources, warnings) = load_sources(&plugin_root); + assert_eq!(warnings, Vec::::new()); assert_sources(&sources, &["hooks/hooks.json"]); } @@ -196,8 +210,9 @@ fn load_plugin_hooks_supports_manifest_hook_path() { ); write_hook_file(&plugin_root, "hooks/one.json", "PreToolUse", "echo one"); - let sources = load_sources(&plugin_root); + let (sources, warnings) = load_sources(&plugin_root); + assert_eq!(warnings, Vec::::new()); assert_sources(&sources, &["hooks/one.json"]); } @@ -220,8 +235,9 @@ fn load_plugin_hooks_manifest_paths_replace_default_hooks_file() { write_hook_file(&plugin_root, "hooks/one.json", "PreToolUse", "echo one"); write_hook_file(&plugin_root, "hooks/two.json", "PostToolUse", "echo two"); - let sources = load_sources(&plugin_root); + let (sources, warnings) = load_sources(&plugin_root); + assert_eq!(warnings, Vec::::new()); assert_sources(&sources, &["hooks/one.json", "hooks/two.json"]); } @@ -245,11 +261,30 @@ fn load_plugin_hooks_supports_inline_manifest_hooks() { }"#, ); - let sources = load_sources(&plugin_root); + let (sources, warnings) = load_sources(&plugin_root); + assert_eq!(warnings, Vec::::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::::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(); @@ -280,8 +315,9 @@ fn load_plugin_hooks_supports_inline_manifest_hook_list() { }"#, ); - let sources = load_sources(&plugin_root); + let (sources, warnings) = load_sources(&plugin_root); + assert_eq!(warnings, Vec::::new()); assert_sources(&sources, &["plugin.json#hooks[0]", "plugin.json#hooks[1]"]); } diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index 757aec8bc53..9d760da53e0 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -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 { 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 { let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path()) .ok()? diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index 45feff61bd5..0ba6b0d2c6e 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -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(); diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 8eb7f5b0960..fb4b5a62125 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -220,6 +220,7 @@ 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, }] ); @@ -721,6 +722,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions mcp_servers: HashMap::new(), apps: Vec::new(), hook_sources: Vec::new(), + hook_load_warnings: Vec::new(), error: None, }] ); @@ -839,6 +841,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { 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 beaecab14ff..351e3049ab9 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -755,17 +755,21 @@ impl Session { 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 = if plugin_hooks_enabled { + 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_sources(), + plugin_outcome.effective_plugin_hook_warnings(), + ) } else { - Vec::new() + (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/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index ce42908c097..f2e195bb943 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -38,10 +38,11 @@ struct HookHandlerSource<'a> { 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 { let mut handlers = Vec::new(); - let mut warnings = Vec::new(); + let mut warnings = plugin_hook_load_warnings; let mut display_order = 0_i64; append_plugin_hook_sources( &mut handlers, @@ -53,7 +54,7 @@ pub(crate) fn discover_handlers( }; 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( @@ -162,15 +163,20 @@ fn append_plugin_hook_sources( 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, @@ -403,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, diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 5c121136f7a..89daf501cae 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -79,6 +79,7 @@ impl ClaudeHooksEngine { enabled: bool, config_layer_stack: Option<&ConfigLayerStack>, plugin_hook_sources: Vec, + plugin_hook_load_warnings: Vec, shell: CommandShell, ) -> Self { if !enabled { @@ -90,7 +91,11 @@ impl ClaudeHooksEngine { } let _ = schema_loader::generated_hook_schemas(); - let discovered = discovery::discover_handlers(config_layer_stack, plugin_hook_sources); + 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 245c31ba5fd..b29542d8bb0 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs; use std::path::Path; @@ -109,6 +110,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: /*enabled*/ true, Some(&config_layer_stack), Vec::new(), + Vec::new(), CommandShell { program: String::new(), args: Vec::new(), @@ -193,6 +195,7 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { /*enabled*/ true, Some(&config_layer_stack), Vec::new(), + Vec::new(), CommandShell { program: String::new(), args: Vec::new(), @@ -301,6 +304,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { /*enabled*/ true, Some(&config_layer_stack), Vec::new(), + Vec::new(), CommandShell { program: String::new(), args: Vec::new(), @@ -337,6 +341,8 @@ 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"); @@ -361,6 +367,7 @@ Path(r"{log_path}").write_text(json.dumps({{ 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 { @@ -380,6 +387,7 @@ Path(r"{log_path}").write_text(json.dumps({{ /*enabled*/ true, /*config_layer_stack*/ None, plugin_hook_sources, + Vec::new(), CommandShell { program: String::new(), args: Vec::new(), @@ -430,3 +438,89 @@ Path(r"{log_path}").write_text(json.dumps({{ }) ); } + +#[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()]); +} diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 4509a8a6318..7dd93213a11 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -27,6 +27,7 @@ pub struct HooksConfig { 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, } @@ -56,6 +57,7 @@ impl Hooks { 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/src/lib.rs b/codex-rs/plugin/src/lib.rs index 31ecf560152..2140645de39 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -33,6 +33,7 @@ pub struct PluginCapabilitySummary { 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, diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index 40dba4ae2ae..0865b9020fc 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -23,6 +23,7 @@ pub struct LoadedPlugin { pub mcp_servers: HashMap, pub apps: Vec, pub hook_sources: Vec, + pub hook_load_warnings: Vec, pub error: Option, } @@ -150,6 +151,14 @@ impl PluginLoadOutcome { .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 } From d1f7d6991d0a523bfa585f2c010cd4557b422eb5 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 22:11:34 -0700 Subject: [PATCH 26/64] Fix hooks tests after base merge --- codex-rs/hooks/src/engine/mod_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 6c4ba38747b..145da888db1 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -125,6 +125,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: feature_enabled: true, config_layer_stack: Some(config_layer_stack.clone()), plugin_hook_sources: Vec::new(), + plugin_hook_load_warnings: Vec::new(), shell_program: None, shell_args: Vec::new(), }); @@ -535,6 +536,7 @@ Path(r"{log_path}").write_text(json.dumps({{ feature_enabled: true, config_layer_stack: None, plugin_hook_sources, + plugin_hook_load_warnings: Vec::new(), shell_program: None, shell_args: Vec::new(), }); From 3261bee4649588d594a268f2e2d7a2f5a73900a1 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 22:18:13 -0700 Subject: [PATCH 27/64] Clean up hooks browser view --- .../tui/src/bottom_pane/hooks_browser_view.rs | 68 ++++++------------- 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 8a9cb20cc80..fbf68fa0c87 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -85,17 +85,17 @@ impl HooksBrowserView { .collect() } - fn handlers_for_event(&self, event_name: HookEventName) -> Vec<&HookMetadata> { + fn handlers_for_event(&self, event_name: HookEventName) -> impl Iterator { self.hooks .iter() - .filter(|hook| hook.event_name == event_name) - .collect() + .filter(move |hook| hook.event_name == event_name) } fn selected_event(&self) -> Option { self.state .selected_idx - .and_then(|idx| self.event_rows().get(idx).map(|row| row.event_name)) + .and_then(|idx| codex_protocol::protocol::HookEventName::iter().nth(idx)) + .map(Into::into) } fn selected_hook_index(&self, event_name: HookEventName) -> Option { @@ -127,8 +127,8 @@ impl HooksBrowserView { fn page_len(&self) -> usize { match self.page { - HooksBrowserPage::Events => self.event_rows().len(), - HooksBrowserPage::Handlers(event_name) => self.handlers_for_event(event_name).len(), + HooksBrowserPage::Events => codex_protocol::protocol::HookEventName::iter().count(), + HooksBrowserPage::Handlers(event_name) => self.handlers_for_event(event_name).count(), } } @@ -178,9 +178,8 @@ impl HooksBrowserView { self.state = ScrollState::new(); self.state.selected_idx = selected_event_name .and_then(|event_name| { - self.event_rows() - .iter() - .position(|row| row.event_name == event_name) + codex_protocol::protocol::HookEventName::iter() + .position(|candidate| HookEventName::from(candidate) == event_name) }) .or_else(|| (self.page_len() > 0).then_some(0)); } @@ -194,7 +193,7 @@ impl HooksBrowserView { ] } - fn handler_header_lines(&self, event_name: HookEventName) -> Vec> { + fn handler_header_lines(event_name: HookEventName) -> Vec> { vec![ format!("{} hooks", event_label(event_name)).bold().into(), "Turn hooks on or off. Your changes are saved automatically." @@ -245,7 +244,6 @@ impl HooksBrowserView { fn handler_row_lines(&self, event_name: HookEventName, width: usize) -> Vec> { self.handlers_for_event(event_name) - .into_iter() .enumerate() .map(|(idx, hook)| { let marker = if hook.enabled { 'x' } else { ' ' }; @@ -310,16 +308,14 @@ impl HooksBrowserView { " to close".into(), ]), HooksBrowserPage::Handlers(event_name) => { - if self.selected_hook(event_name).is_none() { + let selected_hook = self.selected_hook(event_name); + if selected_hook.is_none() { Line::from(vec![ "Press ".into(), key_hint::plain(KeyCode::Esc).into(), " to go back".into(), ]) - } else if self - .selected_hook(event_name) - .is_some_and(|hook| hook.is_managed) - { + } else if selected_hook.is_some_and(|hook| hook.is_managed) { Line::from(vec![ "Admin managed; press ".into(), key_hint::plain(KeyCode::Esc).into(), @@ -419,10 +415,10 @@ impl Renderable for HooksBrowserView { HooksBrowserPage::Handlers(event_name) => { let row_count = self.handler_row_lines(event_name, content_width).len(); if row_count == 0 { - self.handler_header_lines(event_name).len() + 2 + Self::handler_header_lines(event_name).len() + 2 } else { let visible_row_count = row_count.min(MAX_POPUP_ROWS); - self.handler_header_lines(event_name).len() + Self::handler_header_lines(event_name).len() + 1 + visible_row_count + 1 @@ -450,7 +446,7 @@ impl Renderable for HooksBrowserView { lines } HooksBrowserPage::Handlers(event_name) => { - let mut lines = self.handler_header_lines(event_name); + let mut lines = Self::handler_header_lines(event_name); let rows = self.handler_row_lines(event_name, width); if rows.is_empty() { lines.push(Line::default()); @@ -541,7 +537,7 @@ fn config_source_label(source: HookSource) -> &'static str { HookSource::Project => "Project Config", HookSource::Mdm => "MDM", HookSource::SessionFlags => "Session Flags", - HookSource::Plugin => "Plugin", + HookSource::Plugin => unreachable!("plugin hooks are handled by summary_source"), HookSource::LegacyManagedConfigFile => "Legacy Managed Config", HookSource::LegacyManagedConfigMdm => "Legacy Managed MDM", HookSource::Unknown => "Unknown Source", @@ -831,8 +827,7 @@ mod tests { ); } - #[test] - fn space_toggles_unmanaged_handler() { + fn assert_unmanaged_toggle_key(key_code: KeyCode) { let (tx_raw, mut rx) = unbounded_channel::(); let mut view = HooksBrowserView::new( vec![hook( @@ -848,7 +843,7 @@ mod tests { AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); - view.handle_key_event(KeyEvent::from(KeyCode::Char(' '))); + view.handle_key_event(KeyEvent::from(key_code)); match rx.try_recv().expect("toggle event") { AppEvent::SetHookEnabled { key, enabled } => { @@ -860,30 +855,9 @@ mod tests { } #[test] - fn enter_toggles_unmanaged_handler() { - let (tx_raw, mut rx) = unbounded_channel::(); - let mut view = HooksBrowserView::new( - vec![hook( - "plugin:superpowers", - HookEventName::PreToolUse, - HookSource::Plugin, - Some("superpowers@openai-curated"), - "hooks/pre-tool-use-check.sh", - /*enabled*/ true, - /*is_managed*/ false, - /*display_order*/ 0, - )], - AppEventSender::new(tx_raw), - ); - view.handle_key_event(KeyEvent::from(KeyCode::Enter)); - view.handle_key_event(KeyEvent::from(KeyCode::Enter)); - - match rx.try_recv().expect("toggle event") { - AppEvent::SetHookEnabled { key, enabled } => { - assert_eq!(key, "plugin:superpowers"); - assert!(!enabled); - } - other => panic!("expected hook toggle event, got {other:?}"), + fn toggle_keys_toggle_unmanaged_handler() { + for key_code in [KeyCode::Char(' '), KeyCode::Enter] { + assert_unmanaged_toggle_key(key_code); } } From e92d89793de5fb7835a8e25a73d4be076652d05f Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 22:47:42 -0700 Subject: [PATCH 28/64] tui: show hook list diagnostics in browser --- .../tui/src/bottom_pane/hooks_browser_view.rs | 96 ++++++++++++++++--- ...sts__hooks_browser_events_with_issues.snap | 21 ++++ codex-rs/tui/src/chatwidget/hooks.rs | 6 +- ...s__hooks_popup_shows_list_diagnostics.snap | 20 ++++ .../chatwidget/tests/popups_and_settings.rs | 27 ++++++ 5 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index fbf68fa0c87..a4abc5defcc 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -1,3 +1,4 @@ +use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookMetadata; use codex_app_server_protocol::HookSource; @@ -40,6 +41,8 @@ enum HooksBrowserPage { pub(crate) struct HooksBrowserView { hooks: Vec, + warnings: Vec, + errors: Vec, page: HooksBrowserPage, state: ScrollState, complete: bool, @@ -47,10 +50,17 @@ pub(crate) struct HooksBrowserView { } impl HooksBrowserView { - pub(crate) fn new(mut hooks: Vec, app_event_tx: AppEventSender) -> Self { + pub(crate) fn new( + mut hooks: Vec, + warnings: Vec, + errors: Vec, + app_event_tx: AppEventSender, + ) -> Self { hooks.sort_by_key(|hook| hook.display_order); let mut view = Self { hooks, + warnings, + errors, page: HooksBrowserPage::Events, state: ScrollState::new(), complete: false, @@ -242,6 +252,40 @@ impl HooksBrowserView { lines } + fn event_issue_lines(&self) -> Vec> { + let mut lines = Vec::new(); + if self.warnings.is_empty() && self.errors.is_empty() { + return lines; + } + + lines.push("Issues".bold().into()); + lines.extend( + self.warnings + .iter() + .map(|warning| format!("⚠ {warning}").yellow().into()), + ); + lines.extend(self.errors.iter().map(|error| { + format!("■ {}: {}", error.path.display(), error.message) + .red() + .into() + })); + lines + } + + fn event_page_lines(&self) -> Vec> { + let mut lines = Self::event_header_lines(); + lines.push(Line::default()); + + let issue_lines = self.event_issue_lines(); + if !issue_lines.is_empty() { + lines.extend(issue_lines); + lines.push(Line::default()); + } + + lines.extend(self.event_table_lines()); + lines + } + fn handler_row_lines(&self, event_name: HookEventName, width: usize) -> Vec> { self.handlers_for_event(event_name) .enumerate() @@ -409,9 +453,7 @@ impl Renderable for HooksBrowserView { fn desired_height(&self, width: u16) -> u16 { let content_width = width.saturating_sub(4) as usize; let height = match self.page { - HooksBrowserPage::Events => { - Self::event_header_lines().len() + 1 + self.event_table_lines().len() - } + HooksBrowserPage::Events => self.event_page_lines().len(), HooksBrowserPage::Handlers(event_name) => { let row_count = self.handler_row_lines(event_name, content_width).len(); if row_count == 0 { @@ -439,12 +481,7 @@ impl Renderable for HooksBrowserView { let content_area = render_menu_surface(content_area, buf); let width = content_area.width as usize; let lines = match self.page { - HooksBrowserPage::Events => { - let mut lines = Self::event_header_lines(); - lines.push(Line::default()); - lines.extend(self.event_table_lines()); - lines - } + HooksBrowserPage::Events => self.event_page_lines(), HooksBrowserPage::Handlers(event_name) => { let mut lines = Self::handler_header_lines(event_name); let rows = self.handler_row_lines(event_name, width); @@ -700,6 +737,8 @@ mod tests { /*display_order*/ 2, ), ], + Vec::new(), + Vec::new(), AppEventSender::new(tx_raw), ) } @@ -710,6 +749,25 @@ mod tests { assert_snapshot!("hooks_browser_events", render_lines(&view, /*width*/ 112)); } + #[test] + fn renders_event_browser_with_issues() { + let (tx_raw, _rx) = unbounded_channel::(); + let view = HooksBrowserView::new( + Vec::new(), + vec!["skipped invalid matcher for PreToolUse".to_string()], + vec![HookErrorInfo { + path: test_path_buf("/tmp/hooks.json"), + message: "failed to parse hooks config".to_string(), + }], + AppEventSender::new(tx_raw), + ); + + assert_snapshot!( + "hooks_browser_events_with_issues", + render_lines(&view, /*width*/ 112) + ); + } + #[test] fn renders_handler_browser_with_details() { let mut view = view(); @@ -754,6 +812,8 @@ mod tests { /*display_order*/ 1, ), ], + Vec::new(), + Vec::new(), AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -781,7 +841,8 @@ mod tests { ) }) .collect(); - let mut view = HooksBrowserView::new(hooks, AppEventSender::new(tx_raw)); + let mut view = + HooksBrowserView::new(hooks, Vec::new(), Vec::new(), AppEventSender::new(tx_raw)); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); for _ in 0..MAX_POPUP_ROWS { view.handle_key_event(KeyEvent::from(KeyCode::Down)); @@ -806,6 +867,8 @@ mod tests { /*is_managed*/ false, /*display_order*/ 0, )], + Vec::new(), + Vec::new(), AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -818,7 +881,12 @@ mod tests { #[test] fn renders_empty_handler_browser_message() { let (tx_raw, _rx) = unbounded_channel::(); - let mut view = HooksBrowserView::new(Vec::new(), AppEventSender::new(tx_raw)); + let mut view = HooksBrowserView::new( + Vec::new(), + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); view.handle_key_event(KeyEvent::from(KeyCode::Down)); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert_snapshot!( @@ -840,6 +908,8 @@ mod tests { /*is_managed*/ false, /*display_order*/ 0, )], + Vec::new(), + Vec::new(), AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -875,6 +945,8 @@ mod tests { /*is_managed*/ true, /*display_order*/ 0, )], + Vec::new(), + Vec::new(), AppEventSender::new(tx_raw), ); view.handle_key_event(KeyEvent::from(KeyCode::Enter)); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap new file mode 100644 index 00000000000..22b1dfcec53 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap @@ -0,0 +1,21 @@ +--- +source: tui/src/bottom_pane/hooks_browser_view.rs +expression: "render_lines(&view, 112)" +--- + + Hooks + Lifecycle hooks from config and enabled plugins. + + Issues + ⚠ skipped invalid matcher for PreToolUse + ■ /tmp/hooks.json: failed to parse hooks config + + Event Installed Active Description + PreToolUse 0 0 Before a tool executes + PermissionRequest 0 0 When permission is requested + PostToolUse 0 0 After a tool executes + SessionStart 0 0 When a new session starts + UserPromptSubmit 0 0 When the user submits a prompt + Stop 0 0 Before Codex concludes a response + + Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/hooks.rs b/codex-rs/tui/src/chatwidget/hooks.rs index 48902f1463e..5e2748d3aa7 100644 --- a/codex-rs/tui/src/chatwidget/hooks.rs +++ b/codex-rs/tui/src/chatwidget/hooks.rs @@ -23,14 +23,16 @@ impl ChatWidget { match result { Ok(response) => { - let hooks = response + let (hooks, warnings, errors) = response .data .into_iter() .find(|entry| entry.cwd.as_path() == cwd.as_path()) - .map(|entry| entry.hooks) + .map(|entry| (entry.hooks, entry.warnings, entry.errors)) .unwrap_or_default(); self.bottom_pane.show_view(Box::new(HooksBrowserView::new( hooks, + warnings, + errors, self.app_event_tx.clone(), ))); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap new file mode 100644 index 00000000000..6562b4b56e6 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: popup +--- + Hooks + Lifecycle hooks from config and enabled plugins. + + Issues + ⚠ skipped invalid matcher for PreToolUse + ■ /tmp/hooks.json: failed to parse hooks config + + Event Installed Active Description + PreToolUse 0 0 Before a tool executes + PermissionRequest 0 0 When permission is requested + PostToolUse 0 0 After a tool executes + SessionStart 0 0 When a new session starts + UserPromptSubmit 0 0 When the user submits a prompt + Stop 0 0 Before Codex concludes a response + + Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 3977d9d0eec..2ae722df8a4 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1,5 +1,8 @@ use super::*; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::HookErrorInfo; +use codex_app_server_protocol::HooksListEntry; +use codex_app_server_protocol::HooksListResponse; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -101,6 +104,30 @@ async fn plugins_popup_loading_state_snapshot() { assert_chatwidget_snapshot!("plugins_popup_loading_state", popup); } +#[tokio::test] +async fn hooks_popup_shows_list_diagnostics() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let cwd = chat.config.cwd.clone(); + + chat.on_hooks_loaded( + cwd.to_path_buf(), + Ok(HooksListResponse { + data: vec![HooksListEntry { + cwd: cwd.to_path_buf(), + hooks: Vec::new(), + warnings: vec!["skipped invalid matcher for PreToolUse".to_string()], + errors: vec![HookErrorInfo { + path: test_path_buf("/tmp/hooks.json"), + message: "failed to parse hooks config".to_string(), + }], + }], + }), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 112); + assert_chatwidget_snapshot!("hooks_popup_shows_list_diagnostics", popup); +} + #[tokio::test] async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_name() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 6eb99d3334d1b22c097bc9a7405420a1d56a6469 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 22:49:59 -0700 Subject: [PATCH 29/64] Expose managed hooks and reject user toggles --- .../codex_app_server_protocol.schemas.json | 4 + .../codex_app_server_protocol.v2.schemas.json | 4 + .../schema/json/v2/HooksListResponse.json | 4 + .../schema/typescript/v2/HookMetadata.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 1 + codex-rs/app-server/README.md | 7 +- .../app-server/src/codex_message_processor.rs | 81 +++++++++++++++++++ codex-rs/hooks/src/engine/discovery.rs | 1 + codex-rs/hooks/src/engine/mod.rs | 1 + codex-rs/hooks/src/engine/mod_tests.rs | 2 + 10 files changed, 103 insertions(+), 4 deletions(-) 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 7c531f72689..18c9bd70966 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 @@ -9662,6 +9662,9 @@ "handlerType": { "$ref": "#/definitions/v2/HookHandlerType" }, + "isManaged": { + "type": "boolean" + }, "key": { "type": "string" }, @@ -9700,6 +9703,7 @@ "enabled", "eventName", "handlerType", + "isManaged", "key", "source", "sourcePath", 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 b6bdea0ae23..6d89f823705 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 @@ -6292,6 +6292,9 @@ "handlerType": { "$ref": "#/definitions/HookHandlerType" }, + "isManaged": { + "type": "boolean" + }, "key": { "type": "string" }, @@ -6330,6 +6333,7 @@ "enabled", "eventName", "handlerType", + "isManaged", "key", "source", "sourcePath", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index b0dff3844c8..e72c3dfd9e5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -60,6 +60,9 @@ "handlerType": { "$ref": "#/definitions/HookHandlerType" }, + "isManaged": { + "type": "boolean" + }, "key": { "type": "string" }, @@ -98,6 +101,7 @@ "enabled", "eventName", "handlerType", + "isManaged", "key", "source", "sourcePath", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index fd1ce7f25b0..8ccd2b1825a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -6,4 +6,4 @@ import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; -export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, }; +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index b6a7dfb1537..2f7024ede5f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4497,6 +4497,7 @@ pub struct HookMetadata { pub plugin_id: Option, pub display_order: i64, pub enabled: bool, + pub is_managed: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c6f5ac515fd..58b74f5f13e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1452,7 +1452,7 @@ To enable or disable a skill by name: } ``` -Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. +Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Managed hooks are returned with `"isManaged": true` and cannot be changed through `hooks/config/write`. ```json { @@ -1482,7 +1482,8 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "source": "user", "pluginId": null, "displayOrder": 0, - "enabled": true + "enabled": true, + "isManaged": false }], "warnings": [], "errors": [] @@ -1491,7 +1492,7 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled } ``` -To enable or disable a hook, write the hook key returned by `hooks/list`: +To enable or disable a non-managed hook, write the hook key returned by `hooks/list`: ```json { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index b25649b00fb..e3de08e1f72 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6423,6 +6423,18 @@ impl CodexMessageProcessor { return; } + let current_workspace_hooks = match self.current_workspace_hooks().await { + Ok(hooks) => hooks, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + if let Err(error) = validate_hook_config_write_target(¤t_workspace_hooks, &key) { + self.outgoing.send_error(request_id, error).await; + return; + } + let result = ConfigEditsBuilder::new(&self.config.codex_home) .with_edits(vec![ConfigEdit::SetHookConfig { key, enabled }]) .apply() @@ -6451,6 +6463,26 @@ impl CodexMessageProcessor { } } + async fn current_workspace_hooks(&self) -> Result, JSONRPCErrorError> { + let HooksListResponse { mut data } = self + .hooks_list_response(HooksListParams { cwds: Vec::new() }) + .await?; + let Some(entry) = data.pop() else { + return Ok(Vec::new()); + }; + if let Some(error) = entry.errors.into_iter().next() { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to resolve current workspace hooks: {}", + error.message + ), + data: None, + }); + } + Ok(entry.hooks) + } + async fn turn_start( &self, request_id: ConnectionRequestId, @@ -8664,10 +8696,26 @@ fn hooks_to_info(hooks: &[codex_core::hooks::HookListEntry]) -> Vec Result<(), JSONRPCErrorError> { + if hooks.iter().any(|hook| hook.key == key && hook.is_managed) { + return Err(JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: format!("hook {key} is managed and cannot be configured"), + data: None, + }); + } + + Ok(()) +} + fn plugin_skills_to_info( skills: &[codex_core::skills::SkillMetadata], disabled_skill_paths: &std::collections::HashSet, @@ -9792,6 +9840,39 @@ mod tests { use std::sync::Arc; use tempfile::TempDir; + fn hook_metadata_for_test(key: &str, is_managed: bool) -> HookMetadata { + HookMetadata { + key: key.to_string(), + event_name: codex_app_server_protocol::HookEventName::PreToolUse, + handler_type: codex_app_server_protocol::HookHandlerType::Command, + matcher: Some("^Bash$".to_string()), + command: Some("python3 /tmp/hook.py".to_string()), + timeout_sec: 10, + status_message: Some("checking".to_string()), + source_path: test_path_buf("/tmp/hooks.json").abs(), + source: codex_app_server_protocol::HookSource::Mdm, + plugin_id: None, + display_order: 0, + enabled: true, + is_managed, + } + } + + #[test] + fn managed_hook_config_write_targets_are_rejected() { + let key = "path:/tmp/managed-hooks:pre_tool_use:0:0"; + let hooks = vec![hook_metadata_for_test(key, true)]; + + let err = validate_hook_config_write_target(&hooks, key) + .expect_err("managed hooks should not be user-configurable"); + + assert_eq!(err.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!( + err.message, + format!("hook {key} is managed and cannot be configured") + ); + } + #[test] fn validate_dynamic_tools_rejects_unsupported_input_schema() { let tools = vec![ApiDynamicToolSpec { diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index cf0162cae86..71c557e2dc5 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -428,6 +428,7 @@ fn append_matcher_groups( plugin_id: source.plugin_id.clone(), display_order: *display_order, enabled, + is_managed: source.is_managed, }); if enabled { handlers.push(ConfiguredHandler { diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 8b802dd726a..cc3bc11eb00 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -83,6 +83,7 @@ pub struct HookListEntry { pub plugin_id: Option, pub display_order: i64, pub enabled: bool, + pub is_managed: bool, } #[derive(Clone)] diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index a392c62b228..5cb58c8422a 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -265,7 +265,9 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); assert_eq!(discovered.hook_entries.len(), 2); assert_eq!(discovered.hook_entries[0].enabled, true); + assert!(discovered.hook_entries[0].is_managed); assert_eq!(discovered.hook_entries[1].enabled, false); + assert!(!discovered.hook_entries[1].is_managed); } #[test] From b48e305362e5d5fe952a8b93277b1fc550433315 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 22:57:09 -0700 Subject: [PATCH 30/64] tui: avoid yellow in hooks diagnostics --- codex-rs/tui/src/bottom_pane/hooks_browser_view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index a4abc5defcc..1ecc0cee65a 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -262,7 +262,7 @@ impl HooksBrowserView { lines.extend( self.warnings .iter() - .map(|warning| format!("⚠ {warning}").yellow().into()), + .map(|warning| format!("⚠ {warning}").into()), ); lines.extend(self.errors.iter().map(|error| { format!("■ {}: {}", error.path.display(), error.message) From 99edca4b6b1e84d86e978794a6469b3d062664a2 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 23:01:59 -0700 Subject: [PATCH 31/64] tui: stabilize status snapshot sandbox setup --- ...ched_limits_hide_credits_without_flag.snap | 34 +++++++++---------- ..._snapshot_includes_credits_and_limits.snap | 34 +++++++++---------- ..._status_snapshot_includes_forked_from.snap | 34 +++++++++---------- ...tatus_snapshot_includes_monthly_limit.snap | 30 ++++++++-------- ...snapshot_shows_missing_limits_message.snap | 30 ++++++++-------- ...apshot_shows_refreshing_limits_notice.snap | 32 ++++++++--------- ...s_snapshot_shows_stale_limits_message.snap | 34 +++++++++---------- ...shot_shows_unavailable_limits_message.snap | 30 ++++++++-------- ...efreshing_empty_limits_as_unavailable.snap | 30 ++++++++-------- ...snapshot_truncates_in_narrow_terminal.snap | 2 +- ...s_default_reasoning_when_config_empty.snap | 30 ++++++++-------- codex-rs/tui/src/status/tests.rs | 8 +++-- 12 files changed, 166 insertions(+), 162 deletions(-) diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap index ae85660b9ef..4c2018abf1f 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_cached_limits_hide_credits_without_flag.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 1.05K total (700 input + 350 output) │ -│ Context window: 100% left (1.45K used / 272K) │ -│ 5h limit: [████████░░░░░░░░░░░░] 40% left (resets 11:32) │ -│ Weekly limit: [█████████████░░░░░░░] 65% left (resets 11:52) │ -│ Warning: limits may be stale - start new turn to refresh. │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.05K total (700 input + 350 output) │ +│ Context window: 100% left (1.45K used / 272K) │ +│ 5h limit: [████████░░░░░░░░░░░░] 40% left (resets 11:32) │ +│ Weekly limit: [█████████████░░░░░░░] 65% left (resets 11:52) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰─────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap index f01a6261112..6cc7a8946ce 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_credits_and_limits.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 2K total (1.4K input + 600 output) │ -│ Context window: 100% left (2.2K used / 272K) │ -│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 09:25) │ -│ Weekly limit: [██████████████░░░░░░] 70% left (resets 09:55) │ -│ Credits: 38 credits │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 2K total (1.4K input + 600 output) │ +│ Context window: 100% left (2.2K used / 272K) │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 09:25) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets 09:55) │ +│ Credits: 38 credits │ +╰───────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap index b473b4cd0c2..fa0df32ed48 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_forked_from.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ -│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ -│ │ -│ Token usage: 1.2K total (800 input + 400 output) │ -│ Context window: 100% left (1.2K used / 272K) │ -│ Limits: data not available yet │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ Session: 0f0f3c13-6cf9-4aa4-8b80-7d49c2f1be2e │ +│ Forked from: e9f18a88-8081-4e51-9d4e-8af5cde2d8dd │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap index e8be9989979..027d935c705 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_monthly_limit.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 1.2K total (800 input + 400 output) │ -│ Context window: 100% left (1.2K used / 272K) │ -│ Monthly limit: [██████████████████░░] 88% left (resets 07:08 on 7 May) │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.2K total (800 input + 400 output) │ +│ Context window: 100% left (1.2K used / 272K) │ +│ Monthly limit: [██████████████████░░] 88% left (resets 07:08 on 7 May) │ +╰────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap index 948ab6ef7b9..6db1821d4c2 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_missing_limits_message.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: data not available yet │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: data not available yet │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap index ad81d5da7b5..2b31afce0c1 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_refreshing_limits_notice.snap @@ -4,19 +4,19 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 08:24) │ -│ Weekly limit: [██████████████░░░░░░] 70% left (resets 08:54) │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 08:24) │ +│ Weekly limit: [██████████████░░░░░░] 70% left (resets 08:54) │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap index e07b400e72b..e6043b65f3d 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_stale_limits_message.snap @@ -4,20 +4,20 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 1.9K total (1K input + 900 output) │ -│ Context window: 100% left (2.25K used / 272K) │ -│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ -│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │ -│ Warning: limits may be stale - start new turn to refresh. │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 1.9K total (1K input + 900 output) │ +│ Context window: 100% left (2.25K used / 272K) │ +│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │ +│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │ +│ Warning: limits may be stale - start new turn to refresh. │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap index 99c2d322684..62c6cc3375e 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_unavailable_limits_message.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: not available for this account │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: not available for this account │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap index 99c2d322684..62c6cc3375e 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_treats_refreshing_empty_limits_as_unavailable.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: not available for this account │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭───────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: not available for this account │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap index 0a342b5f13e..91057d482bd 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -12,7 +12,7 @@ expression: sanitized │ │ │ Model: gpt-5.1-codex-max (reasoning high, summaries de │ │ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on │ +│ Permissions: Custom (read-only, on-request) │ │ Agents.md: │ │ │ │ Token usage: 1.9K total (1K input + 900 output) │ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap index e0ceb5b6807..de0ea5056dc 100644 --- a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_uses_default_reasoning_when_config_empty.snap @@ -4,18 +4,18 @@ expression: sanitized --- /status -╭─────────────────────────────────────────────────────────────────────────────╮ -│ >_ OpenAI Codex (v0.0.0) │ -│ │ -│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ -│ information on rate limits and credits │ -│ │ -│ Model: gpt-5.1-codex-max (reasoning medium, summaries auto) │ -│ Directory: [[workspace]] │ -│ Permissions: Custom (workspace-write with network access, on-request) │ -│ Agents.md: │ -│ │ -│ Token usage: 750 total (500 input + 250 output) │ -│ Context window: 100% left (750 used / 272K) │ -│ Limits: data not available yet │ -╰─────────────────────────────────────────────────────────────────────────────╯ +╭─────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning medium, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (read-only, on-request) │ +│ Agents.md: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 03a988c793c..f13817ceb04 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -27,11 +27,15 @@ use ratatui::prelude::*; use tempfile::TempDir; async fn test_config(temp_home: &TempDir) -> Config { - ConfigBuilder::default() + let mut config = ConfigBuilder::default() .codex_home(temp_home.path().to_path_buf()) .build() .await - .expect("load config") + .expect("load config"); + config + .set_legacy_sandbox_policy(SandboxPolicy::new_read_only_policy()) + .expect("set sandbox policy"); + config } fn test_status_account_display() -> Option { From 5a2394653f027189b02e975beb91cfb6cdb8ae9a Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 27 Apr 2026 23:05:23 -0700 Subject: [PATCH 32/64] Refresh live hooks after config writes --- .../app-server/src/codex_message_processor.rs | 2 + codex-rs/core/src/mcp_tool_call_tests.rs | 25 +++++---- codex-rs/core/src/session/mod.rs | 56 ++++++++++++++++--- codex-rs/core/src/session/session.rs | 27 +-------- codex-rs/core/src/session/tests.rs | 45 +++++++++++++-- codex-rs/core/src/state/service.rs | 3 +- .../runtimes/shell/unix_escalation_tests.rs | 20 ++++--- 7 files changed, 122 insertions(+), 56 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e3de08e1f72..efb2b3b684c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2,6 +2,7 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed; use crate::command_exec::CommandExecManager; use crate::command_exec::StartCommandExecParams; +use crate::config_api::UserConfigReloader; use crate::config_manager::ConfigManager; use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; use crate::error_code::INTERNAL_ERROR_CODE; @@ -6443,6 +6444,7 @@ impl CodexMessageProcessor { match result { Ok(()) => { self.clear_plugin_related_caches(); + self.thread_manager.reload_user_config().await; self.outgoing .send_response( request_id, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 8b3f22770ad..7eb2c1818d2 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -139,17 +139,20 @@ print({hook_output:?}) ) .expect("write hooks.json"); - session.services.hooks = Hooks::new(HooksConfig { - feature_enabled: true, - config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), - shell_program: (!cfg!(windows)).then_some("/bin/sh".to_string()), - shell_args: if cfg!(windows) { - Vec::new() - } else { - vec!["-c".to_string()] - }, - ..HooksConfig::default() - }); + session + .services + .hooks + .store(Arc::new(Hooks::new(HooksConfig { + feature_enabled: true, + config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), + shell_program: (!cfg!(windows)).then_some("/bin/sh".to_string()), + shell_args: if cfg!(windows) { + Vec::new() + } else { + vec!["-c".to_string()] + }, + ..HooksConfig::default() + }))); log_path.to_path_buf() } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1ebe2b4fc75..ed8e3bb1509 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1390,14 +1390,26 @@ impl Session { } }; - let mut state = self.state.lock().await; - let mut config = (*state.session_configuration.original_config_do_not_use).clone(); - config.config_layer_stack = config - .config_layer_stack - .with_user_config(&config_toml_path, user_config); - state.session_configuration.original_config_do_not_use = Arc::new(config); + let config = { + let mut state = self.state.lock().await; + let mut config = (*state.session_configuration.original_config_do_not_use).clone(); + config.config_layer_stack = config + .config_layer_stack + .with_user_config(&config_toml_path, user_config); + let config = Arc::new(config); + state.session_configuration.original_config_do_not_use = Arc::clone(&config); + config + }; self.services.skills_manager.clear_cache(); self.services.plugins_manager.clear_cache(); + self.services.hooks.store(Arc::new( + build_hooks_for_config( + config.as_ref(), + self.services.plugins_manager.as_ref(), + self.services.user_shell.as_ref(), + ) + .await, + )); } async fn build_settings_update_items( @@ -3198,8 +3210,8 @@ impl Session { } } - pub(crate) fn hooks(&self) -> &Hooks { - &self.services.hooks + pub(crate) fn hooks(&self) -> Arc { + self.services.hooks.load_full() } pub(crate) fn user_shell(&self) -> Arc { @@ -3327,3 +3339,31 @@ use codex_memories_read::build_memory_tool_developer_instructions; #[cfg(test)] pub(crate) mod tests; +async fn build_hooks_for_config( + config: &Config, + plugins_manager: &PluginsManager, + user_shell: &crate::shell::Shell, +) -> Hooks { + let mut hook_shell_argv = user_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()) + }; + 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/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 351e3049ab9..f9f4843e753 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -750,29 +750,8 @@ impl Session { (None, None) }; - let mut hook_shell_argv = - 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, - }); + let hooks = + build_hooks_for_config(&config, plugins_manager.as_ref(), &default_shell).await; for warning in hooks.startup_warnings() { post_session_configured_events.push(Event { id: INITIAL_SUBMIT_ID.to_owned(), @@ -809,7 +788,7 @@ impl Session { shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client, - hooks, + hooks: arc_swap::ArcSwap::from_pointee(hooks), rollout_thread_trace, user_shell: Arc::new(default_shell), shell_snapshot_tx, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 283220e8fa2..82b22baa29e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1134,6 +1134,43 @@ async fn reload_user_config_layer_updates_effective_apps_config() { assert_eq!(app.destructive_enabled, Some(false)); } +#[tokio::test] +async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> { + let session = make_session_with_config(|config| { + config + .features + .enable(Feature::CodexHooks) + .expect("enable Codex hooks"); + }) + .await?; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home)?; + std::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +[hooks] + +[[hooks.SessionStart]] +hooks = [{ type = "command", command = "python3 /tmp/user.py" }] +"#, + )?; + + let request = codex_hooks::SessionStartRequest { + session_id: session.conversation_id, + cwd: session.get_config().await.cwd.clone(), + transcript_path: None, + model: "gpt-5.2".to_string(), + permission_mode: "default".to_string(), + source: codex_hooks::SessionStartSource::Startup, + }; + assert!(session.hooks().preview_session_start(&request).is_empty()); + + session.reload_user_config_layer().await; + + assert_eq!(session.hooks().preview_session_start(&request).len(), 1); + Ok(()) +} + #[test] fn filter_connectors_for_input_skips_duplicate_slug_mentions() { let connectors = vec![ @@ -3424,10 +3461,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.chatgpt_base_url.trim_end_matches('/').to_string(), config.analytics_enabled, ), - hooks: Hooks::new(HooksConfig { + hooks: arc_swap::ArcSwap::from_pointee(Hooks::new(HooksConfig { legacy_notify_argv: config.notify.clone(), ..HooksConfig::default() - }), + })), rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(), user_shell: Arc::new(default_user_shell()), shell_snapshot_tx: watch::channel(None).0, @@ -4783,10 +4820,10 @@ where config.chatgpt_base_url.trim_end_matches('/').to_string(), config.analytics_enabled, ), - hooks: Hooks::new(HooksConfig { + hooks: arc_swap::ArcSwap::from_pointee(Hooks::new(HooksConfig { legacy_notify_argv: config.notify.clone(), ..HooksConfig::default() - }), + })), rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(), user_shell: Arc::new(default_user_shell()), shell_snapshot_tx: watch::channel(None).0, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index fe27d89ae08..ddf21221722 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -15,6 +15,7 @@ use crate::tools::code_mode::CodeModeService; use crate::tools::network_approval::NetworkApprovalService; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecProcessManager; +use arc_swap::ArcSwap; use codex_analytics::AnalyticsEventsClient; use codex_exec_server::EnvironmentManager; use codex_hooks::Hooks; @@ -42,7 +43,7 @@ pub(crate) struct SessionServices { #[cfg_attr(not(unix), allow(dead_code))] pub(crate) main_execve_wrapper_exe: Option, pub(crate) analytics_events_client: AnalyticsEventsClient, - pub(crate) hooks: Hooks, + pub(crate) hooks: ArcSwap, pub(crate) rollout_thread_trace: ThreadTraceContext, pub(crate) user_shell: Arc, pub(crate) shell_snapshot_tx: watch::Sender>>, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 2ec5ede4a82..84e469e22d1 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -38,6 +38,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::Value; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use tokio::sync::RwLock; @@ -324,7 +325,7 @@ fn shell_request_escalation_execution_is_explicit() { #[tokio::test(flavor = "current_thread")] async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Result<()> { - let (mut session, mut turn_context) = make_session_and_context().await; + let (session, mut turn_context) = make_session_and_context().await; std::fs::create_dir_all(&turn_context.config.codex_home) .context("recreate codex home for hook fixtures")?; let script_path = turn_context @@ -376,13 +377,16 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul .derive_exec_args("", /*use_login_shell*/ false); let hook_shell_program = hook_shell_argv.remove(0); let _ = hook_shell_argv.pop(); - session.services.hooks = Hooks::new(HooksConfig { - feature_enabled: true, - config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), - shell_program: Some(hook_shell_program), - shell_args: hook_shell_argv, - ..HooksConfig::default() - }); + session + .services + .hooks + .store(Arc::new(Hooks::new(HooksConfig { + feature_enabled: true, + config_layer_stack: Some(turn_context.config.config_layer_stack.clone()), + shell_program: Some(hook_shell_program), + shell_args: hook_shell_argv, + ..HooksConfig::default() + }))); turn_context.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); turn_context.permission_profile = PermissionProfile::from_runtime_permissions( From 2f621576c27ad6c27f10f15cca40c83806381e08 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 10:04:42 -0700 Subject: [PATCH 33/64] Namespace hook keys by source --- .../codex_app_server_protocol.schemas.json | 4 -- .../codex_app_server_protocol.v2.schemas.json | 4 -- .../schema/json/v2/HooksListResponse.json | 4 -- .../schema/typescript/v2/HookMetadata.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 1 - codex-rs/app-server/README.md | 9 ++- .../app-server/src/codex_message_processor.rs | 60 ++----------------- .../app-server/tests/suite/v2/hooks_list.rs | 1 + codex-rs/core/src/config/edit_tests.rs | 8 +-- codex-rs/hooks/src/engine/discovery.rs | 9 ++- codex-rs/hooks/src/engine/mod.rs | 1 - codex-rs/hooks/src/engine/mod_tests.rs | 18 ++++-- 12 files changed, 31 insertions(+), 90 deletions(-) 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 18c9bd70966..7c531f72689 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 @@ -9662,9 +9662,6 @@ "handlerType": { "$ref": "#/definitions/v2/HookHandlerType" }, - "isManaged": { - "type": "boolean" - }, "key": { "type": "string" }, @@ -9703,7 +9700,6 @@ "enabled", "eventName", "handlerType", - "isManaged", "key", "source", "sourcePath", 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 6d89f823705..b6bdea0ae23 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 @@ -6292,9 +6292,6 @@ "handlerType": { "$ref": "#/definitions/HookHandlerType" }, - "isManaged": { - "type": "boolean" - }, "key": { "type": "string" }, @@ -6333,7 +6330,6 @@ "enabled", "eventName", "handlerType", - "isManaged", "key", "source", "sourcePath", diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index e72c3dfd9e5..b0dff3844c8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -60,9 +60,6 @@ "handlerType": { "$ref": "#/definitions/HookHandlerType" }, - "isManaged": { - "type": "boolean" - }, "key": { "type": "string" }, @@ -101,7 +98,6 @@ "enabled", "eventName", "handlerType", - "isManaged", "key", "source", "sourcePath", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index 8ccd2b1825a..fd1ce7f25b0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -6,4 +6,4 @@ import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; -export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, }; +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 2f7024ede5f..b6a7dfb1537 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4497,7 +4497,6 @@ pub struct HookMetadata { pub plugin_id: Option, pub display_order: i64, pub enabled: bool, - pub is_managed: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 58b74f5f13e..b41fcbdd112 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1452,7 +1452,7 @@ To enable or disable a skill by name: } ``` -Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Managed hooks are returned with `"isManaged": true` and cannot be changed through `hooks/config/write`. +Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Managed hook keys use the `managed:` prefix and cannot be changed through `hooks/config/write`. Hook keys are source-namespaced with `file:`, `managed:`, or `plugin:` prefixes; the trailing event/group/handler selector is currently positional. ```json { @@ -1471,7 +1471,7 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "data": [{ "cwd": "/Users/me/project", "hooks": [{ - "key": "path:/Users/me/.codex/config.toml:pre_tool_use:0:0", + "key": "file:/Users/me/.codex/config.toml:pre_tool_use:0:0", "eventName": "pre_tool_use", "handlerType": "command", "matcher": "Bash", @@ -1482,8 +1482,7 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "source": "user", "pluginId": null, "displayOrder": 0, - "enabled": true, - "isManaged": false + "enabled": true }], "warnings": [], "errors": [] @@ -1499,7 +1498,7 @@ To enable or disable a non-managed hook, write the hook key returned by `hooks/l "method": "hooks/config/write", "id": 29, "params": { - "key": "path:/Users/me/.codex/config.toml:pre_tool_use:0:0", + "key": "file:/Users/me/.codex/config.toml:pre_tool_use:0:0", "enabled": false } } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index efb2b3b684c..735fcddf01f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6424,14 +6424,7 @@ impl CodexMessageProcessor { return; } - let current_workspace_hooks = match self.current_workspace_hooks().await { - Ok(hooks) => hooks, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - if let Err(error) = validate_hook_config_write_target(¤t_workspace_hooks, &key) { + if let Err(error) = validate_hook_config_write_target(&key) { self.outgoing.send_error(request_id, error).await; return; } @@ -6465,26 +6458,6 @@ impl CodexMessageProcessor { } } - async fn current_workspace_hooks(&self) -> Result, JSONRPCErrorError> { - let HooksListResponse { mut data } = self - .hooks_list_response(HooksListParams { cwds: Vec::new() }) - .await?; - let Some(entry) = data.pop() else { - return Ok(Vec::new()); - }; - if let Some(error) = entry.errors.into_iter().next() { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to resolve current workspace hooks: {}", - error.message - ), - data: None, - }); - } - Ok(entry.hooks) - } - async fn turn_start( &self, request_id: ConnectionRequestId, @@ -8698,16 +8671,12 @@ fn hooks_to_info(hooks: &[codex_core::hooks::HookListEntry]) -> Vec Result<(), JSONRPCErrorError> { - if hooks.iter().any(|hook| hook.key == key && hook.is_managed) { +fn validate_hook_config_write_target(key: &str) -> Result<(), JSONRPCErrorError> { + if key.starts_with("managed:") { return Err(JSONRPCErrorError { code: INVALID_PARAMS_ERROR_CODE, message: format!("hook {key} is managed and cannot be configured"), @@ -9842,30 +9811,11 @@ mod tests { use std::sync::Arc; use tempfile::TempDir; - fn hook_metadata_for_test(key: &str, is_managed: bool) -> HookMetadata { - HookMetadata { - key: key.to_string(), - event_name: codex_app_server_protocol::HookEventName::PreToolUse, - handler_type: codex_app_server_protocol::HookHandlerType::Command, - matcher: Some("^Bash$".to_string()), - command: Some("python3 /tmp/hook.py".to_string()), - timeout_sec: 10, - status_message: Some("checking".to_string()), - source_path: test_path_buf("/tmp/hooks.json").abs(), - source: codex_app_server_protocol::HookSource::Mdm, - plugin_id: None, - display_order: 0, - enabled: true, - is_managed, - } - } - #[test] fn managed_hook_config_write_targets_are_rejected() { - let key = "path:/tmp/managed-hooks:pre_tool_use:0:0"; - let hooks = vec![hook_metadata_for_test(key, true)]; + let key = "managed:/tmp/managed-hooks:pre_tool_use:0:0"; - let err = validate_hook_config_write_target(&hooks, key) + let err = validate_hook_config_write_target(key) .expect_err("managed hooks should not be user-configurable"); assert_eq!(err.code, INVALID_PARAMS_ERROR_CODE); diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 160e049efe5..10bd9e37cb8 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -59,6 +59,7 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { assert_eq!(data[0].cwd.as_path(), cwd.path()); assert_eq!(data[0].hooks.len(), 1); let hook = &data[0].hooks[0]; + assert!(hook.key.starts_with("file:")); assert_eq!(hook.event_name, HookEventName::PreToolUse); assert_eq!(hook.matcher.as_deref(), Some("Bash")); assert_eq!(hook.command.as_deref(), Some("python3 /tmp/listed-hook.py")); diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 6461c8e8c06..8499132e439 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -140,7 +140,7 @@ fn set_hook_config_writes_disabled_entry() { ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::SetHookConfig { - key: "path:/tmp/hooks.json:pre_tool_use:0:0".to_string(), + key: "file:/tmp/hooks.json:pre_tool_use:0:0".to_string(), enabled: false, }]) .apply_blocking() @@ -148,7 +148,7 @@ fn set_hook_config_writes_disabled_entry() { let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[[hooks.config]] -key = "path:/tmp/hooks.json:pre_tool_use:0:0" +key = "file:/tmp/hooks.json:pre_tool_use:0:0" enabled = false "#; assert_eq!(contents, expected); @@ -161,7 +161,7 @@ fn set_hook_config_removes_entry_when_enabled() { std::fs::write( codex_home.join(CONFIG_TOML_FILE), r#"[[hooks.config]] -key = "path:/tmp/hooks.json:pre_tool_use:0:0" +key = "file:/tmp/hooks.json:pre_tool_use:0:0" enabled = false "#, ) @@ -169,7 +169,7 @@ enabled = false ConfigEditsBuilder::new(codex_home) .with_edits([ConfigEdit::SetHookConfig { - key: "path:/tmp/hooks.json:pre_tool_use:0:0".to_string(), + key: "file:/tmp/hooks.json:pre_tool_use:0:0".to_string(), enabled: true, }]) .apply_blocking() diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 71c557e2dc5..be9f186d885 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -92,7 +92,7 @@ pub(crate) fn discover_handlers( &mut display_order, HookHandlerSource { path: &source_path, - key_prefix: format!("path:{}", source_path.display()), + key_prefix: format!("file:{}", source_path.display()), is_managed: false, source: hook_source, hook_config_rules: &hook_config_rules, @@ -144,7 +144,7 @@ fn append_managed_requirement_handlers( display_order, HookHandlerSource { path: &source_path, - key_prefix: format!("path:{}", source_path.display()), + key_prefix: format!("managed:{}", source_path.display()), is_managed: true, source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), hook_config_rules, @@ -406,7 +406,7 @@ fn append_matcher_groups( command.replace(&format!("${{{key}}}"), value) }); let timeout_sec = timeout_sec.unwrap_or(600).max(1); - // TODO(abhinav): replace this positional selector with a durable hook id. + // TODO(abhinav): replace this positional suffix with a durable hook id. let key = format!( "{}:{}:{}:{}", source.key_prefix, @@ -428,7 +428,6 @@ fn append_matcher_groups( plugin_id: source.plugin_id.clone(), display_order: *display_order, enabled, - is_managed: source.is_managed, }); if enabled { handlers.push(ConfiguredHandler { @@ -530,7 +529,7 @@ mod tests { ) -> super::HookHandlerSource<'a> { super::HookHandlerSource { path, - key_prefix: format!("path:{}", path.display()), + key_prefix: format!("file:{}", path.display()), is_managed: false, source: hook_source(), hook_config_rules, diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index cc3bc11eb00..8b802dd726a 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -83,7 +83,6 @@ pub struct HookListEntry { pub plugin_id: Option, pub display_order: i64, pub enabled: bool, - pub is_managed: bool, } #[derive(Clone)] diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 5cb58c8422a..26c71267e92 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -179,8 +179,8 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ); let config_path = AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path"); - let managed_disabled_key = format!("path:{}:pre_tool_use:0:0", managed_dir.display()); - let user_disabled_key = format!("path:{}:pre_tool_use:0:0", config_path.display()); + let managed_disabled_key = format!("managed:{}:pre_tool_use:0:0", managed_dir.display()); + let user_disabled_key = format!("file:{}:pre_tool_use:0:0", config_path.display()); let mut user_config = TomlValue::Table(Default::default()); let TomlValue::Table(user_config_entries) = &mut user_config else { unreachable!("config TOML root should be a table"); @@ -193,13 +193,19 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { let TomlValue::Table(managed_config_entries) = &mut managed_config else { unreachable!("hook config should be a table"); }; - managed_config_entries.insert("key".to_string(), TomlValue::String(managed_disabled_key)); + managed_config_entries.insert( + "key".to_string(), + TomlValue::String(managed_disabled_key.clone()), + ); managed_config_entries.insert("enabled".to_string(), TomlValue::Boolean(false)); let mut user_hook_config = TomlValue::Table(Default::default()); let TomlValue::Table(user_hook_config_entries) = &mut user_hook_config else { unreachable!("hook config should be a table"); }; - user_hook_config_entries.insert("key".to_string(), TomlValue::String(user_disabled_key)); + user_hook_config_entries.insert( + "key".to_string(), + TomlValue::String(user_disabled_key.clone()), + ); user_hook_config_entries.insert("enabled".to_string(), TomlValue::Boolean(false)); hooks_entries.insert( "config".to_string(), @@ -264,10 +270,10 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { let discovered = super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); assert_eq!(discovered.hook_entries.len(), 2); + assert_eq!(discovered.hook_entries[0].key, managed_disabled_key); assert_eq!(discovered.hook_entries[0].enabled, true); - assert!(discovered.hook_entries[0].is_managed); + assert_eq!(discovered.hook_entries[1].key, user_disabled_key); assert_eq!(discovered.hook_entries[1].enabled, false); - assert!(!discovered.hook_entries[1].is_managed); } #[test] From bb897c15c8a8dee3692abbfd3d5a2f23801f5215 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 10:19:36 -0700 Subject: [PATCH 34/64] Inline managed hook config validation --- codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 33 ++++--------------- 2 files changed, 7 insertions(+), 28 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b41fcbdd112..fc054cfd241 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -195,7 +195,7 @@ Example with notification opt-out: - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). -- `hooks/list` — list discovered hooks for one or more `cwd` values, including hooks disabled by user config. +- `hooks/list` — list discovered hooks for one or more `cwd` values. - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 735fcddf01f..2f0791f5e53 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6424,7 +6424,12 @@ impl CodexMessageProcessor { return; } - if let Err(error) = validate_hook_config_write_target(&key) { + if key.starts_with("managed:") { + let error = JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message: format!("hook {key} is managed and cannot be configured"), + data: None, + }; self.outgoing.send_error(request_id, error).await; return; } @@ -8675,18 +8680,6 @@ fn hooks_to_info(hooks: &[codex_core::hooks::HookListEntry]) -> Vec Result<(), JSONRPCErrorError> { - if key.starts_with("managed:") { - return Err(JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: format!("hook {key} is managed and cannot be configured"), - data: None, - }); - } - - Ok(()) -} - fn plugin_skills_to_info( skills: &[codex_core::skills::SkillMetadata], disabled_skill_paths: &std::collections::HashSet, @@ -9811,20 +9804,6 @@ mod tests { use std::sync::Arc; use tempfile::TempDir; - #[test] - fn managed_hook_config_write_targets_are_rejected() { - let key = "managed:/tmp/managed-hooks:pre_tool_use:0:0"; - - let err = validate_hook_config_write_target(key) - .expect_err("managed hooks should not be user-configurable"); - - assert_eq!(err.code, INVALID_PARAMS_ERROR_CODE); - assert_eq!( - err.message, - format!("hook {key} is managed and cannot be configured") - ); - } - #[test] fn validate_dynamic_tools_rejects_unsupported_input_schema() { let tools = vec![ApiDynamicToolSpec { From c51a4dc3d33d141fbf2b66091f303d1e57d784b2 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 10:32:00 -0700 Subject: [PATCH 35/64] Clean up hook config model --- codex-rs/app-server/src/config_api.rs | 1 - codex-rs/config/src/config_toml.rs | 6 +- codex-rs/config/src/hook_config.rs | 12 ++- codex-rs/config/src/hooks_tests.rs | 42 ++++++++++ codex-rs/config/src/lib.rs | 1 + codex-rs/core/config.schema.json | 110 ++++++++++++------------- codex-rs/hooks/src/config_rules.rs | 78 ++++++++++++++---- codex-rs/hooks/src/engine/discovery.rs | 49 ++++++----- 8 files changed, 194 insertions(+), 105 deletions(-) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 6225fc6ffd8..e8bb82777c6 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -307,7 +307,6 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed session_start, user_prompt_submit, stop, - config: _, } = hooks; ManagedHooksRequirements { diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 2821de5a8b4..fa149ce3caf 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -4,7 +4,7 @@ use std::collections::BTreeMap; use std::collections::HashMap; use std::path::Path; -use crate::HookEventsToml; +use crate::HooksToml; use crate::permissions_toml::PermissionsToml; use crate::profile_toml::ConfigProfile; use crate::types::AnalyticsConfigToml; @@ -342,8 +342,8 @@ pub struct ConfigToml { /// User-level skill config entries keyed by SKILL.md path. pub skills: Option, - /// Lifecycle hooks configured inline in TOML. - pub hooks: Option, + /// Lifecycle hooks configured inline in TOML plus user-level overrides. + pub hooks: Option, /// User-level plugin config entries keyed by plugin name. #[serde(default)] diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index b5107e47d63..b6b03cb802f 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -12,6 +12,14 @@ pub struct HooksFile { pub hooks: HookEventsToml, } +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HooksToml { + #[serde(flatten)] + pub events: HookEventsToml, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config: Vec, +} + #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct HookEventsToml { #[serde(rename = "PreToolUse", default)] @@ -26,8 +34,6 @@ pub struct HookEventsToml { pub user_prompt_submit: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub config: Vec, } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -46,7 +52,6 @@ impl HookEventsToml { session_start, user_prompt_submit, stop, - config: _, } = self; pre_tool_use.is_empty() && permission_request.is_empty() @@ -64,7 +69,6 @@ impl HookEventsToml { session_start, user_prompt_submit, stop, - config: _, } = self; [ pre_tool_use, diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 5e3f1df6747..3859b602fd9 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -3,6 +3,7 @@ use pretty_assertions::assert_eq; use super::HookEventsToml; use super::HookHandlerConfig; use super::HooksFile; +use super::HooksToml; use super::ManagedHooksRequirementsToml; use super::MatcherGroup; @@ -81,6 +82,47 @@ statusMessage = "checking" ); } +#[test] +fn hooks_toml_deserializes_inline_events_and_config() { + let parsed: HooksToml = toml::from_str( + r#" +[[config]] +key = "file:/tmp/hooks.json:pre_tool_use:0:0" +enabled = false + +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "python3 /tmp/pre.py" +"#, + ) + .expect("hooks TOML should deserialize"); + + assert_eq!( + parsed, + HooksToml { + events: HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "python3 /tmp/pre.py".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + }, + config: vec![super::HookConfig { + key: Some("file:/tmp/hooks.json:pre_tool_use:0:0".to_string()), + enabled: false, + }], + } + ); +} + #[test] fn managed_hooks_requirements_flatten_hook_events() { let parsed: ManagedHooksRequirementsToml = toml::from_str( diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 36e7f657a27..11f11eaf7fd 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -73,6 +73,7 @@ pub use hook_config::HookConfig; pub use hook_config::HookEventsToml; pub use hook_config::HookHandlerConfig; pub use hook_config::HooksFile; +pub use hook_config::HooksToml; pub use hook_config::ManagedHooksRequirementsToml; pub use hook_config::MatcherGroup; pub use host_name::host_name; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 337c4f02b1f..2320bdfd482 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -870,59 +870,6 @@ ], "type": "object" }, - "HookEventsToml": { - "properties": { - "PermissionRequest": { - "default": [], - "items": { - "$ref": "#/definitions/MatcherGroup" - }, - "type": "array" - }, - "PostToolUse": { - "default": [], - "items": { - "$ref": "#/definitions/MatcherGroup" - }, - "type": "array" - }, - "PreToolUse": { - "default": [], - "items": { - "$ref": "#/definitions/MatcherGroup" - }, - "type": "array" - }, - "SessionStart": { - "default": [], - "items": { - "$ref": "#/definitions/MatcherGroup" - }, - "type": "array" - }, - "Stop": { - "default": [], - "items": { - "$ref": "#/definitions/MatcherGroup" - }, - "type": "array" - }, - "UserPromptSubmit": { - "default": [], - "items": { - "$ref": "#/definitions/MatcherGroup" - }, - "type": "array" - }, - "config": { - "items": { - "$ref": "#/definitions/HookConfig" - }, - "type": "array" - } - }, - "type": "object" - }, "HookHandlerConfig": { "oneOf": [ { @@ -987,6 +934,59 @@ } ] }, + "HooksToml": { + "properties": { + "PermissionRequest": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "PostToolUse": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "PreToolUse": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "SessionStart": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "Stop": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "UserPromptSubmit": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "config": { + "items": { + "$ref": "#/definitions/HookConfig" + }, + "type": "array" + } + }, + "type": "object" + }, "MarketplaceConfig": { "additionalProperties": false, "properties": { @@ -2862,10 +2862,10 @@ "hooks": { "allOf": [ { - "$ref": "#/definitions/HookEventsToml" + "$ref": "#/definitions/HooksToml" } ], - "description": "Lifecycle hooks configured inline in TOML." + "description": "Lifecycle hooks configured inline in TOML plus user-level overrides." }, "include_apps_instructions": { "description": "Whether to inject the `` developer block.", diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index 0dc10cf9340..c48247f1510 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -4,18 +4,7 @@ use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::HookConfig; -use codex_config::HookEventsToml; - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct HookConfigRules { - disabled_keys: HashSet, -} - -impl HookConfigRules { - pub(crate) fn is_enabled(&self, key: &str) -> bool { - !self.disabled_keys.contains(key) - } -} +use codex_config::HooksToml; /// Build hook enablement rules from config layers that are allowed to override /// user preferences. @@ -24,11 +13,11 @@ impl HookConfigRules { /// disabled layers, to match the skills config behavior. Project, managed, and /// plugin layers can discover hooks, but they do not get to write user /// enablement state. -pub(crate) fn hook_config_rules_from_stack( +pub(crate) fn disabled_hook_keys_from_stack( config_layer_stack: Option<&ConfigLayerStack>, -) -> HookConfigRules { +) -> HashSet { let Some(config_layer_stack) = config_layer_stack else { - return HookConfigRules::default(); + return HashSet::new(); }; let mut disabled_keys = HashSet::new(); @@ -46,7 +35,7 @@ pub(crate) fn hook_config_rules_from_stack( let Some(hooks_value) = layer.config.get("hooks") else { continue; }; - let hooks: HookEventsToml = match hooks_value.clone().try_into() { + let hooks: HooksToml = match hooks_value.clone().try_into() { Ok(hooks) => hooks, Err(_) => { continue; @@ -67,7 +56,7 @@ pub(crate) fn hook_config_rules_from_stack( } } - HookConfigRules { disabled_keys } + disabled_keys } fn hook_config_key(entry: &HookConfig) -> Option { @@ -78,3 +67,58 @@ fn hook_config_key(entry: &HookConfig) -> Option { Some(key.to_string()) } } + +#[cfg(test)] +mod tests { + use codex_config::ConfigLayerEntry; + use codex_config::TomlValue; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn disabled_hook_keys_from_stack_respects_layer_precedence() { + let key = "file:/tmp/hooks.json:pre_tool_use:0:0"; + let stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_path_buf("/tmp/config.toml").abs(), + }, + config_with_hook_override(key, false), + ), + ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + config_with_hook_override(key, true), + ), + ], + Default::default(), + Default::default(), + ) + .expect("config layer stack"); + + assert_eq!(disabled_hook_keys_from_stack(Some(&stack)), HashSet::new()); + } + + fn config_with_hook_override(key: &str, enabled: bool) -> TomlValue { + let mut config = TomlValue::Table(Default::default()); + let TomlValue::Table(config_entries) = &mut config else { + unreachable!("config root should be a table"); + }; + let mut hooks = TomlValue::Table(Default::default()); + let TomlValue::Table(hook_entries) = &mut hooks else { + unreachable!("hooks should be a table"); + }; + let mut hook_override = TomlValue::Table(Default::default()); + let TomlValue::Table(hook_override_entries) = &mut hook_override else { + unreachable!("hook override should be a table"); + }; + hook_override_entries.insert("key".to_string(), TomlValue::String(key.to_string())); + hook_override_entries.insert("enabled".to_string(), TomlValue::Boolean(enabled)); + hook_entries.insert("config".to_string(), TomlValue::Array(vec![hook_override])); + config_entries.insert("hooks".to_string(), hooks); + config + } +} diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index be9f186d885..40bc470dbbf 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -16,11 +16,11 @@ use codex_plugin::PluginHookSource; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use std::collections::HashMap; +use std::collections::HashSet; use super::ConfiguredHandler; use super::HookListEntry; -use crate::config_rules::HookConfigRules; -use crate::config_rules::hook_config_rules_from_stack; +use crate::config_rules::disabled_hook_keys_from_stack; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; use codex_protocol::protocol::HookHandlerType; @@ -38,7 +38,7 @@ struct HookHandlerSource<'a> { key_prefix: String, is_managed: bool, source: HookSource, - hook_config_rules: &'a HookConfigRules, + disabled_hook_keys: &'a HashSet, env: HashMap, plugin_id: Option, } @@ -52,7 +52,7 @@ pub(crate) fn discover_handlers( let mut hook_entries = Vec::new(); let mut warnings = plugin_hook_load_warnings; let mut display_order = 0_i64; - let hook_config_rules = hook_config_rules_from_stack(config_layer_stack); + let disabled_hook_keys = disabled_hook_keys_from_stack(config_layer_stack); if let Some(config_layer_stack) = config_layer_stack { append_managed_requirement_handlers( @@ -61,7 +61,7 @@ pub(crate) fn discover_handlers( &mut warnings, &mut display_order, config_layer_stack, - &hook_config_rules, + &disabled_hook_keys, ); for layer in config_layer_stack.get_layers( @@ -95,7 +95,7 @@ pub(crate) fn discover_handlers( key_prefix: format!("file:{}", source_path.display()), is_managed: false, source: hook_source, - hook_config_rules: &hook_config_rules, + disabled_hook_keys: &disabled_hook_keys, env: HashMap::new(), plugin_id: None, }, @@ -111,7 +111,7 @@ pub(crate) fn discover_handlers( &mut warnings, &mut display_order, plugin_hook_sources, - &hook_config_rules, + &disabled_hook_keys, ); DiscoveryResult { @@ -127,7 +127,7 @@ fn append_managed_requirement_handlers( warnings: &mut Vec, display_order: &mut i64, config_layer_stack: &ConfigLayerStack, - hook_config_rules: &HookConfigRules, + disabled_hook_keys: &HashSet, ) { let Some(managed_hooks) = config_layer_stack.requirements().managed_hooks.as_ref() else { return; @@ -147,7 +147,7 @@ fn append_managed_requirement_handlers( key_prefix: format!("managed:{}", source_path.display()), is_managed: true, source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), - hook_config_rules, + disabled_hook_keys, env: HashMap::new(), plugin_id: None, }, @@ -161,7 +161,7 @@ fn append_plugin_hook_sources( warnings: &mut Vec, display_order: &mut i64, plugin_hook_sources: Vec, - hook_config_rules: &HookConfigRules, + disabled_hook_keys: &HashSet, ) { // TODO(abhinav): check enabled/trusted state here before plugin hooks become runnable. for source in plugin_hook_sources { @@ -193,7 +193,7 @@ fn append_plugin_hook_sources( key_prefix: format!("plugin:{plugin_id}:{source_relative_path}"), is_managed: false, source: HookSource::Plugin, - hook_config_rules, + disabled_hook_keys, env, plugin_id: Some(plugin_id), }, @@ -296,7 +296,7 @@ fn load_toml_hooks_from_layer( ) -> Option<(AbsolutePathBuf, HookEventsToml)> { let source_path = config_toml_source_path(layer); let hook_value = layer.config.get("hooks")?.clone(); - let parsed = match HookEventsToml::deserialize(hook_value) { + let parsed = match codex_config::HooksToml::deserialize(hook_value) { Ok(parsed) => parsed, Err(err) => { warnings.push(format!( @@ -307,7 +307,7 @@ fn load_toml_hooks_from_layer( } }; - (!parsed.is_empty()).then_some((source_path, parsed)) + (!parsed.events.is_empty()).then_some((source_path, parsed.events)) } fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf { @@ -414,7 +414,7 @@ fn append_matcher_groups( group_index, handler_index ); - let enabled = source.is_managed || source.hook_config_rules.is_enabled(&key); + let enabled = source.is_managed || !source.disabled_hook_keys.contains(&key); hook_entries.push(HookListEntry { key, event_name, @@ -511,7 +511,6 @@ mod tests { use super::ConfiguredHandler; use super::append_matcher_groups; - use crate::config_rules::HookConfigRules; use codex_config::HookHandlerConfig; use codex_config::MatcherGroup; @@ -525,14 +524,14 @@ mod tests { fn hook_handler_source<'a>( path: &'a AbsolutePathBuf, - hook_config_rules: &'a HookConfigRules, + disabled_hook_keys: &'a std::collections::HashSet, ) -> super::HookHandlerSource<'a> { super::HookHandlerSource { path, key_prefix: format!("file:{}", path.display()), is_managed: false, source: hook_source(), - hook_config_rules, + disabled_hook_keys, env: std::collections::HashMap::new(), plugin_id: None, } @@ -556,14 +555,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); - let hook_config_rules = HookConfigRules::default(); + let disabled_hook_keys = std::collections::HashSet::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path, &hook_config_rules), + hook_handler_source(&source_path, &disabled_hook_keys), HookEventName::UserPromptSubmit, vec![command_group(Some("["))], ); @@ -592,14 +591,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); - let hook_config_rules = HookConfigRules::default(); + let disabled_hook_keys = std::collections::HashSet::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path, &hook_config_rules), + hook_handler_source(&source_path, &disabled_hook_keys), HookEventName::PreToolUse, vec![command_group(Some("^Bash$"))], ); @@ -628,14 +627,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); - let hook_config_rules = HookConfigRules::default(); + let disabled_hook_keys = std::collections::HashSet::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path, &hook_config_rules), + hook_handler_source(&source_path, &disabled_hook_keys), HookEventName::PreToolUse, vec![command_group(Some("*"))], ); @@ -651,14 +650,14 @@ mod tests { let mut warnings = Vec::new(); let mut display_order = 0; let source_path = source_path(); - let hook_config_rules = HookConfigRules::default(); + let disabled_hook_keys = std::collections::HashSet::new(); append_matcher_groups( &mut handlers, &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path, &hook_config_rules), + hook_handler_source(&source_path, &disabled_hook_keys), HookEventName::PostToolUse, vec![command_group(Some("Edit|Write"))], ); From b9cd67491c0bbc64dce475e09c3dc327673dd346 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 10:36:21 -0700 Subject: [PATCH 36/64] Fix plugin hook listing regressions --- .../app-server/src/codex_message_processor.rs | 5 +- .../app-server/tests/suite/v2/hooks_list.rs | 55 ++++++++++++++ codex-rs/core/src/plugins/manager.rs | 46 ++++++++---- codex-rs/core/src/plugins/manager_tests.rs | 71 +++++++++++++++++++ 4 files changed, 162 insertions(+), 15 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index abae28d633a..929a01c239c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6246,8 +6246,8 @@ impl CodexMessageProcessor { // Plugin hook sources are discovered from the same effective plugin // view used by runtime loading, but only when both plugin feature // gates are enabled for this workspace. - let plugin_hook_sources = plugins_manager - .effective_plugin_hook_sources_for_layer_stack( + let (plugin_hook_sources, plugin_hook_load_warnings) = plugins_manager + .effective_plugin_hooks_for_layer_stack( &config_layer_stack, plugins_enabled, config.features.enabled(Feature::PluginHooks), @@ -6257,6 +6257,7 @@ impl CodexMessageProcessor { feature_enabled: config.features.enabled(Feature::CodexHooks), config_layer_stack: Some(config_layer_stack), plugin_hook_sources, + plugin_hook_load_warnings, ..Default::default() }); data.push(codex_app_server_protocol::HooksListEntry { diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index be378fd0090..e463cc6ff6f 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -33,6 +33,29 @@ statusMessage = "running listed hook" Ok(()) } +fn write_invalid_plugin_hook_config(codex_home: &std::path::Path) -> Result<()> { + let plugin_root = codex_home.join("plugins/cache/test/demo/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.join("hooks"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"demo"}"#, + )?; + std::fs::write(plugin_root.join("hooks/hooks.json"), "{ not-json")?; + std::fs::write( + codex_home.join("config.toml"), + r#"[features] +plugins = true +plugin_hooks = true +codex_hooks = true + +[plugins."demo@test"] +enabled = true +"#, + )?; + Ok(()) +} + #[tokio::test] async fn hooks_list_shows_discovered_hook() -> Result<()> { let codex_home = TempDir::new()?; @@ -65,3 +88,35 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { assert_eq!(hook.source, HookSource::User); Ok(()) } + +#[tokio::test] +async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_invalid_plugin_hook_config(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + + assert_eq!(data.len(), 1); + assert_eq!(data[0].hooks, Vec::new()); + assert_eq!(data[0].warnings.len(), 1); + assert!( + data[0].warnings[0].contains("failed to parse plugin hooks config"), + "unexpected warnings: {:?}", + data[0].warnings + ); + Ok(()) +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index a8d90c1a012..941a86c632a 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -332,12 +332,18 @@ pub struct PluginsManager { featured_plugin_ids_cache: RwLock>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, - cached_enabled_outcome: RwLock>, + cached_enabled_outcome: RwLock>, remote_sync_lock: Semaphore, restriction_product: Option, analytics_events_client: RwLock>, } +#[derive(Clone)] +struct CachedEnabledOutcome { + plugin_hooks_enabled: bool, + outcome: PluginLoadOutcome, +} + impl PluginsManager { pub fn new(codex_home: PathBuf) -> Self { Self::new_with_restriction_product(codex_home, Some(Product::Codex)) @@ -401,7 +407,8 @@ impl PluginsManager { return PluginLoadOutcome::default(); } - if !force_reload && let Some(outcome) = self.cached_enabled_outcome() { + let plugin_hooks_enabled = config.features.enabled(Feature::PluginHooks); + if !force_reload && let Some(outcome) = self.cached_enabled_outcome(plugin_hooks_enabled) { return outcome; } @@ -409,7 +416,7 @@ impl PluginsManager { &config.config_layer_stack, &self.store, self.restriction_product, - config.features.enabled(Feature::PluginHooks), + plugin_hooks_enabled, ) .await; log_plugin_load_errors(&outcome); @@ -417,7 +424,10 @@ impl PluginsManager { Ok(cache) => cache, Err(err) => err.into_inner(), }; - *cache = Some(outcome.clone()); + *cache = Some(CachedEnabledOutcome { + plugin_hooks_enabled, + outcome: outcome.clone(), + }); outcome } @@ -453,29 +463,39 @@ impl PluginsManager { .effective_skill_roots() } - pub async fn effective_plugin_hook_sources_for_layer_stack( + pub async fn effective_plugin_hooks_for_layer_stack( &self, config_layer_stack: &ConfigLayerStack, plugins_feature_enabled: bool, plugin_hooks_feature_enabled: bool, - ) -> Vec { + ) -> (Vec, Vec) { if !plugins_feature_enabled || !plugin_hooks_feature_enabled { - return Vec::new(); + return (Vec::new(), Vec::new()); } - load_plugins_from_layer_stack( + let outcome = load_plugins_from_layer_stack( config_layer_stack, &self.store, self.restriction_product, /*plugin_hooks_enabled*/ true, ) - .await - .effective_plugin_hook_sources() + .await; + ( + outcome.effective_plugin_hook_sources(), + outcome.effective_plugin_hook_warnings(), + ) } - fn cached_enabled_outcome(&self) -> Option { + fn cached_enabled_outcome(&self, plugin_hooks_enabled: bool) -> Option { match self.cached_enabled_outcome.read() { - Ok(cache) => cache.clone(), - Err(err) => err.into_inner().clone(), + Ok(cache) => cache + .as_ref() + .filter(|cached| cached.plugin_hooks_enabled == plugin_hooks_enabled) + .map(|cached| cached.outcome.clone()), + Err(err) => err + .into_inner() + .as_ref() + .filter(|cached| cached.plugin_hooks_enabled == plugin_hooks_enabled) + .map(|cached| cached.outcome.clone()), } } diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index dc0482c7a5f..d1d6f482011 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -94,6 +94,18 @@ fn run_git(repo: &Path, args: &[&str]) { } fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { + plugin_config_toml_with_plugin_hooks( + enabled, + plugins_feature_enabled, + /*plugin_hooks_feature_enabled*/ false, + ) +} + +fn plugin_config_toml_with_plugin_hooks( + enabled: bool, + plugins_feature_enabled: bool, + plugin_hooks_feature_enabled: bool, +) -> String { let mut root = toml::map::Map::new(); let mut features = toml::map::Map::new(); @@ -101,6 +113,10 @@ fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { "plugins".to_string(), Value::Boolean(plugins_feature_enabled), ); + features.insert( + "plugin_hooks".to_string(), + Value::Boolean(plugin_hooks_feature_enabled), + ); root.insert("features".to_string(), Value::Table(features)); let mut plugin = toml::map::Map::new(); @@ -935,6 +951,61 @@ async fn load_plugins_returns_empty_when_feature_disabled() { assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "PreToolUse": [ + { + "hooks": [{ "type": "command", "command": "echo plugin hook" }] + } + ] + } +}"#, + ); + + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml_with_plugin_hooks( + /*enabled*/ true, /*plugins_feature_enabled*/ true, + /*plugin_hooks_feature_enabled*/ false, + ), + ); + let config_without_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await; + let without_plugin_hooks = manager + .plugins_for_config(&config_without_plugin_hooks) + .await; + assert!( + without_plugin_hooks + .effective_plugin_hook_sources() + .is_empty() + ); + + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + &plugin_config_toml_with_plugin_hooks( + /*enabled*/ true, /*plugins_feature_enabled*/ true, + /*plugin_hooks_feature_enabled*/ true, + ), + ); + let config_with_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await; + let with_plugin_hooks = manager.plugins_for_config(&config_with_plugin_hooks).await; + assert_eq!(with_plugin_hooks.effective_plugin_hook_sources().len(), 1); +} + #[tokio::test] async fn load_plugins_rejects_invalid_plugin_keys() { let codex_home = TempDir::new().unwrap(); From 32ad9b10cffc4bb2f2882c4a438b0703a31ca8a8 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 10:43:59 -0700 Subject: [PATCH 37/64] Store plugin hook mode on load outcome --- codex-rs/core-plugins/src/loader.rs | 2 +- codex-rs/core/src/plugins/manager.rs | 21 +++----- codex-rs/core/src/plugins/manager_tests.rs | 61 ++++++++++++---------- codex-rs/plugin/src/load_outcome.rs | 10 +++- 4 files changed, 47 insertions(+), 47 deletions(-) diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index c1da8adadac..bebce0a5691 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -144,7 +144,7 @@ pub async fn load_plugins_from_layer_stack( plugins.push(loaded_plugin); } - PluginLoadOutcome::from_plugins(plugins) + PluginLoadOutcome::from_plugins(plugins, plugin_hooks_enabled) } pub fn refresh_curated_plugin_cache( diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 941a86c632a..2e29a8db6b3 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -332,18 +332,12 @@ pub struct PluginsManager { featured_plugin_ids_cache: RwLock>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, - cached_enabled_outcome: RwLock>, + cached_enabled_outcome: RwLock>, remote_sync_lock: Semaphore, restriction_product: Option, analytics_events_client: RwLock>, } -#[derive(Clone)] -struct CachedEnabledOutcome { - plugin_hooks_enabled: bool, - outcome: PluginLoadOutcome, -} - impl PluginsManager { pub fn new(codex_home: PathBuf) -> Self { Self::new_with_restriction_product(codex_home, Some(Product::Codex)) @@ -424,10 +418,7 @@ impl PluginsManager { Ok(cache) => cache, Err(err) => err.into_inner(), }; - *cache = Some(CachedEnabledOutcome { - plugin_hooks_enabled, - outcome: outcome.clone(), - }); + *cache = Some(outcome.clone()); outcome } @@ -489,13 +480,13 @@ impl PluginsManager { match self.cached_enabled_outcome.read() { Ok(cache) => cache .as_ref() - .filter(|cached| cached.plugin_hooks_enabled == plugin_hooks_enabled) - .map(|cached| cached.outcome.clone()), + .filter(|outcome| outcome.plugin_hooks_enabled() == plugin_hooks_enabled) + .cloned(), Err(err) => err .into_inner() .as_ref() - .filter(|cached| cached.plugin_hooks_enabled == plugin_hooks_enabled) - .map(|cached| cached.outcome.clone()), + .filter(|outcome| outcome.plugin_hooks_enabled() == plugin_hooks_enabled) + .cloned(), } } diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index d1d6f482011..fb99b0f1ffe 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -866,35 +866,38 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { description: None, ..PluginCapabilitySummary::default() }; - let outcome = PluginLoadOutcome::from_plugins(vec![ - LoadedPlugin { - skill_roots: vec![codex_home.path().join("skills-plugin/skills").abs()], - has_enabled_skills: true, - ..plugin("skills@test", "skills-plugin", "skills-plugin") - }, - LoadedPlugin { - mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]), - apps: vec![connector("connector_example")], - ..plugin("alpha@test", "alpha-plugin", "alpha-plugin") - }, - LoadedPlugin { - mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]), - apps: vec![connector("connector_example"), connector("connector_gmail")], - ..plugin("beta@test", "beta-plugin", "beta-plugin") - }, - plugin("empty@test", "empty-plugin", "empty-plugin"), - LoadedPlugin { - enabled: false, - skill_roots: vec![codex_home.path().join("disabled-plugin/skills").abs()], - apps: vec![connector("connector_hidden")], - ..plugin("disabled@test", "disabled-plugin", "disabled-plugin") - }, - LoadedPlugin { - apps: vec![connector("connector_broken")], - error: Some("failed to load".to_string()), - ..plugin("broken@test", "broken-plugin", "broken-plugin") - }, - ]); + let outcome = PluginLoadOutcome::from_plugins( + vec![ + LoadedPlugin { + skill_roots: vec![codex_home.path().join("skills-plugin/skills").abs()], + has_enabled_skills: true, + ..plugin("skills@test", "skills-plugin", "skills-plugin") + }, + LoadedPlugin { + mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]), + apps: vec![connector("connector_example")], + ..plugin("alpha@test", "alpha-plugin", "alpha-plugin") + }, + LoadedPlugin { + mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]), + apps: vec![connector("connector_example"), connector("connector_gmail")], + ..plugin("beta@test", "beta-plugin", "beta-plugin") + }, + plugin("empty@test", "empty-plugin", "empty-plugin"), + LoadedPlugin { + enabled: false, + skill_roots: vec![codex_home.path().join("disabled-plugin/skills").abs()], + apps: vec![connector("connector_hidden")], + ..plugin("disabled@test", "disabled-plugin", "disabled-plugin") + }, + LoadedPlugin { + apps: vec![connector("connector_broken")], + error: Some("failed to load".to_string()), + ..plugin("broken@test", "broken-plugin", "broken-plugin") + }, + ], + /*plugin_hooks_enabled*/ false, + ); assert_eq!( outcome.capability_summaries(), diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index 0865b9020fc..6110dddb79d 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -84,16 +84,17 @@ pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option { plugins: Vec>, capability_summaries: Vec, + plugin_hooks_enabled: bool, } impl Default for PluginLoadOutcome { fn default() -> Self { - Self::from_plugins(Vec::new()) + Self::from_plugins(Vec::new(), /*plugin_hooks_enabled*/ false) } } impl PluginLoadOutcome { - pub fn from_plugins(plugins: Vec>) -> Self { + pub fn from_plugins(plugins: Vec>, plugin_hooks_enabled: bool) -> Self { let capability_summaries = plugins .iter() .filter_map(plugin_capability_summary_from_loaded) @@ -101,6 +102,7 @@ impl PluginLoadOutcome { Self { plugins, capability_summaries, + plugin_hooks_enabled, } } @@ -166,6 +168,10 @@ impl PluginLoadOutcome { pub fn plugins(&self) -> &[LoadedPlugin] { &self.plugins } + + pub fn plugin_hooks_enabled(&self) -> bool { + self.plugin_hooks_enabled + } } /// Implemented by [`PluginLoadOutcome`] so callers (e.g. skills) can depend on `codex-plugin` From b1b74506446462021a49179a36891a88ada9baef Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 10:51:14 -0700 Subject: [PATCH 38/64] Pass plugin hook flag through helper --- codex-rs/core/src/plugins/manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 2e29a8db6b3..8a27c387aea 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -467,7 +467,7 @@ impl PluginsManager { config_layer_stack, &self.store, self.restriction_product, - /*plugin_hooks_enabled*/ true, + plugin_hooks_feature_enabled, ) .await; ( From d46139e06ac4da4bcb35b8ecb6a6cf92f62dbaca Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 10:57:01 -0700 Subject: [PATCH 39/64] Fix argument comment lint --- codex-rs/hooks/src/config_rules.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index c48247f1510..7f851daa127 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -87,11 +87,11 @@ mod tests { ConfigLayerSource::User { file: test_path_buf("/tmp/config.toml").abs(), }, - config_with_hook_override(key, false), + config_with_hook_override(key, /*enabled*/ false), ), ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, - config_with_hook_override(key, true), + config_with_hook_override(key, /*enabled*/ true), ), ], Default::default(), From 2b48f64f7ef867d5ec27970758c7b597be106421 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 11:17:54 -0700 Subject: [PATCH 40/64] Simplify hooks list plumbing --- codex-rs/Cargo.lock | 1 + codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 4 +- codex-rs/core-plugins/src/loader.rs | 2 +- codex-rs/core/src/hooks.rs | 5 -- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/plugins/manager.rs | 18 +++--- codex-rs/core/src/plugins/manager_tests.rs | 61 +++++++++---------- .../write/templates/memories/consolidation.md | 2 +- codex-rs/plugin/src/load_outcome.rs | 10 +-- 10 files changed, 48 insertions(+), 57 deletions(-) delete mode 100644 codex-rs/core/src/hooks.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a3bfc9678c0..786a91b7c44 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1857,6 +1857,7 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-git-utils", + "codex-hooks", "codex-login", "codex-mcp", "codex-model-provider", diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 06ed624c375..5ab23ce85de 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -40,6 +40,7 @@ codex-device-key = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-git-utils = { workspace = true } +codex-hooks = { workspace = true } codex-otel = { workspace = true } codex-shell-command = { workspace = true } codex-utils-cli = { workspace = true } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 929a01c239c..f5cac385975 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6253,7 +6253,7 @@ impl CodexMessageProcessor { config.features.enabled(Feature::PluginHooks), ) .await; - let hooks = codex_core::hooks::list_hooks(codex_core::hooks::HooksConfig { + let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { feature_enabled: config.features.enabled(Feature::CodexHooks), config_layer_stack: Some(config_layer_stack), plugin_hook_sources, @@ -8598,7 +8598,7 @@ fn skills_to_info( .collect() } -fn hooks_to_info(hooks: &[codex_core::hooks::HookListEntry]) -> Vec { +fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { hooks .iter() .map(|hook| HookMetadata { diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index bebce0a5691..c1da8adadac 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -144,7 +144,7 @@ pub async fn load_plugins_from_layer_stack( plugins.push(loaded_plugin); } - PluginLoadOutcome::from_plugins(plugins, plugin_hooks_enabled) + PluginLoadOutcome::from_plugins(plugins) } pub fn refresh_curated_plugin_cache( diff --git a/codex-rs/core/src/hooks.rs b/codex-rs/core/src/hooks.rs deleted file mode 100644 index 2f1a46e0d12..00000000000 --- a/codex-rs/core/src/hooks.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub use codex_hooks::HookListEntry; -pub use codex_hooks::HookListOutcome; -pub use codex_hooks::Hooks; -pub use codex_hooks::HooksConfig; -pub use codex_hooks::list_hooks; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e2758f1790b..c5e0da9b8fd 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -39,7 +39,6 @@ mod git_info_tests; mod goals; mod guardian; mod hook_runtime; -pub mod hooks; mod installation_id; pub(crate) mod landlock; pub use landlock::spawn_command_under_linux_sandbox; diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 8a27c387aea..59dcd67dbc3 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -332,7 +332,7 @@ pub struct PluginsManager { featured_plugin_ids_cache: RwLock>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, - cached_enabled_outcome: RwLock>, + cached_enabled_outcome: RwLock>, remote_sync_lock: Semaphore, restriction_product: Option, analytics_events_client: RwLock>, @@ -418,7 +418,7 @@ impl PluginsManager { Ok(cache) => cache, Err(err) => err.into_inner(), }; - *cache = Some(outcome.clone()); + *cache = Some((plugin_hooks_enabled, outcome.clone())); outcome } @@ -467,7 +467,7 @@ impl PluginsManager { config_layer_stack, &self.store, self.restriction_product, - plugin_hooks_feature_enabled, + /*plugin_hooks_enabled*/ true, ) .await; ( @@ -480,13 +480,17 @@ impl PluginsManager { match self.cached_enabled_outcome.read() { Ok(cache) => cache .as_ref() - .filter(|outcome| outcome.plugin_hooks_enabled() == plugin_hooks_enabled) - .cloned(), + .filter(|(cached_plugin_hooks_enabled, _)| { + *cached_plugin_hooks_enabled == plugin_hooks_enabled + }) + .map(|(_, outcome)| outcome.clone()), Err(err) => err .into_inner() .as_ref() - .filter(|outcome| outcome.plugin_hooks_enabled() == plugin_hooks_enabled) - .cloned(), + .filter(|(cached_plugin_hooks_enabled, _)| { + *cached_plugin_hooks_enabled == plugin_hooks_enabled + }) + .map(|(_, outcome)| outcome.clone()), } } diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index fb99b0f1ffe..d1d6f482011 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -866,38 +866,35 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { description: None, ..PluginCapabilitySummary::default() }; - let outcome = PluginLoadOutcome::from_plugins( - vec![ - LoadedPlugin { - skill_roots: vec![codex_home.path().join("skills-plugin/skills").abs()], - has_enabled_skills: true, - ..plugin("skills@test", "skills-plugin", "skills-plugin") - }, - LoadedPlugin { - mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]), - apps: vec![connector("connector_example")], - ..plugin("alpha@test", "alpha-plugin", "alpha-plugin") - }, - LoadedPlugin { - mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]), - apps: vec![connector("connector_example"), connector("connector_gmail")], - ..plugin("beta@test", "beta-plugin", "beta-plugin") - }, - plugin("empty@test", "empty-plugin", "empty-plugin"), - LoadedPlugin { - enabled: false, - skill_roots: vec![codex_home.path().join("disabled-plugin/skills").abs()], - apps: vec![connector("connector_hidden")], - ..plugin("disabled@test", "disabled-plugin", "disabled-plugin") - }, - LoadedPlugin { - apps: vec![connector("connector_broken")], - error: Some("failed to load".to_string()), - ..plugin("broken@test", "broken-plugin", "broken-plugin") - }, - ], - /*plugin_hooks_enabled*/ false, - ); + let outcome = PluginLoadOutcome::from_plugins(vec![ + LoadedPlugin { + skill_roots: vec![codex_home.path().join("skills-plugin/skills").abs()], + has_enabled_skills: true, + ..plugin("skills@test", "skills-plugin", "skills-plugin") + }, + LoadedPlugin { + mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]), + apps: vec![connector("connector_example")], + ..plugin("alpha@test", "alpha-plugin", "alpha-plugin") + }, + LoadedPlugin { + mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]), + apps: vec![connector("connector_example"), connector("connector_gmail")], + ..plugin("beta@test", "beta-plugin", "beta-plugin") + }, + plugin("empty@test", "empty-plugin", "empty-plugin"), + LoadedPlugin { + enabled: false, + skill_roots: vec![codex_home.path().join("disabled-plugin/skills").abs()], + apps: vec![connector("connector_hidden")], + ..plugin("disabled@test", "disabled-plugin", "disabled-plugin") + }, + LoadedPlugin { + apps: vec![connector("connector_broken")], + error: Some("failed to load".to_string()), + ..plugin("broken@test", "broken-plugin", "broken-plugin") + }, + ]); assert_eq!( outcome.capability_summaries(), diff --git a/codex-rs/memories/write/templates/memories/consolidation.md b/codex-rs/memories/write/templates/memories/consolidation.md index 06161f677f1..8ce97a4322f 100644 --- a/codex-rs/memories/write/templates/memories/consolidation.md +++ b/codex-rs/memories/write/templates/memories/consolidation.md @@ -154,7 +154,7 @@ Incremental update and forgetting mechanism: - Use the git-style diff in `{{ phase2_workspace_diff_file }}` to identify relevant changed sections and deleted inputs. -- Every changes in `{{ phase2_workspace_diff_file }}` are authoritative and must propagated and consolidated. If a +- Every changes in `{{ phase2_workspace_diff_file }}` are authoritative and must propagated and consolidated. If a changes appears to be randomly placed in the files, it is probably a user change and you shouldn't just drop it. Make sure to add it to the overall memories consolidation - Do not open raw sessions / original rollout transcripts. diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index 6110dddb79d..0865b9020fc 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -84,17 +84,16 @@ pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option { plugins: Vec>, capability_summaries: Vec, - plugin_hooks_enabled: bool, } impl Default for PluginLoadOutcome { fn default() -> Self { - Self::from_plugins(Vec::new(), /*plugin_hooks_enabled*/ false) + Self::from_plugins(Vec::new()) } } impl PluginLoadOutcome { - pub fn from_plugins(plugins: Vec>, plugin_hooks_enabled: bool) -> Self { + pub fn from_plugins(plugins: Vec>) -> Self { let capability_summaries = plugins .iter() .filter_map(plugin_capability_summary_from_loaded) @@ -102,7 +101,6 @@ impl PluginLoadOutcome { Self { plugins, capability_summaries, - plugin_hooks_enabled, } } @@ -168,10 +166,6 @@ impl PluginLoadOutcome { pub fn plugins(&self) -> &[LoadedPlugin] { &self.plugins } - - pub fn plugin_hooks_enabled(&self) -> bool { - self.plugin_hooks_enabled - } } /// Implemented by [`PluginLoadOutcome`] so callers (e.g. skills) can depend on `codex-plugin` From 425cd2d72bc0530a5348b3b724fdf7616640c010 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 11:30:08 -0700 Subject: [PATCH 41/64] Add hooks list plugin coverage --- .../app-server/tests/suite/v2/hooks_list.rs | 108 +++++++++++++++--- 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index e463cc6ff6f..29d3a3ca805 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -4,11 +4,15 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::HookHandlerType; +use codex_app_server_protocol::HookMetadata; use codex_app_server_protocol::HookSource; +use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -33,7 +37,7 @@ statusMessage = "running listed hook" Ok(()) } -fn write_invalid_plugin_hook_config(codex_home: &std::path::Path) -> Result<()> { +fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> Result<()> { let plugin_root = codex_home.join("plugins/cache/test/demo/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::create_dir_all(plugin_root.join("hooks"))?; @@ -41,7 +45,7 @@ fn write_invalid_plugin_hook_config(codex_home: &std::path::Path) -> Result<()> plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"demo"}"#, )?; - std::fs::write(plugin_root.join("hooks/hooks.json"), "{ not-json")?; + std::fs::write(plugin_root.join("hooks/hooks.json"), hooks_json)?; std::fs::write( codex_home.join("config.toml"), r#"[features] @@ -76,16 +80,94 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { ) .await??; let HooksListResponse { data } = to_response(response)?; - assert_eq!(data.len(), 1); - assert_eq!(data[0].cwd.as_path(), cwd.path()); - assert_eq!(data[0].hooks.len(), 1); - let hook = &data[0].hooks[0]; - assert_eq!(hook.event_name, HookEventName::PreToolUse); - assert_eq!(hook.matcher.as_deref(), Some("Bash")); - assert_eq!(hook.command.as_deref(), Some("python3 /tmp/listed-hook.py")); - assert_eq!(hook.timeout_sec, 5); - assert_eq!(hook.status_message.as_deref(), Some("running listed hook")); - assert_eq!(hook.source, HookSource::User); + assert_eq!( + data, + vec![HooksListEntry { + cwd: cwd.path().to_path_buf(), + hooks: vec![HookMetadata { + event_name: HookEventName::PreToolUse, + handler_type: HookHandlerType::Command, + matcher: Some("Bash".to_string()), + command: Some("python3 /tmp/listed-hook.py".to_string()), + timeout_sec: 5, + status_message: Some("running listed hook".to_string()), + source_path: AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( + codex_home.path().join("config.toml") + )?,)?, + source: HookSource::User, + plugin_id: None, + display_order: 0, + }], + warnings: Vec::new(), + errors: Vec::new(), + }] + ); + Ok(()) +} + +#[tokio::test] +async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + write_plugin_hook_config( + codex_home.path(), + r#"{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo plugin hook", + "timeout": 7, + "statusMessage": "running plugin hook" + } + ] + } + ] + } +}"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!( + data, + vec![HooksListEntry { + cwd: cwd.path().to_path_buf(), + hooks: vec![HookMetadata { + event_name: HookEventName::PreToolUse, + handler_type: HookHandlerType::Command, + matcher: Some("Bash".to_string()), + command: Some("echo plugin hook".to_string()), + timeout_sec: 7, + status_message: Some("running plugin hook".to_string()), + source_path: AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( + codex_home + .path() + .join("plugins/cache/test/demo/local/hooks/hooks.json"), + )?,)?, + source: HookSource::Plugin, + plugin_id: Some("demo@test".to_string()), + display_order: 0, + }], + warnings: Vec::new(), + errors: Vec::new(), + }] + ); Ok(()) } @@ -93,7 +175,7 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - write_invalid_plugin_hook_config(codex_home.path())?; + write_plugin_hook_config(codex_home.path(), "{ not-json")?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; From ef403b27479a861ddfba4f235a5a739db9e68eaf Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 11:34:16 -0700 Subject: [PATCH 42/64] update comment --- codex-rs/app-server/src/codex_message_processor.rs | 3 --- codex-rs/core/src/plugins/manager.rs | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f5cac385975..4f245236e49 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6243,9 +6243,6 @@ impl CodexMessageProcessor { continue; } }; - // Plugin hook sources are discovered from the same effective plugin - // view used by runtime loading, but only when both plugin feature - // gates are enabled for this workspace. let (plugin_hook_sources, plugin_hook_load_warnings) = plugins_manager .effective_plugin_hooks_for_layer_stack( &config_layer_stack, diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 59dcd67dbc3..18470c12a13 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -454,6 +454,10 @@ impl PluginsManager { .effective_skill_roots() } + /// Resolve effective plugin hook sources and load warnings for a config layer stack + /// without touching the plugins cache. + /// + /// Returns empty vectors unless both plugin feature gates are enabled. pub async fn effective_plugin_hooks_for_layer_stack( &self, config_layer_stack: &ConfigLayerStack, From 225940faba230d5c2db99239decf62537b464129 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 12:04:31 -0700 Subject: [PATCH 43/64] Simplify per-layer plugin loading --- .../app-server/src/codex_message_processor.rs | 29 ++++++++------ codex-rs/core/src/plugins/manager.rs | 38 +++---------------- codex-rs/core/src/session/handlers.rs | 7 ++-- 3 files changed, 27 insertions(+), 47 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4f245236e49..ce14adb8c50 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -6169,15 +6169,16 @@ impl CodexMessageProcessor { let extra_roots = extra_roots_by_cwd .get(&cwd) .map_or(&[][..], std::vec::Vec::as_slice); - let effective_skill_roots = plugins_manager - .effective_skill_roots_for_layer_stack( + let plugin_outcome = plugins_manager + .plugins_for_layer_stack( &config_layer_stack, config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled, + /*plugin_hooks_feature_enabled*/ false, ) .await; let skills_input = codex_core::skills::SkillsLoadInput::new( cwd_abs.clone(), - effective_skill_roots, + plugin_outcome.effective_skill_roots(), config_layer_stack, config.bundled_skills_enabled(), ); @@ -6243,18 +6244,22 @@ impl CodexMessageProcessor { continue; } }; - let (plugin_hook_sources, plugin_hook_load_warnings) = plugins_manager - .effective_plugin_hooks_for_layer_stack( - &config_layer_stack, - plugins_enabled, - config.features.enabled(Feature::PluginHooks), - ) - .await; + let plugin_outcome = if config.features.enabled(Feature::PluginHooks) { + plugins_manager + .plugins_for_layer_stack( + &config_layer_stack, + plugins_enabled, + /*plugin_hooks_feature_enabled*/ true, + ) + .await + } else { + codex_core::plugins::PluginLoadOutcome::default() + }; let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { feature_enabled: config.features.enabled(Feature::CodexHooks), config_layer_stack: Some(config_layer_stack), - plugin_hook_sources, - plugin_hook_load_warnings, + plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(), + plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), ..Default::default() }); data.push(codex_app_server_protocol::HooksListEntry { diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 18470c12a13..b82f9714e33 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -435,49 +435,23 @@ impl PluginsManager { *cached_enabled_outcome = None; } - /// Resolve plugin skill roots for a config layer stack without touching the plugins cache. - pub async fn effective_skill_roots_for_layer_stack( + /// Load plugins for a config layer stack without touching the plugins cache. + pub async fn plugins_for_layer_stack( &self, config_layer_stack: &ConfigLayerStack, plugins_feature_enabled: bool, - ) -> Vec { + plugin_hooks_feature_enabled: bool, + ) -> PluginLoadOutcome { if !plugins_feature_enabled { - return Vec::new(); + return PluginLoadOutcome::default(); } load_plugins_from_layer_stack( config_layer_stack, &self.store, self.restriction_product, - /*plugin_hooks_enabled*/ false, + plugin_hooks_feature_enabled, ) .await - .effective_skill_roots() - } - - /// Resolve effective plugin hook sources and load warnings for a config layer stack - /// without touching the plugins cache. - /// - /// Returns empty vectors unless both plugin feature gates are enabled. - pub async fn effective_plugin_hooks_for_layer_stack( - &self, - config_layer_stack: &ConfigLayerStack, - plugins_feature_enabled: bool, - plugin_hooks_feature_enabled: bool, - ) -> (Vec, Vec) { - if !plugins_feature_enabled || !plugin_hooks_feature_enabled { - return (Vec::new(), Vec::new()); - } - let outcome = load_plugins_from_layer_stack( - config_layer_stack, - &self.store, - self.restriction_product, - /*plugin_hooks_enabled*/ true, - ) - .await; - ( - outcome.effective_plugin_hook_sources(), - outcome.effective_plugin_hook_warnings(), - ) } fn cached_enabled_outcome(&self, plugin_hooks_enabled: bool) -> Option { diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 5c11b2d9da9..d2409394c41 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -618,15 +618,16 @@ pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec, for continue; } }; - let effective_skill_roots = plugins_manager - .effective_skill_roots_for_layer_stack( + let plugin_outcome = plugins_manager + .plugins_for_layer_stack( &config_layer_stack, config.features.enabled(Feature::Plugins), + /*plugin_hooks_feature_enabled*/ false, ) .await; let skills_input = crate::SkillsLoadInput::new( cwd_abs.clone(), - effective_skill_roots, + plugin_outcome.effective_skill_roots(), config_layer_stack, config.bundled_skills_enabled(), ); From ec415716763e382505bf509672b22e635b8571a9 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 12:40:10 -0700 Subject: [PATCH 44/64] Fix argument comment lint --- codex-rs/app-server/src/codex_message_processor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e3de08e1f72..9c5d1e72283 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -9861,7 +9861,7 @@ mod tests { #[test] fn managed_hook_config_write_targets_are_rejected() { let key = "path:/tmp/managed-hooks:pre_tool_use:0:0"; - let hooks = vec![hook_metadata_for_test(key, true)]; + let hooks = vec![hook_metadata_for_test(key, /*is_managed*/ true)]; let err = validate_hook_config_write_target(&hooks, key) .expect_err("managed hooks should not be user-configurable"); From 95f931bbee0f9db49d50f706f32e2741350c2340 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 14:18:20 -0700 Subject: [PATCH 45/64] Clarify plugin hook cache key --- codex-rs/core/src/plugins/manager.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index b82f9714e33..fc86e4f0dca 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -332,6 +332,7 @@ pub struct PluginsManager { featured_plugin_ids_cache: RwLock>, configured_marketplace_upgrade_state: RwLock, non_curated_cache_refresh_state: RwLock, + // The bool records whether plugin hooks were enabled when the cached outcome was produced. cached_enabled_outcome: RwLock>, remote_sync_lock: Semaphore, restriction_product: Option, From 2bb1247cd35a801d1dfe034f5ec8f9007429f9a4 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 14:34:23 -0700 Subject: [PATCH 46/64] Keep hook dispatches on one snapshot --- codex-rs/core/src/hook_runtime.rs | 26 +++++++++++++++----------- codex-rs/core/src/session/mod.rs | 3 +++ codex-rs/core/src/session/turn.rs | 5 +++-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index b534c63cf42..9c1c615c5ac 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -116,13 +116,13 @@ pub(crate) async fn run_pending_session_start_hooks( permission_mode: hook_permission_mode(turn_context), source: session_start_source, }; - let preview_runs = sess.hooks().preview_session_start(&request); + let hooks = sess.hooks(); + let preview_runs = hooks.preview_session_start(&request); run_context_injecting_hook( sess, turn_context, preview_runs, - sess.hooks() - .run_session_start(request, Some(turn_context.sub_id.clone())), + hooks.run_session_start(request, Some(turn_context.sub_id.clone())), ) .await .record_additional_contexts(sess, turn_context) @@ -153,14 +153,15 @@ pub(crate) async fn run_pre_tool_use_hooks( tool_use_id, tool_input: tool_input.clone(), }; - let preview_runs = sess.hooks().preview_pre_tool_use(&request); + let hooks = sess.hooks(); + let preview_runs = hooks.preview_pre_tool_use(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; let PreToolUseOutcome { hook_events, should_block, block_reason, - } = sess.hooks().run_pre_tool_use(request).await; + } = hooks.run_pre_tool_use(request).await; emit_hook_completed_events(sess, turn_context, hook_events).await; if should_block { @@ -202,13 +203,14 @@ pub(crate) async fn run_permission_request_hooks( run_id_suffix: run_id_suffix.to_string(), tool_input: payload.tool_input, }; - let preview_runs = sess.hooks().preview_permission_request(&request); + let hooks = sess.hooks(); + let preview_runs = hooks.preview_permission_request(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; let PermissionRequestOutcome { hook_events, decision, - } = sess.hooks().run_permission_request(request).await; + } = hooks.run_permission_request(request).await; emit_hook_completed_events(sess, turn_context, hook_events).await; decision @@ -242,10 +244,11 @@ pub(crate) async fn run_post_tool_use_hooks( tool_input, tool_response, }; - let preview_runs = sess.hooks().preview_post_tool_use(&request); + let hooks = sess.hooks(); + let preview_runs = hooks.preview_post_tool_use(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; - let outcome = sess.hooks().run_post_tool_use(request).await; + let outcome = hooks.run_post_tool_use(request).await; emit_hook_completed_events(sess, turn_context, outcome.hook_events.clone()).await; outcome } @@ -264,12 +267,13 @@ pub(crate) async fn run_user_prompt_submit_hooks( permission_mode: hook_permission_mode(turn_context), prompt, }; - let preview_runs = sess.hooks().preview_user_prompt_submit(&request); + let hooks = sess.hooks(); + let preview_runs = hooks.preview_user_prompt_submit(&request); run_context_injecting_hook( sess, turn_context, preview_runs, - sess.hooks().run_user_prompt_submit(request), + hooks.run_user_prompt_submit(request), ) .await } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ed8e3bb1509..1e98af88716 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1365,6 +1365,9 @@ impl Session { } pub(crate) async fn reload_user_config_layer(&self) { + // Refresh layer-backed runtime state for an existing session, including enabled plugin, + // skill, and hook state. Derived config fields such as feature gates and legacy notify + // settings remain session-static. let config_toml_path = { let state = self.state.lock().await; state diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 41195490b65..537a3e783fc 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -522,7 +522,8 @@ pub(crate) async fn run_turn( stop_hook_active, last_assistant_message: last_agent_message.clone(), }; - for run in sess.hooks().preview_stop(&stop_request) { + let hooks = sess.hooks(); + for run in hooks.preview_stop(&stop_request) { sess.send_event( &turn_context, EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent { @@ -532,7 +533,7 @@ pub(crate) async fn run_turn( ) .await; } - let stop_outcome = sess.hooks().run_stop(stop_request).await; + let stop_outcome = hooks.run_stop(stop_request).await; emit_hook_completed_events(&sess, &turn_context, stop_outcome.hook_events) .await; if stop_outcome.should_block { From ce6097a7b4869d1e21b78da281fa6ef2d4873423 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 16:22:01 -0700 Subject: [PATCH 47/64] Use config batch write for hook state --- .../schema/json/ClientRequest.json | 39 ------ .../codex_app_server_protocol.schemas.json | 60 +------- .../codex_app_server_protocol.v2.schemas.json | 60 +------- .../json/v2/HooksConfigWriteParams.json | 17 --- .../json/v2/HooksConfigWriteResponse.json | 13 -- .../schema/json/v2/HooksListResponse.json | 6 + .../schema/typescript/ClientRequest.ts | 3 +- .../schema/typescript/v2/HookMetadata.ts | 2 +- .../typescript/v2/HooksConfigWriteParams.ts | 5 - .../typescript/v2/HooksConfigWriteResponse.ts | 5 - .../schema/typescript/v2/index.ts | 2 - .../src/protocol/common.rs | 5 - .../app-server-protocol/src/protocol/v2.rs | 16 +-- codex-rs/app-server/README.md | 22 ++- .../app-server/src/codex_message_processor.rs | 64 +-------- .../app-server/tests/common/mcp_process.rs | 10 -- .../app-server/tests/suite/v2/hooks_list.rs | 70 ++++++++-- codex-rs/config/src/hook_config.rs | 18 +-- codex-rs/config/src/hooks_tests.rs | 17 ++- codex-rs/config/src/lib.rs | 2 +- codex-rs/config/src/types.rs | 2 +- codex-rs/core/config.schema.json | 30 ++-- codex-rs/core/src/config/edit.rs | 129 ------------------ codex-rs/core/src/config/edit_tests.rs | 46 ------- codex-rs/hooks/src/config_rules.rs | 57 ++++---- codex-rs/hooks/src/engine/discovery.rs | 2 + codex-rs/hooks/src/engine/mod.rs | 1 + codex-rs/hooks/src/engine/mod_tests.rs | 34 ++--- 28 files changed, 178 insertions(+), 559 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json delete mode 100644 codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index efa1445ae7b..c266fb5208a 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1390,21 +1390,6 @@ }, "type": "object" }, - "HooksConfigWriteParams": { - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - }, - "required": [ - "enabled", - "key" - ], - "type": "object" - }, "HooksListParams": { "properties": { "cwds": { @@ -5286,30 +5271,6 @@ "title": "Skills/config/writeRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "hooks/config/write" - ], - "title": "Hooks/config/writeRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/HooksConfigWriteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Hooks/config/writeRequest", - "type": "object" - }, { "properties": { "id": { 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 4faea9efcfb..afa8d073ec2 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 @@ -1122,30 +1122,6 @@ "title": "Skills/config/writeRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/v2/RequestId" - }, - "method": { - "enum": [ - "hooks/config/write" - ], - "title": "Hooks/config/writeRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/HooksConfigWriteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Hooks/config/writeRequest", - "type": "object" - }, { "properties": { "id": { @@ -9607,6 +9583,12 @@ "null" ] }, + "configKeyPath": { + "type": [ + "string", + "null" + ] + }, "displayOrder": { "format": "int64", "type": "integer" @@ -9839,36 +9821,6 @@ "title": "HookStartedNotification", "type": "object" }, - "HooksConfigWriteParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - }, - "required": [ - "enabled", - "key" - ], - "title": "HooksConfigWriteParams", - "type": "object" - }, - "HooksConfigWriteResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "effectiveEnabled": { - "type": "boolean" - } - }, - "required": [ - "effectiveEnabled" - ], - "title": "HooksConfigWriteResponse", - "type": "object" - }, "HooksListEntry": { "properties": { "cwd": { 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 3131409ac8d..4d6c5ca3811 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 @@ -1828,30 +1828,6 @@ "title": "Skills/config/writeRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "hooks/config/write" - ], - "title": "Hooks/config/writeRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/HooksConfigWriteParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Hooks/config/writeRequest", - "type": "object" - }, { "properties": { "id": { @@ -6237,6 +6213,12 @@ "null" ] }, + "configKeyPath": { + "type": [ + "string", + "null" + ] + }, "displayOrder": { "format": "int64", "type": "integer" @@ -6469,36 +6451,6 @@ "title": "HookStartedNotification", "type": "object" }, - "HooksConfigWriteParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - }, - "required": [ - "enabled", - "key" - ], - "title": "HooksConfigWriteParams", - "type": "object" - }, - "HooksConfigWriteResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "effectiveEnabled": { - "type": "boolean" - } - }, - "required": [ - "effectiveEnabled" - ], - "title": "HooksConfigWriteResponse", - "type": "object" - }, "HooksListEntry": { "properties": { "cwd": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json deleted file mode 100644 index da575768df9..00000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteParams.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - }, - "required": [ - "enabled", - "key" - ], - "title": "HooksConfigWriteParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json deleted file mode 100644 index 6016edad4f9..00000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksConfigWriteResponse.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "effectiveEnabled": { - "type": "boolean" - } - }, - "required": [ - "effectiveEnabled" - ], - "title": "HooksConfigWriteResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index b0dff3844c8..ad7143d5b76 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -47,6 +47,12 @@ "null" ] }, + "configKeyPath": { + "type": [ + "string", + "null" + ] + }, "displayOrder": { "format": "int64", "type": "integer" diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 2a28d77b8ce..82e313e4236 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -34,7 +34,6 @@ import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; -import type { HooksConfigWriteParams } from "./v2/HooksConfigWriteParams"; import type { HooksListParams } from "./v2/HooksListParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; import type { LoginAccountParams } from "./v2/LoginAccountParams"; @@ -78,4 +77,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "hooks/config/write", id: RequestId, params: HooksConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index fd1ce7f25b0..5df9ce387b2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -6,4 +6,4 @@ import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; -export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, }; +export type HookMetadata = { key: string, configKeyPath: string | null, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts deleted file mode 100644 index d7f7394a339..00000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type HooksConfigWriteParams = { key: string, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts deleted file mode 100644 index 10b3b73da45..00000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HooksConfigWriteResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type HooksConfigWriteResponse = { effectiveEnabled: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 2cfd3df8208..5aeb7f687ec 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -164,8 +164,6 @@ export type { HookRunSummary } from "./HookRunSummary"; export type { HookScope } from "./HookScope"; export type { HookSource } from "./HookSource"; export type { HookStartedNotification } from "./HookStartedNotification"; -export type { HooksConfigWriteParams } from "./HooksConfigWriteParams"; -export type { HooksConfigWriteResponse } from "./HooksConfigWriteResponse"; export type { HooksListEntry } from "./HooksListEntry"; export type { HooksListParams } from "./HooksListParams"; export type { HooksListResponse } from "./HooksListResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5c35fa874aa..13916227778 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -579,11 +579,6 @@ client_request_definitions! { serialization: global("config"), response: v2::SkillsConfigWriteResponse, }, - HooksConfigWrite => "hooks/config/write" { - params: v2::HooksConfigWriteParams, - serialization: global("config"), - response: v2::HooksConfigWriteResponse, - }, PluginInstall => "plugin/install" { params: v2::PluginInstallParams, serialization: global("config"), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 01ba3f35807..97a9befde3c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4510,6 +4510,7 @@ pub struct HooksListEntry { #[ts(export_to = "v2/")] pub struct HookMetadata { pub key: String, + pub config_key_path: Option, pub event_name: HookEventName, pub handler_type: HookHandlerType, pub matcher: Option, @@ -4686,21 +4687,6 @@ pub struct SkillsConfigWriteResponse { pub effective_enabled: bool, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksConfigWriteParams { - pub key: String, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksConfigWriteResponse { - pub effective_enabled: bool, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7b0ac197f4c..8f387109f93 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -207,7 +207,6 @@ Example with notification opt-out: - `device/key/public` — return a device key's SPKI DER public key as base64 plus its `algorithm` and `protectionClass`. - `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API. - `skills/config/write` — write user-level skill config by name or absolute path. -- `hooks/config/write` — write user-level hook config by hook key. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. @@ -1452,7 +1451,7 @@ To enable or disable a skill by name: } ``` -Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Managed hook keys use the `managed:` prefix and cannot be changed through `hooks/config/write`. Hook keys are source-namespaced with `file:`, `managed:`, or `plugin:` prefixes; the trailing event/group/handler selector is currently positional. +Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Non-managed hooks include a `configKeyPath` that clients can pass to `config/batchWrite` when upserting user-level hook state. Managed hook keys use the `managed:` prefix and omit `configKeyPath`; user config entries for those keys are ignored during loading. Hook keys are source-namespaced with `file:`, `managed:`, or `plugin:` prefixes; the trailing event/group/handler selector is currently positional. ```json { @@ -1472,6 +1471,7 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "cwd": "/Users/me/project", "hooks": [{ "key": "file:/Users/me/.codex/config.toml:pre_tool_use:0:0", + "configKeyPath": "hooks.state", "eventName": "pre_tool_use", "handlerType": "command", "matcher": "Bash", @@ -1491,19 +1491,29 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled } ``` -To enable or disable a non-managed hook, write the hook key returned by `hooks/list`: +To disable a non-managed hook, upsert a state entry at the returned `configKeyPath` with `config/batchWrite`: ```json { - "method": "hooks/config/write", + "method": "config/batchWrite", "id": 29, "params": { - "key": "file:/Users/me/.codex/config.toml:pre_tool_use:0:0", - "enabled": false + "edits": [{ + "keyPath": "hooks.state", + "value": { + "file:/Users/me/.codex/config.toml:pre_tool_use:0:0": { + "enabled": false + } + }, + "mergeStrategy": "upsert" + }], + "reloadUserConfig": true } } ``` +To re-enable it, upsert the same hook key with `"enabled": true`. + ## Apps Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, `branding`, `appMetadata`, `labels`, whether it is currently accessible, and whether it is enabled in config. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 9cb6ccfd12d..6ec96f728ee 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -2,7 +2,6 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed; use crate::command_exec::CommandExecManager; use crate::command_exec::StartCommandExecParams; -use crate::config_api::UserConfigReloader; use crate::config_manager::ConfigManager; use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; use crate::error_code::INTERNAL_ERROR_CODE; @@ -78,8 +77,6 @@ use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::HookMetadata; -use codex_app_server_protocol::HooksConfigWriteParams; -use codex_app_server_protocol::HooksConfigWriteResponse; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCErrorError; @@ -1112,10 +1109,6 @@ impl CodexMessageProcessor { self.skills_config_write(to_connection_request_id(request_id), params) .await; } - ClientRequest::HooksConfigWrite { request_id, params } => { - self.hooks_config_write(to_connection_request_id(request_id), params) - .await; - } ClientRequest::PluginInstall { request_id, params } => { self.plugin_install(to_connection_request_id(request_id), params) .await; @@ -6506,62 +6499,6 @@ impl CodexMessageProcessor { .map_err(|err| internal_error(format!("failed to update skill settings: {err}"))) } - /// Handle `hooks/config/write` by updating user-level hook enablement. - async fn hooks_config_write( - &self, - request_id: ConnectionRequestId, - params: HooksConfigWriteParams, - ) { - let HooksConfigWriteParams { key, enabled } = params; - if key.trim().is_empty() { - let error = JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: "hooks/config/write requires a non-empty key".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - if key.starts_with("managed:") { - let error = JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: format!("hook {key} is managed and cannot be configured"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - - let result = ConfigEditsBuilder::new(&self.config.codex_home) - .with_edits(vec![ConfigEdit::SetHookConfig { key, enabled }]) - .apply() - .await; - - match result { - Ok(()) => { - self.clear_plugin_related_caches(); - self.thread_manager.reload_user_config().await; - self.outgoing - .send_response( - request_id, - HooksConfigWriteResponse { - effective_enabled: enabled, - }, - ) - .await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to update hook settings: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - async fn turn_start( &self, request_id: ConnectionRequestId, @@ -8733,6 +8670,7 @@ fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { .iter() .map(|hook| HookMetadata { key: hook.key.clone(), + config_key_path: hook.config_key_path.clone(), event_name: hook.event_name.into(), handler_type: hook.handler_type.into(), matcher: hook.matcher.clone(), diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 051b77775bd..8b4709354ca 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -37,7 +37,6 @@ use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetConversationSummaryParams; -use codex_app_server_protocol::HooksConfigWriteParams; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; @@ -571,15 +570,6 @@ impl McpProcess { self.send_request("hooks/list", params).await } - /// Send a `hooks/config/write` JSON-RPC request. - pub async fn send_hooks_config_write_request( - &mut self, - params: HooksConfigWriteParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("hooks/config/write", params).await - } - /// Send a `marketplace/add` JSON-RPC request. pub async fn send_marketplace_add_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 97c19c272d1..ca8389f4aa3 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -3,16 +3,17 @@ use std::time::Duration; use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigEdit; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookHandlerType; use codex_app_server_protocol::HookMetadata; use codex_app_server_protocol::HookSource; -use codex_app_server_protocol::HooksConfigWriteParams; -use codex_app_server_protocol::HooksConfigWriteResponse; use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::MergeStrategy; use codex_app_server_protocol::RequestId; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -91,6 +92,7 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { cwd: cwd.path().to_path_buf(), hooks: vec![HookMetadata { key: format!("file:{}:pre_tool_use:0:0", config_path.as_path().display()), + config_key_path: Some("hooks.state".to_string()), event_name: HookEventName::PreToolUse, handler_type: HookHandlerType::Command, matcher: Some("Bash".to_string()), @@ -160,6 +162,7 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { cwd: cwd.path().to_path_buf(), hooks: vec![HookMetadata { key: "plugin:demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(), + config_key_path: Some("hooks.state".to_string()), event_name: HookEventName::PreToolUse, handler_type: HookHandlerType::Command, matcher: Some("Bash".to_string()), @@ -212,7 +215,7 @@ async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { } #[tokio::test] -async fn hooks_config_write_disables_user_hook() -> Result<()> { +async fn config_batch_write_toggles_user_hook() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; write_user_hook_config(codex_home.path())?; @@ -235,9 +238,22 @@ async fn hooks_config_write_disables_user_hook() -> Result<()> { assert_eq!(hook.enabled, true); let write_id = mcp - .send_hooks_config_write_request(HooksConfigWriteParams { - key: hook.key.clone(), - enabled: false, + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: hook + .config_key_path + .clone() + .expect("non-managed hook should be configurable"), + value: serde_json::json!({ + hook.key.clone(): { + "enabled": false + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, }) .await?; let response: JSONRPCResponse = timeout( @@ -245,8 +261,7 @@ async fn hooks_config_write_disables_user_hook() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(write_id)), ) .await??; - let HooksConfigWriteResponse { effective_enabled } = to_response(response)?; - assert_eq!(effective_enabled, false); + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; let request_id = mcp .send_hooks_list_request(HooksListParams { @@ -262,5 +277,44 @@ async fn hooks_config_write_disables_user_hook() -> Result<()> { assert_eq!(data[0].hooks.len(), 1); assert_eq!(data[0].hooks[0].key, hook.key); assert_eq!(data[0].hooks[0].enabled, false); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: hook + .config_key_path + .clone() + .expect("non-managed hook should be configurable"), + value: serde_json::json!({ + hook.key.clone(): { + "enabled": true + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let request_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![cwd.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!(data[0].hooks[0].enabled, true); Ok(()) } diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index b6b03cb802f..d947ebb8678 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; @@ -16,8 +17,14 @@ pub struct HooksFile { pub struct HooksToml { #[serde(flatten)] pub events: HookEventsToml, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub config: Vec, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub state: BTreeMap, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HookStateToml { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -36,13 +43,6 @@ pub struct HookEventsToml { pub stop: Vec, } -#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct HookConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub key: Option, - pub enabled: bool, -} - impl HookEventsToml { pub fn is_empty(&self) -> bool { let Self { diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 3859b602fd9..f2f42cd9fd2 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -1,5 +1,7 @@ use pretty_assertions::assert_eq; +use std::collections::BTreeMap; + use super::HookEventsToml; use super::HookHandlerConfig; use super::HooksFile; @@ -83,11 +85,10 @@ statusMessage = "checking" } #[test] -fn hooks_toml_deserializes_inline_events_and_config() { +fn hooks_toml_deserializes_inline_events_and_state_map() { let parsed: HooksToml = toml::from_str( r#" -[[config]] -key = "file:/tmp/hooks.json:pre_tool_use:0:0" +[state."file:/tmp/hooks.json:pre_tool_use:0:0"] enabled = false [[PreToolUse]] @@ -115,10 +116,12 @@ command = "python3 /tmp/pre.py" }], ..Default::default() }, - config: vec![super::HookConfig { - key: Some("file:/tmp/hooks.json:pre_tool_use:0:0".to_string()), - enabled: false, - }], + state: BTreeMap::from([( + "file:/tmp/hooks.json:pre_tool_use:0:0".to_string(), + super::HookStateToml { + enabled: Some(false), + }, + )]), } ); } diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index ef8995d6b5d..e13b392fa31 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -70,9 +70,9 @@ pub use diagnostics::format_config_error; pub use diagnostics::format_config_error_with_source; pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; -pub use hook_config::HookConfig; pub use hook_config::HookEventsToml; pub use hook_config::HookHandlerConfig; +pub use hook_config::HookStateToml; pub use hook_config::HooksFile; pub use hook_config::HooksToml; pub use hook_config::ManagedHooksRequirementsToml; diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index e8be1c25927..c2dbeb76e7b 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -674,7 +674,7 @@ pub struct Notice { pub external_config_migration_prompts: ExternalConfigMigrationPrompts, } -pub use crate::hook_config::HookConfig; +pub use crate::hook_config::HookStateToml; pub use crate::skills_config::BundledSkillsConfig; pub use crate::skills_config::SkillConfig; pub use crate::skills_config::SkillsConfig; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1b0e7877b75..240688303e2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -859,20 +859,6 @@ } ] }, - "HookConfig": { - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "HookHandlerConfig": { "oneOf": [ { @@ -937,6 +923,14 @@ } ] }, + "HookStateToml": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, "HooksToml": { "properties": { "PermissionRequest": { @@ -981,11 +975,11 @@ }, "type": "array" }, - "config": { - "items": { - "$ref": "#/definitions/HookConfig" + "state": { + "additionalProperties": { + "$ref": "#/definitions/HookStateToml" }, - "type": "array" + "type": "object" } }, "type": "object" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 790585735f3..f86e0d925b9 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -61,8 +61,6 @@ pub enum ConfigEdit { SetSkillConfig { path: PathBuf, enabled: bool }, /// Set or clear a skill config entry under `[[skills.config]]` by name. SetSkillConfigByName { name: String, enabled: bool }, - /// Set or clear a hook config entry under `[[hooks.config]]` by key. - SetHookConfig { key: String, enabled: bool }, /// Set trust_level under `[projects.""]`, /// migrating inline tables to explicit tables. SetProjectTrustLevel { path: PathBuf, level: TrustLevel }, @@ -560,9 +558,6 @@ impl ConfigDocument { ConfigEdit::SetSkillConfigByName { name, enabled } => { Ok(self.set_skill_config(SkillConfigSelector::Name(name.clone()), *enabled)) } - ConfigEdit::SetHookConfig { key, enabled } => { - Ok(self.set_hook_config(key.clone(), *enabled)) - } ConfigEdit::SetPath { segments, value } => Ok(self.insert(segments, value.clone())), ConfigEdit::ClearPath { segments } => Ok(self.clear_owned(segments)), ConfigEdit::SetProjectTrustLevel { path, level } => { @@ -763,121 +758,6 @@ impl ConfigDocument { mutated } - /// Set or clear a `[[hooks.config]]` entry by hook key. - /// - /// Disabled state is represented explicitly as `enabled = false`. Enabled - /// state is represented by removing the matching override, matching the - /// skills config behavior. - fn set_hook_config(&mut self, key: String, enabled: bool) -> bool { - let key = key.trim().to_string(); - if key.is_empty() { - return false; - } - let mut remove_hooks_table = false; - let mut mutated = false; - - { - let root = self.doc.as_table_mut(); - let hooks_item = match root.get_mut("hooks") { - Some(item) => item, - None => { - if enabled { - return false; - } - root.insert( - "hooks", - TomlItem::Table(document_helpers::new_implicit_table()), - ); - let Some(item) = root.get_mut("hooks") else { - return false; - }; - item - } - }; - - if document_helpers::ensure_table_for_write(hooks_item).is_none() { - if enabled { - return false; - } - *hooks_item = TomlItem::Table(document_helpers::new_implicit_table()); - } - let Some(hooks_table) = hooks_item.as_table_mut() else { - return false; - }; - - let config_item = match hooks_table.get_mut("config") { - Some(item) => item, - None => { - if enabled { - return false; - } - hooks_table.insert("config", TomlItem::ArrayOfTables(ArrayOfTables::new())); - let Some(item) = hooks_table.get_mut("config") else { - return false; - }; - item - } - }; - - if !matches!(config_item, TomlItem::ArrayOfTables(_)) { - if enabled { - return false; - } - *config_item = TomlItem::ArrayOfTables(ArrayOfTables::new()); - } - - let TomlItem::ArrayOfTables(overrides) = config_item else { - return false; - }; - - // Only persist negative overrides. Re-enabling removes the entry so - // the hook's default discovered state applies again. - let existing_index = overrides.iter().enumerate().find_map(|(idx, table)| { - hook_config_key_from_table(table) - .filter(|value| value == &key) - .map(|_| idx) - }); - - if enabled { - if let Some(index) = existing_index { - overrides.remove(index); - mutated = true; - if overrides.is_empty() { - hooks_table.remove("config"); - if hooks_table.is_empty() { - remove_hooks_table = true; - } - } - } - } else if let Some(index) = existing_index { - for (idx, table) in overrides.iter_mut().enumerate() { - if idx == index { - table["key"] = value(key); - table["enabled"] = value(false); - mutated = true; - break; - } - } - } else { - let mut entry = TomlTable::new(); - entry.set_implicit(false); - entry["key"] = value(key); - entry["enabled"] = value(false); - overrides.push(entry); - mutated = true; - } - } - - // Defer removing the parent table until the nested borrows above are - // dropped. - if remove_hooks_table { - let root = self.doc.as_table_mut(); - root.remove("hooks"); - } - - mutated - } - fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { let resolved: Vec = segments .iter() @@ -1024,15 +904,6 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele } } -fn hook_config_key_from_table(table: &TomlTable) -> Option { - table - .get("key") - .and_then(|item| item.as_str()) - .map(str::trim) - .filter(|key| !key.is_empty()) - .map(str::to_string) -} - /// Persist edits using a blocking strategy. pub fn apply_blocking( codex_home: &Path, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 97f4b83d887..376632a93a7 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -298,52 +298,6 @@ enabled = false assert_eq!(contents, expected); } -#[test] -fn set_hook_config_writes_disabled_entry() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - ConfigEditsBuilder::new(codex_home) - .with_edits([ConfigEdit::SetHookConfig { - key: "file:/tmp/hooks.json:pre_tool_use:0:0".to_string(), - enabled: false, - }]) - .apply_blocking() - .expect("persist"); - - let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[[hooks.config]] -key = "file:/tmp/hooks.json:pre_tool_use:0:0" -enabled = false -"#; - assert_eq!(contents, expected); -} - -#[test] -fn set_hook_config_removes_entry_when_enabled() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[[hooks.config]] -key = "file:/tmp/hooks.json:pre_tool_use:0:0" -enabled = false -"#, - ) - .expect("seed config"); - - ConfigEditsBuilder::new(codex_home) - .with_edits([ConfigEdit::SetHookConfig { - key: "file:/tmp/hooks.json:pre_tool_use:0:0".to_string(), - enabled: true, - }]) - .apply_blocking() - .expect("persist"); - - let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - assert_eq!(contents, ""); -} - #[test] fn blocking_set_model_preserves_inline_table_contents() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index 7f851daa127..fc427a8162e 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -3,7 +3,6 @@ use std::collections::HashSet; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; -use codex_config::HookConfig; use codex_config::HooksToml; /// Build hook enablement rules from config layers that are allowed to override @@ -42,16 +41,21 @@ pub(crate) fn disabled_hook_keys_from_stack( } }; - for entry in hooks.config { - let Some(key) = hook_config_key(&entry) else { + for (key, state) in hooks.state { + let key = key.trim(); + if key.is_empty() { continue; - }; - // Later layers win: an enabled entry removes a disabled override - // for the same key, while a disabled entry inserts it. - if entry.enabled { - disabled_keys.remove(&key); - } else { - disabled_keys.insert(key); + } + // Later layers win. Hooks without an explicit enabled override can + // still carry future per-hook state without changing enablement. + match state.enabled { + Some(false) => { + disabled_keys.insert(key.to_string()); + } + Some(true) => { + disabled_keys.remove(key); + } + None => {} } } } @@ -59,15 +63,6 @@ pub(crate) fn disabled_hook_keys_from_stack( disabled_keys } -fn hook_config_key(entry: &HookConfig) -> Option { - let key = entry.key.as_deref().map(str::trim).unwrap_or_default(); - if key.is_empty() { - None - } else { - Some(key.to_string()) - } -} - #[cfg(test)] mod tests { use codex_config::ConfigLayerEntry; @@ -87,11 +82,11 @@ mod tests { ConfigLayerSource::User { file: test_path_buf("/tmp/config.toml").abs(), }, - config_with_hook_override(key, /*enabled*/ false), + config_with_hook_override(key, Some(false)), ), ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, - config_with_hook_override(key, /*enabled*/ true), + config_with_hook_override(key, Some(true)), ), ], Default::default(), @@ -102,7 +97,7 @@ mod tests { assert_eq!(disabled_hook_keys_from_stack(Some(&stack)), HashSet::new()); } - fn config_with_hook_override(key: &str, enabled: bool) -> TomlValue { + fn config_with_hook_override(key: &str, enabled: Option) -> TomlValue { let mut config = TomlValue::Table(Default::default()); let TomlValue::Table(config_entries) = &mut config else { unreachable!("config root should be a table"); @@ -111,13 +106,19 @@ mod tests { let TomlValue::Table(hook_entries) = &mut hooks else { unreachable!("hooks should be a table"); }; - let mut hook_override = TomlValue::Table(Default::default()); - let TomlValue::Table(hook_override_entries) = &mut hook_override else { - unreachable!("hook override should be a table"); + let mut state_entries = TomlValue::Table(Default::default()); + let TomlValue::Table(state_map) = &mut state_entries else { + unreachable!("state should be a table"); + }; + let mut hook_state = TomlValue::Table(Default::default()); + let TomlValue::Table(hook_state_entries) = &mut hook_state else { + unreachable!("hook state should be a table"); }; - hook_override_entries.insert("key".to_string(), TomlValue::String(key.to_string())); - hook_override_entries.insert("enabled".to_string(), TomlValue::Boolean(enabled)); - hook_entries.insert("config".to_string(), TomlValue::Array(vec![hook_override])); + if let Some(enabled) = enabled { + hook_state_entries.insert("enabled".to_string(), TomlValue::Boolean(enabled)); + } + state_map.insert(key.to_string(), hook_state); + hook_entries.insert("state".to_string(), state_entries); config_entries.insert("hooks".to_string(), hooks); config } diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 40bc470dbbf..1065879500c 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -415,8 +415,10 @@ fn append_matcher_groups( handler_index ); let enabled = source.is_managed || !source.disabled_hook_keys.contains(&key); + let config_key_path = (!source.is_managed).then(|| "hooks.state".to_string()); hook_entries.push(HookListEntry { key, + config_key_path, event_name, handler_type: HookHandlerType::Command, matcher: matcher.map(ToOwned::to_owned), diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 8b802dd726a..3a77f123003 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -72,6 +72,7 @@ impl ConfiguredHandler { #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookListEntry { pub key: String, + pub config_key_path: Option, pub event_name: HookEventName, pub handler_type: HookHandlerType, pub matcher: Option, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 26c71267e92..18585a28a71 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -189,28 +189,19 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { let TomlValue::Table(hooks_entries) = &mut hooks else { unreachable!("hooks should be a table"); }; - let mut managed_config = TomlValue::Table(Default::default()); - let TomlValue::Table(managed_config_entries) = &mut managed_config else { - unreachable!("hook config should be a table"); + let mut state = TomlValue::Table(Default::default()); + let TomlValue::Table(state_entries) = &mut state else { + unreachable!("state should be a table"); }; - managed_config_entries.insert( - "key".to_string(), - TomlValue::String(managed_disabled_key.clone()), - ); - managed_config_entries.insert("enabled".to_string(), TomlValue::Boolean(false)); - let mut user_hook_config = TomlValue::Table(Default::default()); - let TomlValue::Table(user_hook_config_entries) = &mut user_hook_config else { - unreachable!("hook config should be a table"); - }; - user_hook_config_entries.insert( - "key".to_string(), - TomlValue::String(user_disabled_key.clone()), - ); - user_hook_config_entries.insert("enabled".to_string(), TomlValue::Boolean(false)); - hooks_entries.insert( - "config".to_string(), - TomlValue::Array(vec![managed_config, user_hook_config]), - ); + for key in [managed_disabled_key.clone(), user_disabled_key.clone()] { + let mut hook_state = TomlValue::Table(Default::default()); + let TomlValue::Table(hook_state_entries) = &mut hook_state else { + unreachable!("hook state should be a table"); + }; + hook_state_entries.insert("enabled".to_string(), TomlValue::Boolean(false)); + state_entries.insert(key, hook_state); + } + hooks_entries.insert("state".to_string(), state); let mut user_hook_group = TomlValue::Table(Default::default()); let TomlValue::Table(user_hook_group_entries) = &mut user_hook_group else { unreachable!("user hook group should be a table"); @@ -271,6 +262,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); assert_eq!(discovered.hook_entries.len(), 2); assert_eq!(discovered.hook_entries[0].key, managed_disabled_key); + assert_eq!(discovered.hook_entries[0].config_key_path, None); assert_eq!(discovered.hook_entries[0].enabled, true); assert_eq!(discovered.hook_entries[1].key, user_disabled_key); assert_eq!(discovered.hook_entries[1].enabled, false); From e0124f884ecc4a90d6b1ac8944db3e72701a498e Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 17:11:10 -0700 Subject: [PATCH 48/64] Keep managed hook sources non-configurable --- .../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/json/v2/HooksListResponse.json | 1 + .../schema/typescript/v2/HookSource.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 1 + codex-rs/app-server/README.md | 6 +- .../app-server/tests/suite/v2/hooks_list.rs | 4 +- codex-rs/config/src/hooks_tests.rs | 4 +- codex-rs/hooks/src/engine/discovery.rs | 30 ++++--- codex-rs/hooks/src/engine/mod_tests.rs | 81 ++++++++++++++++++- codex-rs/protocol/src/protocol.rs | 1 + 14 files changed, 115 insertions(+), 20 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index c94559c112a..aaff271a444 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1901,6 +1901,7 @@ "mdm", "sessionFlags", "plugin", + "cloudRequirements", "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 afa8d073ec2..70fedc9c3f7 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 @@ -9792,6 +9792,7 @@ "mdm", "sessionFlags", "plugin", + "cloudRequirements", "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 4d6c5ca3811..44d5ef217c3 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 @@ -6422,6 +6422,7 @@ "mdm", "sessionFlags", "plugin", + "cloudRequirements", "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 7c03e35543b..d55c059a735 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -161,6 +161,7 @@ "mdm", "sessionFlags", "plugin", + "cloudRequirements", "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 d08300d5264..03d2998ca5f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -161,6 +161,7 @@ "mdm", "sessionFlags", "plugin", + "cloudRequirements", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index ad7143d5b76..ef9d27563ee 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -119,6 +119,7 @@ "mdm", "sessionFlags", "plugin", + "cloudRequirements", "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 24a06bd1385..98bbe1e412a 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" | "plugin" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown"; +export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "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 97a9befde3c..35b2c75815f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -470,6 +470,7 @@ v2_enum_from_core!( Mdm, SessionFlags, Plugin, + CloudRequirements, LegacyManagedConfigFile, LegacyManagedConfigMdm, Unknown, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8f387109f93..5f57f553238 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1451,7 +1451,7 @@ To enable or disable a skill by name: } ``` -Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Non-managed hooks include a `configKeyPath` that clients can pass to `config/batchWrite` when upserting user-level hook state. Managed hook keys use the `managed:` prefix and omit `configKeyPath`; user config entries for those keys are ignored during loading. Hook keys are source-namespaced with `file:`, `managed:`, or `plugin:` prefixes; the trailing event/group/handler selector is currently positional. +Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Non-managed hooks include a `configKeyPath` that clients can pass to `config/batchWrite` when upserting user-level hook state. Managed hooks omit `configKeyPath`, and user config entries for those keys are ignored during loading. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional. ```json { @@ -1470,7 +1470,7 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "data": [{ "cwd": "/Users/me/project", "hooks": [{ - "key": "file:/Users/me/.codex/config.toml:pre_tool_use:0:0", + "key": "/Users/me/.codex/config.toml:pre_tool_use:0:0", "configKeyPath": "hooks.state", "eventName": "pre_tool_use", "handlerType": "command", @@ -1501,7 +1501,7 @@ To disable a non-managed hook, upsert a state entry at the returned `configKeyPa "edits": [{ "keyPath": "hooks.state", "value": { - "file:/Users/me/.codex/config.toml:pre_tool_use:0:0": { + "/Users/me/.codex/config.toml:pre_tool_use:0:0": { "enabled": false } }, diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index ca8389f4aa3..efc8f699cd4 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -91,7 +91,7 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { vec![HooksListEntry { cwd: cwd.path().to_path_buf(), hooks: vec![HookMetadata { - key: format!("file:{}:pre_tool_use:0:0", config_path.as_path().display()), + key: format!("{}:pre_tool_use:0:0", config_path.as_path().display()), config_key_path: Some("hooks.state".to_string()), event_name: HookEventName::PreToolUse, handler_type: HookHandlerType::Command, @@ -161,7 +161,7 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { vec![HooksListEntry { cwd: cwd.path().to_path_buf(), hooks: vec![HookMetadata { - key: "plugin:demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(), + key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(), config_key_path: Some("hooks.state".to_string()), event_name: HookEventName::PreToolUse, handler_type: HookHandlerType::Command, diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index f2f42cd9fd2..93541ee7f8a 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -88,7 +88,7 @@ statusMessage = "checking" fn hooks_toml_deserializes_inline_events_and_state_map() { let parsed: HooksToml = toml::from_str( r#" -[state."file:/tmp/hooks.json:pre_tool_use:0:0"] +[state."/tmp/hooks.json:pre_tool_use:0:0"] enabled = false [[PreToolUse]] @@ -117,7 +117,7 @@ command = "python3 /tmp/pre.py" ..Default::default() }, state: BTreeMap::from([( - "file:/tmp/hooks.json:pre_tool_use:0:0".to_string(), + "/tmp/hooks.json:pre_tool_use:0:0".to_string(), super::HookStateToml { enabled: Some(false), }, diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 1065879500c..2e3d3d56d27 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -35,7 +35,7 @@ pub(crate) struct DiscoveryResult { #[derive(Clone)] struct HookHandlerSource<'a> { path: &'a AbsolutePathBuf, - key_prefix: String, + key_source: String, is_managed: bool, source: HookSource, disabled_hook_keys: &'a HashSet, @@ -69,6 +69,7 @@ pub(crate) fn discover_handlers( /*include_disabled*/ false, ) { let hook_source = hook_source_for_config_layer_source(&layer.name); + let is_managed = hook_source_is_managed(hook_source); let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings); let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings); @@ -92,8 +93,8 @@ pub(crate) fn discover_handlers( &mut display_order, HookHandlerSource { path: &source_path, - key_prefix: format!("file:{}", source_path.display()), - is_managed: false, + key_source: source_path.display().to_string(), + is_managed, source: hook_source, disabled_hook_keys: &disabled_hook_keys, env: HashMap::new(), @@ -144,7 +145,7 @@ fn append_managed_requirement_handlers( display_order, HookHandlerSource { path: &source_path, - key_prefix: format!("managed:{}", source_path.display()), + key_source: source_path.display().to_string(), is_managed: true, source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), disabled_hook_keys, @@ -190,7 +191,7 @@ fn append_plugin_hook_sources( display_order, HookHandlerSource { path: &source_path, - key_prefix: format!("plugin:{plugin_id}:{source_relative_path}"), + key_source: format!("{plugin_id}:{source_relative_path}"), is_managed: false, source: HookSource::Plugin, disabled_hook_keys, @@ -409,7 +410,7 @@ fn append_matcher_groups( // TODO(abhinav): replace this positional suffix with a durable hook id. let key = format!( "{}:{}:{}:{}", - source.key_prefix, + source.key_source, hook_event_key_label(event_name), group_index, handler_index @@ -485,6 +486,16 @@ fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource } } +fn hook_source_is_managed(source: HookSource) -> bool { + matches!( + source, + HookSource::System + | HookSource::Mdm + | HookSource::LegacyManagedConfigFile + | HookSource::LegacyManagedConfigMdm + ) +} + fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> HookSource { match source { Some(RequirementSource::MdmManagedPreferences { .. }) => HookSource::Mdm, @@ -495,9 +506,8 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => { HookSource::LegacyManagedConfigMdm } - Some(RequirementSource::CloudRequirements | RequirementSource::Unknown) | None => { - HookSource::Unknown - } + Some(RequirementSource::CloudRequirements) => HookSource::CloudRequirements, + Some(RequirementSource::Unknown) | None => HookSource::Unknown, } } @@ -530,7 +540,7 @@ mod tests { ) -> super::HookHandlerSource<'a> { super::HookHandlerSource { path, - key_prefix: format!("file:{}", path.display()), + key_source: path.display().to_string(), is_managed: false, source: hook_source(), disabled_hook_keys, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 18585a28a71..d249c7e429a 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -179,8 +179,8 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ); let config_path = AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path"); - let managed_disabled_key = format!("managed:{}:pre_tool_use:0:0", managed_dir.display()); - let user_disabled_key = format!("file:{}:pre_tool_use:0:0", config_path.display()); + let managed_disabled_key = format!("{}:pre_tool_use:0:0", managed_dir.display()); + let user_disabled_key = format!("{}:pre_tool_use:0:0", config_path.display()); let mut user_config = TomlValue::Table(Default::default()); let TomlValue::Table(user_config_entries) = &mut user_config else { unreachable!("config TOML root should be a table"); @@ -268,6 +268,83 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { assert_eq!(discovered.hook_entries[1].enabled, false); } +#[test] +fn user_disablement_does_not_filter_managed_layer_hooks() { + let temp = tempdir().expect("create temp dir"); + let managed_config_path = + AbsolutePathBuf::try_from(temp.path().join("managed_config.toml")).expect("absolute path"); + let user_config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path"); + let managed_key = format!("{}:pre_tool_use:0:0", managed_config_path.display()); + + let config_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_config_path, + }, + config_with_hook_state(&managed_key, false), + ), + ConfigLayerEntry::new( + ConfigLayerSource::LegacyManagedConfigTomlFromFile { + file: managed_config_path, + }, + config_with_pre_tool_use_hook("python3 /tmp/managed-layer.py"), + ), + ], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + assert_eq!(engine.handlers.len(), 1); + assert!(engine.handlers[0].is_managed); + let discovered = + super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); + assert_eq!(discovered.hook_entries.len(), 1); + assert_eq!(discovered.hook_entries[0].key, managed_key); + assert_eq!(discovered.hook_entries[0].config_key_path, None); + assert_eq!(discovered.hook_entries[0].enabled, true); +} + +fn config_with_hook_state(key: &str, enabled: bool) -> TomlValue { + serde_json::from_value(serde_json::json!({ + "hooks": { + "state": { + (key): { + "enabled": enabled, + }, + }, + }, + })) + .expect("config TOML should deserialize") +} + +fn config_with_pre_tool_use_hook(command: &str) -> TomlValue { + serde_json::from_value(serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "hooks": [{ + "type": "command", + "command": command, + }], + }], + }, + })) + .expect("config TOML should deserialize") +} + #[test] fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { let temp = tempdir().expect("create temp dir"); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c9ff684f7c9..db7a938b7d1 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1564,6 +1564,7 @@ pub enum HookSource { Mdm, SessionFlags, Plugin, + CloudRequirements, LegacyManagedConfigFile, LegacyManagedConfigMdm, #[default] From 5744c8b620f7a2a3b5f5096d382330facea5ee10 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 17:37:53 -0700 Subject: [PATCH 49/64] Fix hook source CI failures --- codex-rs/analytics/src/analytics_client_tests.rs | 11 +++++++++++ codex-rs/analytics/src/events.rs | 1 + codex-rs/core/src/hook_runtime.rs | 13 +++++++++++++ codex-rs/hooks/src/config_rules.rs | 4 ++-- codex-rs/hooks/src/engine/mod_tests.rs | 2 +- 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 352acbe1fc1..de030448840 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1499,6 +1499,15 @@ fn hook_run_metadata_maps_sources_and_statuses() { }, )) .expect("serialize project hook"); + let cloud_requirements = serde_json::to_value(codex_hook_run_metadata( + &tracking, + HookRunFact { + event_name: HookEventName::Stop, + hook_source: HookSource::CloudRequirements, + status: HookRunStatus::Blocked, + }, + )) + .expect("serialize cloud requirements hook"); let unknown = serde_json::to_value(codex_hook_run_metadata( &tracking, HookRunFact { @@ -1513,6 +1522,8 @@ fn hook_run_metadata_maps_sources_and_statuses() { assert_eq!(system["status"], "completed"); assert_eq!(project["hook_source"], "project"); assert_eq!(project["status"], "blocked"); + assert_eq!(cloud_requirements["hook_source"], "cloud_requirements"); + assert_eq!(cloud_requirements["status"], "blocked"); assert_eq!(unknown["hook_source"], "unknown"); assert_eq!(unknown["status"], "failed"); } diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 24ae8e00b9c..7120960b627 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -685,6 +685,7 @@ fn analytics_hook_source(source: HookSource) -> &'static str { HookSource::Mdm => "mdm", HookSource::SessionFlags => "session_flags", HookSource::Plugin => "plugin", + HookSource::CloudRequirements => "cloud_requirements", HookSource::LegacyManagedConfigFile => "legacy_managed_config_file", HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm", HookSource::Unknown => "unknown", diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 9c1c615c5ac..9a928545152 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -478,6 +478,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); HookSource::Mdm => "mdm", HookSource::SessionFlags => "session_flags", HookSource::Plugin => "plugin", + HookSource::CloudRequirements => "cloud_requirements", HookSource::LegacyManagedConfigFile => "legacy_managed_config_file", HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm", HookSource::Unknown => "unknown", @@ -609,6 +610,18 @@ mod tests { ("status", "blocked"), ] ); + + let cloud_requirements = + sample_hook_run(HookRunStatus::Blocked, HookSource::CloudRequirements); + + assert_eq!( + hook_run_metric_tags(&cloud_requirements), + [ + ("hook_name", "Stop"), + ("source", "cloud_requirements"), + ("status", "blocked"), + ] + ); } #[test] diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index fc427a8162e..1afedbcde06 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -82,11 +82,11 @@ mod tests { ConfigLayerSource::User { file: test_path_buf("/tmp/config.toml").abs(), }, - config_with_hook_override(key, Some(false)), + config_with_hook_override(key, Some(/*enabled*/ false)), ), ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, - config_with_hook_override(key, Some(true)), + config_with_hook_override(key, Some(/*enabled*/ true)), ), ], Default::default(), diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index d249c7e429a..d03250e1e62 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -283,7 +283,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { ConfigLayerSource::User { file: user_config_path, }, - config_with_hook_state(&managed_key, false), + config_with_hook_state(&managed_key, /*enabled*/ false), ), ConfigLayerEntry::new( ConfigLayerSource::LegacyManagedConfigTomlFromFile { From e0e4f62a90cb578f9b446a961aabfa03491df73b Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 18:36:09 -0700 Subject: [PATCH 50/64] Omit managed hook config key paths --- .../schema/typescript/v2/HookMetadata.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index 5df9ce387b2..2465f200de2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -6,4 +6,4 @@ import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; -export type HookMetadata = { key: string, configKeyPath: string | null, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, }; +export type HookMetadata = { key: string, configKeyPath?: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 35b2c75815f..88c3abda0c4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4511,6 +4511,8 @@ pub struct HooksListEntry { #[ts(export_to = "v2/")] pub struct HookMetadata { pub key: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub config_key_path: Option, pub event_name: HookEventName, pub handler_type: HookHandlerType, @@ -10858,4 +10860,26 @@ mod tests { "unexpected error: {err}" ); } + + #[test] + fn hook_metadata_omits_missing_config_key_path() { + let value = serde_json::to_value(HookMetadata { + key: "managed:/tmp/hooks.json:pre_tool_use:0:0".to_string(), + config_key_path: None, + event_name: HookEventName::PreToolUse, + handler_type: HookHandlerType::Command, + matcher: Some("Bash".to_string()), + command: Some("echo managed hook".to_string()), + timeout_sec: 5, + status_message: None, + source_path: test_absolute_path(), + source: HookSource::CloudRequirements, + plugin_id: None, + display_order: 0, + enabled: true, + }) + .expect("hook metadata should serialize"); + + assert_eq!(value.get("configKeyPath"), None); + } } From dbe6192593035e63a4e7083f87b81dfebf75ce2e Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 20:06:40 -0700 Subject: [PATCH 51/64] Handle hook reload review feedback --- codex-rs/core/src/session/mod.rs | 24 ++++--- codex-rs/hooks/src/config_rules.rs | 47 ++++++++++++-- codex-rs/hooks/src/engine/discovery.rs | 88 +++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 7d8c43fe7ce..9ba07a60c53 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1417,14 +1417,22 @@ impl Session { }; self.services.skills_manager.clear_cache(); self.services.plugins_manager.clear_cache(); - self.services.hooks.store(Arc::new( - build_hooks_for_config( - config.as_ref(), - self.services.plugins_manager.as_ref(), - self.services.user_shell.as_ref(), - ) - .await, - )); + let hooks = build_hooks_for_config( + config.as_ref(), + self.services.plugins_manager.as_ref(), + self.services.user_shell.as_ref(), + ) + .await; + + let state = self.state.lock().await; + // A newer reload may have updated the config while this hook build was in flight. + // Only publish hooks derived from the current config snapshot. + if Arc::ptr_eq( + &state.session_configuration.original_config_do_not_use, + &config, + ) { + self.services.hooks.store(Arc::new(hooks)); + } } async fn build_settings_update_items( diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index 1afedbcde06..a49e718f95c 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -1,9 +1,10 @@ +use std::collections::BTreeMap; use std::collections::HashSet; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; -use codex_config::HooksToml; +use codex_config::HookStateToml; /// Build hook enablement rules from config layers that are allowed to override /// user preferences. @@ -31,17 +32,21 @@ pub(crate) fn disabled_hook_keys_from_stack( continue; } - let Some(hooks_value) = layer.config.get("hooks") else { + let Some(state_value) = layer + .config + .get("hooks") + .and_then(|hooks| hooks.get("state")) + else { continue; }; - let hooks: HooksToml = match hooks_value.clone().try_into() { - Ok(hooks) => hooks, + let state_by_key: BTreeMap = match state_value.clone().try_into() { + Ok(state_by_key) => state_by_key, Err(_) => { continue; } }; - for (key, state) in hooks.state { + for (key, state) in state_by_key { let key = key.trim(); if key.is_empty() { continue; @@ -97,6 +102,38 @@ mod tests { assert_eq!(disabled_hook_keys_from_stack(Some(&stack)), HashSet::new()); } + #[test] + fn disabled_hook_keys_from_stack_ignores_malformed_hook_events() { + let key = "file:/tmp/hooks.json:pre_tool_use:0:0"; + let mut config = config_with_hook_override(key, Some(/*enabled*/ false)); + let TomlValue::Table(config_entries) = &mut config else { + unreachable!("config root should be a table"); + }; + let Some(TomlValue::Table(hook_entries)) = config_entries.get_mut("hooks") else { + unreachable!("hooks should be a table"); + }; + hook_entries.insert( + "SessionStart".to_string(), + TomlValue::String("not a matcher list".to_string()), + ); + let stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_path_buf("/tmp/config.toml").abs(), + }, + config, + )], + Default::default(), + Default::default(), + ) + .expect("config layer stack"); + + assert_eq!( + disabled_hook_keys_from_stack(Some(&stack)), + HashSet::from([key.to_string()]) + ); + } + fn config_with_hook_override(key: &str, enabled: Option) -> TomlValue { let mut config = TomlValue::Table(Default::default()); let TomlValue::Table(config_entries) = &mut config else { diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 2e3d3d56d27..82d036ca737 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -297,7 +297,7 @@ fn load_toml_hooks_from_layer( ) -> Option<(AbsolutePathBuf, HookEventsToml)> { let source_path = config_toml_source_path(layer); let hook_value = layer.config.get("hooks")?.clone(); - let parsed = match codex_config::HooksToml::deserialize(hook_value) { + let parsed = match HookEventsToml::deserialize(hook_value) { Ok(parsed) => parsed, Err(err) => { warnings.push(format!( @@ -308,7 +308,7 @@ fn load_toml_hooks_from_layer( } }; - (!parsed.events.is_empty()).then_some((source_path, parsed.events)) + (!parsed.is_empty()).then_some((source_path, parsed)) } fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf { @@ -513,7 +513,9 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo #[cfg(test)] mod tests { + use codex_config::ConfigLayerEntry; use codex_config::ConfigLayerSource; + use codex_config::HookEventsToml; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookSource; use codex_utils_absolute_path::AbsolutePathBuf; @@ -525,6 +527,7 @@ mod tests { use super::append_matcher_groups; use codex_config::HookHandlerConfig; use codex_config::MatcherGroup; + use codex_config::TomlValue; fn source_path() -> AbsolutePathBuf { test_path_buf("/tmp/hooks.json").abs() @@ -680,6 +683,87 @@ mod tests { assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write")); } + #[test] + fn toml_hook_discovery_ignores_malformed_state_entries() { + let layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_path_buf("/tmp/config.toml").abs(), + }, + config_with_malformed_state_and_session_start_hook(), + ); + let mut warnings = Vec::new(); + + let (_, hooks) = super::load_toml_hooks_from_layer(&layer, &mut warnings) + .expect("valid hook events should still load"); + + assert_eq!(warnings, Vec::::new()); + assert_eq!( + hooks, + HookEventsToml { + session_start: vec![MatcherGroup { + matcher: None, + hooks: vec![HookHandlerConfig::Command { + command: "echo hello".to_string(), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + } + ); + } + + fn config_with_malformed_state_and_session_start_hook() -> TomlValue { + let mut config = TomlValue::Table(Default::default()); + let TomlValue::Table(config_entries) = &mut config else { + unreachable!("config root should be a table"); + }; + + let mut hooks = TomlValue::Table(Default::default()); + let TomlValue::Table(hook_entries) = &mut hooks else { + unreachable!("hooks should be a table"); + }; + + let mut state_entries = TomlValue::Table(Default::default()); + let TomlValue::Table(state_map) = &mut state_entries else { + unreachable!("state should be a table"); + }; + let mut hook_state = TomlValue::Table(Default::default()); + let TomlValue::Table(hook_state_entries) = &mut hook_state else { + unreachable!("hook state should be a table"); + }; + hook_state_entries.insert( + "enabled".to_string(), + TomlValue::String("not a bool".to_string()), + ); + state_map.insert("some_key".to_string(), hook_state); + hook_entries.insert("state".to_string(), state_entries); + + let mut handler = TomlValue::Table(Default::default()); + let TomlValue::Table(handler_entries) = &mut handler else { + unreachable!("handler should be a table"); + }; + handler_entries.insert("type".to_string(), TomlValue::String("command".to_string())); + handler_entries.insert( + "command".to_string(), + TomlValue::String("echo hello".to_string()), + ); + + let mut matcher_group = TomlValue::Table(Default::default()); + let TomlValue::Table(group_entries) = &mut matcher_group else { + unreachable!("matcher group should be a table"); + }; + group_entries.insert("hooks".to_string(), TomlValue::Array(vec![handler])); + hook_entries.insert( + "SessionStart".to_string(), + TomlValue::Array(vec![matcher_group]), + ); + + config_entries.insert("hooks".to_string(), hooks); + config + } + #[test] fn hook_source_for_config_layer_source_discards_source_details() { let config_file = test_path_buf("/tmp/.codex/config.toml").abs(); From bec8ad23eb44f2643840b54d76d0a6c5313536ee Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 21:06:19 -0700 Subject: [PATCH 52/64] Simplify hook configurability metadata --- .../codex_app_server_protocol.schemas.json | 6 ---- .../codex_app_server_protocol.v2.schemas.json | 6 ---- .../schema/json/v2/HooksListResponse.json | 6 ---- .../schema/typescript/v2/HookMetadata.ts | 2 +- .../app-server-protocol/src/protocol/v2.rs | 25 ---------------- codex-rs/app-server/README.md | 5 ++-- .../app-server/src/codex_message_processor.rs | 1 - .../app-server/tests/suite/v2/hooks_list.rs | 12 ++------ codex-rs/hooks/src/engine/discovery.rs | 23 ++------------- codex-rs/hooks/src/engine/mod.rs | 1 - codex-rs/hooks/src/engine/mod_tests.rs | 2 -- codex-rs/protocol/src/protocol.rs | 29 +++++++++++++++++++ 12 files changed, 37 insertions(+), 81 deletions(-) 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 70fedc9c3f7..e9fdd4a87dd 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 @@ -9583,12 +9583,6 @@ "null" ] }, - "configKeyPath": { - "type": [ - "string", - "null" - ] - }, "displayOrder": { "format": "int64", "type": "integer" 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 44d5ef217c3..de900763fe7 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 @@ -6213,12 +6213,6 @@ "null" ] }, - "configKeyPath": { - "type": [ - "string", - "null" - ] - }, "displayOrder": { "format": "int64", "type": "integer" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index ef9d27563ee..d18fbd6e07e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -47,12 +47,6 @@ "null" ] }, - "configKeyPath": { - "type": [ - "string", - "null" - ] - }, "displayOrder": { "format": "int64", "type": "integer" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index 2465f200de2..fd1ce7f25b0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -6,4 +6,4 @@ import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; -export type HookMetadata = { key: string, configKeyPath?: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, }; +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 88c3abda0c4..a60281885dd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4511,9 +4511,6 @@ pub struct HooksListEntry { #[ts(export_to = "v2/")] pub struct HookMetadata { pub key: String, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub config_key_path: Option, pub event_name: HookEventName, pub handler_type: HookHandlerType, pub matcher: Option, @@ -10860,26 +10857,4 @@ mod tests { "unexpected error: {err}" ); } - - #[test] - fn hook_metadata_omits_missing_config_key_path() { - let value = serde_json::to_value(HookMetadata { - key: "managed:/tmp/hooks.json:pre_tool_use:0:0".to_string(), - config_key_path: None, - event_name: HookEventName::PreToolUse, - handler_type: HookHandlerType::Command, - matcher: Some("Bash".to_string()), - command: Some("echo managed hook".to_string()), - timeout_sec: 5, - status_message: None, - source_path: test_absolute_path(), - source: HookSource::CloudRequirements, - plugin_id: None, - display_order: 0, - enabled: true, - }) - .expect("hook metadata should serialize"); - - assert_eq!(value.get("configKeyPath"), None); - } } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5f57f553238..0d7b5b097ff 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1451,7 +1451,7 @@ To enable or disable a skill by name: } ``` -Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Non-managed hooks include a `configKeyPath` that clients can pass to `config/batchWrite` when upserting user-level hook state. Managed hooks omit `configKeyPath`, and user config entries for those keys are ignored during loading. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional. +Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Hook state is stored under `hooks.state`; clients should treat hooks from managed sources as non-configurable, and user config entries for those keys are ignored during loading. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional. ```json { @@ -1471,7 +1471,6 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled "cwd": "/Users/me/project", "hooks": [{ "key": "/Users/me/.codex/config.toml:pre_tool_use:0:0", - "configKeyPath": "hooks.state", "eventName": "pre_tool_use", "handlerType": "command", "matcher": "Bash", @@ -1491,7 +1490,7 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Disabled } ``` -To disable a non-managed hook, upsert a state entry at the returned `configKeyPath` with `config/batchWrite`: +To disable a non-managed hook, upsert a state entry at `hooks.state` with `config/batchWrite`: ```json { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6ec96f728ee..b5a2d38e923 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -8670,7 +8670,6 @@ fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { .iter() .map(|hook| HookMetadata { key: hook.key.clone(), - config_key_path: hook.config_key_path.clone(), event_name: hook.event_name.into(), handler_type: hook.handler_type.into(), matcher: hook.matcher.clone(), diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index efc8f699cd4..3610a6f169e 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -92,7 +92,6 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { cwd: cwd.path().to_path_buf(), hooks: vec![HookMetadata { key: format!("{}:pre_tool_use:0:0", config_path.as_path().display()), - config_key_path: Some("hooks.state".to_string()), event_name: HookEventName::PreToolUse, handler_type: HookHandlerType::Command, matcher: Some("Bash".to_string()), @@ -162,7 +161,6 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { cwd: cwd.path().to_path_buf(), hooks: vec![HookMetadata { key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(), - config_key_path: Some("hooks.state".to_string()), event_name: HookEventName::PreToolUse, handler_type: HookHandlerType::Command, matcher: Some("Bash".to_string()), @@ -240,10 +238,7 @@ async fn config_batch_write_toggles_user_hook() -> Result<()> { let write_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { edits: vec![ConfigEdit { - key_path: hook - .config_key_path - .clone() - .expect("non-managed hook should be configurable"), + key_path: "hooks.state".to_string(), value: serde_json::json!({ hook.key.clone(): { "enabled": false @@ -281,10 +276,7 @@ async fn config_batch_write_toggles_user_hook() -> Result<()> { let write_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { edits: vec![ConfigEdit { - key_path: hook - .config_key_path - .clone() - .expect("non-managed hook should be configurable"), + key_path: "hooks.state".to_string(), value: serde_json::json!({ hook.key.clone(): { "enabled": true diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 82d036ca737..6c4bec5ab01 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -36,7 +36,6 @@ pub(crate) struct DiscoveryResult { struct HookHandlerSource<'a> { path: &'a AbsolutePathBuf, key_source: String, - is_managed: bool, source: HookSource, disabled_hook_keys: &'a HashSet, env: HashMap, @@ -69,7 +68,6 @@ pub(crate) fn discover_handlers( /*include_disabled*/ false, ) { let hook_source = hook_source_for_config_layer_source(&layer.name); - let is_managed = hook_source_is_managed(hook_source); let json_hooks = load_hooks_json(layer.config_folder().as_deref(), &mut warnings); let toml_hooks = load_toml_hooks_from_layer(layer, &mut warnings); @@ -94,7 +92,6 @@ pub(crate) fn discover_handlers( HookHandlerSource { path: &source_path, key_source: source_path.display().to_string(), - is_managed, source: hook_source, disabled_hook_keys: &disabled_hook_keys, env: HashMap::new(), @@ -146,7 +143,6 @@ fn append_managed_requirement_handlers( HookHandlerSource { path: &source_path, key_source: source_path.display().to_string(), - is_managed: true, source: hook_source_for_requirement_source(managed_hooks.source.as_ref()), disabled_hook_keys, env: HashMap::new(), @@ -192,7 +188,6 @@ fn append_plugin_hook_sources( HookHandlerSource { path: &source_path, key_source: format!("{plugin_id}:{source_relative_path}"), - is_managed: false, source: HookSource::Plugin, disabled_hook_keys, env, @@ -415,11 +410,10 @@ fn append_matcher_groups( group_index, handler_index ); - let enabled = source.is_managed || !source.disabled_hook_keys.contains(&key); - let config_key_path = (!source.is_managed).then(|| "hooks.state".to_string()); + let enabled = + source.source.is_managed() || !source.disabled_hook_keys.contains(&key); hook_entries.push(HookListEntry { key, - config_key_path, event_name, handler_type: HookHandlerType::Command, matcher: matcher.map(ToOwned::to_owned), @@ -435,7 +429,7 @@ fn append_matcher_groups( if enabled { handlers.push(ConfiguredHandler { event_name, - is_managed: source.is_managed, + is_managed: source.source.is_managed(), matcher: matcher.map(ToOwned::to_owned), command, timeout_sec, @@ -486,16 +480,6 @@ fn hook_source_for_config_layer_source(source: &ConfigLayerSource) -> HookSource } } -fn hook_source_is_managed(source: HookSource) -> bool { - matches!( - source, - HookSource::System - | HookSource::Mdm - | HookSource::LegacyManagedConfigFile - | HookSource::LegacyManagedConfigMdm - ) -} - fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> HookSource { match source { Some(RequirementSource::MdmManagedPreferences { .. }) => HookSource::Mdm, @@ -544,7 +528,6 @@ mod tests { super::HookHandlerSource { path, key_source: path.display().to_string(), - is_managed: false, source: hook_source(), disabled_hook_keys, env: std::collections::HashMap::new(), diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 3a77f123003..8b802dd726a 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -72,7 +72,6 @@ impl ConfiguredHandler { #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookListEntry { pub key: String, - pub config_key_path: Option, pub event_name: HookEventName, pub handler_type: HookHandlerType, pub matcher: Option, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index d03250e1e62..a60c4afb2c5 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -262,7 +262,6 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); assert_eq!(discovered.hook_entries.len(), 2); assert_eq!(discovered.hook_entries[0].key, managed_disabled_key); - assert_eq!(discovered.hook_entries[0].config_key_path, None); assert_eq!(discovered.hook_entries[0].enabled, true); assert_eq!(discovered.hook_entries[1].key, user_disabled_key); assert_eq!(discovered.hook_entries[1].enabled, false); @@ -314,7 +313,6 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); assert_eq!(discovered.hook_entries.len(), 1); assert_eq!(discovered.hook_entries[0].key, managed_key); - assert_eq!(discovered.hook_entries[0].config_key_path, None); assert_eq!(discovered.hook_entries[0].enabled, true); } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index db7a938b7d1..df498d5a151 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1571,6 +1571,21 @@ pub enum HookSource { Unknown, } +impl HookSource { + /// Returns whether hooks from this source are managed and therefore not + /// user-configurable. + pub fn is_managed(self) -> bool { + matches!( + self, + Self::System + | Self::Mdm + | Self::CloudRequirements + | Self::LegacyManagedConfigFile + | Self::LegacyManagedConfigMdm + ) + } +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookRunStatus { @@ -3976,6 +3991,20 @@ mod tests { use tempfile::NamedTempFile; use tempfile::TempDir; + #[test] + fn hook_source_managedness_is_source_derived() { + assert_eq!(HookSource::System.is_managed(), true); + assert_eq!(HookSource::Mdm.is_managed(), true); + assert_eq!(HookSource::CloudRequirements.is_managed(), true); + assert_eq!(HookSource::LegacyManagedConfigFile.is_managed(), true); + assert_eq!(HookSource::LegacyManagedConfigMdm.is_managed(), true); + assert_eq!(HookSource::User.is_managed(), false); + assert_eq!(HookSource::Project.is_managed(), false); + assert_eq!(HookSource::SessionFlags.is_managed(), false); + assert_eq!(HookSource::Plugin.is_managed(), false); + assert_eq!(HookSource::Unknown.is_managed(), false); + } + fn sorted_writable_roots(roots: Vec) -> Vec<(PathBuf, Vec)> { let mut sorted_roots: Vec<(PathBuf, Vec)> = roots .into_iter() From afbfff2e4003b569323bbed10912c85a476b91e5 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 21:37:19 -0700 Subject: [PATCH 53/64] Parse hook state entries independently --- codex-rs/hooks/src/config_rules.rs | 57 ++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index a49e718f95c..257bf96562f 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -1,10 +1,10 @@ -use std::collections::BTreeMap; use std::collections::HashSet; use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::HookStateToml; +use codex_config::TomlValue; /// Build hook enablement rules from config layers that are allowed to override /// user preferences. @@ -39,14 +39,17 @@ pub(crate) fn disabled_hook_keys_from_stack( else { continue; }; - let state_by_key: BTreeMap = match state_value.clone().try_into() { - Ok(state_by_key) => state_by_key, - Err(_) => { - continue; - } + let TomlValue::Table(state_by_key) = state_value else { + continue; }; - for (key, state) in state_by_key { + for (key, state_value) in state_by_key { + let state: HookStateToml = match state_value.clone().try_into() { + Ok(state) => state, + Err(_) => { + continue; + } + }; let key = key.trim(); if key.is_empty() { continue; @@ -134,6 +137,46 @@ mod tests { ); } + #[test] + fn disabled_hook_keys_from_stack_ignores_malformed_state_entries() { + let key = "file:/tmp/hooks.json:pre_tool_use:0:0"; + let mut config = config_with_hook_override(key, Some(/*enabled*/ false)); + let TomlValue::Table(config_entries) = &mut config else { + unreachable!("config root should be a table"); + }; + let Some(TomlValue::Table(hook_entries)) = config_entries.get_mut("hooks") else { + unreachable!("hooks should be a table"); + }; + let Some(TomlValue::Table(state_entries)) = hook_entries.get_mut("state") else { + unreachable!("state should be a table"); + }; + let mut malformed_state = TomlValue::Table(Default::default()); + let TomlValue::Table(malformed_state_entries) = &mut malformed_state else { + unreachable!("malformed state should be a table"); + }; + malformed_state_entries.insert( + "enabled".to_string(), + TomlValue::String("not a bool".to_string()), + ); + state_entries.insert("malformed".to_string(), malformed_state); + let stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_path_buf("/tmp/config.toml").abs(), + }, + config, + )], + Default::default(), + Default::default(), + ) + .expect("config layer stack"); + + assert_eq!( + disabled_hook_keys_from_stack(Some(&stack)), + HashSet::from([key.to_string()]) + ); + } + fn config_with_hook_override(key: &str, enabled: Option) -> TomlValue { let mut config = TomlValue::Table(Default::default()); let TomlValue::Table(config_entries) = &mut config else { From 19d0a9369f7cd70cf8f221f12e95ad0de94eb891 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 21:41:20 -0700 Subject: [PATCH 54/64] Document hook builder helper --- codex-rs/core/src/session/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9ba07a60c53..e5bf0ba97d7 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3345,8 +3345,7 @@ fn errors_to_info(errors: &[SkillError]) -> Vec { use codex_memories_read::build_memory_tool_developer_instructions; -#[cfg(test)] -pub(crate) mod tests; +/// Builds the hook engine for one config snapshot, including any enabled plugin hooks. async fn build_hooks_for_config( config: &Config, plugins_manager: &PluginsManager, @@ -3375,3 +3374,6 @@ async fn build_hooks_for_config( shell_args: hook_shell_argv, }) } + +#[cfg(test)] +pub(crate) mod tests; From 9dd8214328aa4054279fe3d9b697bc01f807b6d7 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 22:09:53 -0700 Subject: [PATCH 55/64] Simplify hook discovery state --- codex-rs/config/src/types.rs | 1 - codex-rs/hooks/src/engine/discovery.rs | 16 ++-- codex-rs/hooks/src/engine/dispatcher.rs | 1 - codex-rs/hooks/src/engine/mod.rs | 1 - codex-rs/hooks/src/engine/mod_tests.rs | 82 ++++++++----------- 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 - 10 files changed, 40 insertions(+), 66 deletions(-) diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index c2dbeb76e7b..5f2877de26b 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -674,7 +674,6 @@ pub struct Notice { pub external_config_migration_prompts: ExternalConfigMigrationPrompts, } -pub use crate::hook_config::HookStateToml; pub use crate::skills_config::BundledSkillsConfig; pub use crate::skills_config::SkillConfig; pub use crate::skills_config::SkillsConfig; diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 6c4bec5ab01..b51f07c8bde 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -32,7 +32,6 @@ pub(crate) struct DiscoveryResult { pub warnings: Vec, } -#[derive(Clone)] struct HookHandlerSource<'a> { path: &'a AbsolutePathBuf, key_source: String, @@ -348,7 +347,7 @@ fn append_hook_events( hook_entries, warnings, display_order, - source.clone(), + &source, event_name, groups, ); @@ -360,7 +359,7 @@ fn append_matcher_groups( hook_entries: &mut Vec, warnings: &mut Vec, display_order: &mut i64, - source: HookHandlerSource<'_>, + source: &HookHandlerSource<'_>, event_name: codex_protocol::protocol::HookEventName, groups: Vec, ) { @@ -429,7 +428,6 @@ fn append_matcher_groups( if enabled { handlers.push(ConfiguredHandler { event_name, - is_managed: source.source.is_managed(), matcher: matcher.map(ToOwned::to_owned), command, timeout_sec, @@ -560,7 +558,7 @@ mod tests { &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path, &disabled_hook_keys), + &hook_handler_source(&source_path, &disabled_hook_keys), HookEventName::UserPromptSubmit, vec![command_group(Some("["))], ); @@ -570,7 +568,6 @@ mod tests { handlers, vec![ConfiguredHandler { event_name: HookEventName::UserPromptSubmit, - is_managed: false, matcher: None, command: "echo hello".to_string(), timeout_sec: 600, @@ -596,7 +593,7 @@ mod tests { &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path, &disabled_hook_keys), + &hook_handler_source(&source_path, &disabled_hook_keys), HookEventName::PreToolUse, vec![command_group(Some("^Bash$"))], ); @@ -606,7 +603,6 @@ mod tests { handlers, vec![ConfiguredHandler { event_name: HookEventName::PreToolUse, - is_managed: false, matcher: Some("^Bash$".to_string()), command: "echo hello".to_string(), timeout_sec: 600, @@ -632,7 +628,7 @@ mod tests { &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path, &disabled_hook_keys), + &hook_handler_source(&source_path, &disabled_hook_keys), HookEventName::PreToolUse, vec![command_group(Some("*"))], ); @@ -655,7 +651,7 @@ mod tests { &mut Vec::new(), &mut warnings, &mut display_order, - hook_handler_source(&source_path, &disabled_hook_keys), + &hook_handler_source(&source_path, &disabled_hook_keys), HookEventName::PostToolUse, vec![command_group(Some("Edit|Write"))], ); diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index c19b311843b..c44b1fe69d7 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -156,7 +156,6 @@ mod tests { ) -> ConfiguredHandler { ConfiguredHandler { event_name, - is_managed: false, matcher: matcher.map(str::to_owned), command: command.to_string(), timeout_sec: 5, diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 8b802dd726a..a52b5856772 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -36,7 +36,6 @@ pub(crate) struct CommandShell { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ConfiguredHandler { pub event_name: codex_protocol::protocol::HookEventName, - pub is_managed: bool, pub matcher: Option, pub command: String, pub timeout_sec: u64, diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index a60c4afb2c5..d18a5e42cc2 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -119,7 +119,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(engine.warnings().is_empty()); assert_eq!(engine.handlers.len(), 1); - assert!(engine.handlers[0].is_managed); + assert!(engine.handlers[0].source.is_managed()); let cwd = cwd(); let preview = engine.preview_pre_tool_use(&PreToolUseRequest { session_id: ThreadId::new(), @@ -181,51 +181,10 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute path"); let managed_disabled_key = format!("{}:pre_tool_use:0:0", managed_dir.display()); let user_disabled_key = format!("{}:pre_tool_use:0:0", config_path.display()); - let mut user_config = TomlValue::Table(Default::default()); - let TomlValue::Table(user_config_entries) = &mut user_config else { - unreachable!("config TOML root should be a table"); - }; - let mut hooks = TomlValue::Table(Default::default()); - let TomlValue::Table(hooks_entries) = &mut hooks else { - unreachable!("hooks should be a table"); - }; - let mut state = TomlValue::Table(Default::default()); - let TomlValue::Table(state_entries) = &mut state else { - unreachable!("state should be a table"); - }; - for key in [managed_disabled_key.clone(), user_disabled_key.clone()] { - let mut hook_state = TomlValue::Table(Default::default()); - let TomlValue::Table(hook_state_entries) = &mut hook_state else { - unreachable!("hook state should be a table"); - }; - hook_state_entries.insert("enabled".to_string(), TomlValue::Boolean(false)); - state_entries.insert(key, hook_state); - } - hooks_entries.insert("state".to_string(), state); - let mut user_hook_group = TomlValue::Table(Default::default()); - let TomlValue::Table(user_hook_group_entries) = &mut user_hook_group else { - unreachable!("user hook group should be a table"); - }; - user_hook_group_entries.insert( - "hooks".to_string(), - TomlValue::Array(vec![TomlValue::Table(Default::default())]), + let user_config = config_with_pre_tool_use_hook_and_states( + "python3 /tmp/user.py", + [&managed_disabled_key, &user_disabled_key], ); - let Some(TomlValue::Array(user_hooks)) = user_hook_group_entries.get_mut("hooks") else { - unreachable!("user hooks should be an array"); - }; - let Some(TomlValue::Table(user_handler_entries)) = user_hooks.first_mut() else { - unreachable!("user hook handler should be a table"); - }; - user_handler_entries.insert("type".to_string(), TomlValue::String("command".to_string())); - user_handler_entries.insert( - "command".to_string(), - TomlValue::String("python3 /tmp/user.py".to_string()), - ); - hooks_entries.insert( - "PreToolUse".to_string(), - TomlValue::Array(vec![user_hook_group]), - ); - user_config_entries.insert("hooks".to_string(), hooks); let config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::User { file: config_path }, @@ -257,7 +216,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { ); assert_eq!(engine.handlers.len(), 1); - assert!(engine.handlers[0].is_managed); + assert!(engine.handlers[0].source.is_managed()); let discovered = super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); assert_eq!(discovered.hook_entries.len(), 2); @@ -308,7 +267,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { ); assert_eq!(engine.handlers.len(), 1); - assert!(engine.handlers[0].is_managed); + assert!(engine.handlers[0].source.is_managed()); let discovered = super::discovery::discover_handlers(Some(&config_layer_stack), Vec::new(), Vec::new()); assert_eq!(discovered.hook_entries.len(), 1); @@ -329,6 +288,28 @@ fn config_with_hook_state(key: &str, enabled: bool) -> TomlValue { .expect("config TOML should deserialize") } +fn config_with_pre_tool_use_hook_and_states( + command: &str, + disabled_keys: [&str; N], +) -> TomlValue { + let state = disabled_keys + .into_iter() + .map(|key| (key.to_string(), serde_json::json!({ "enabled": false }))) + .collect::>(); + serde_json::from_value(serde_json::json!({ + "hooks": { + "state": state, + "PreToolUse": [{ + "hooks": [{ + "type": "command", + "command": command, + }], + }], + }, + })) + .expect("config TOML should deserialize") +} + fn config_with_pre_tool_use_hook(command: &str) -> TomlValue { serde_json::from_value(serde_json::json!({ "hooks": { @@ -518,7 +499,12 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { tool_input: serde_json::json!({ "command": "echo hello" }), }); assert_eq!(preview.len(), 2); - assert!(engine.handlers.iter().all(|handler| !handler.is_managed)); + assert!( + engine + .handlers + .iter() + .all(|handler| !handler.source.is_managed()) + ); assert_eq!(preview[0].source_path, hooks_json_path); assert_eq!(preview[1].source_path, config_path); } diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index c01cebf78a2..63045ef4258 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -543,7 +543,6 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { event_name: HookEventName::PostToolUse, - is_managed: false, matcher: Some("^Bash$".to_string()), command: "python3 post_tool_use_hook.py".to_string(), timeout_sec: 5, diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 3b20c2c2c02..6fe1555229c 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -534,7 +534,6 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { event_name: HookEventName::PreToolUse, - is_managed: false, matcher: Some("^Bash$".to_string()), command: "echo hook".to_string(), timeout_sec: 5, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 54c7f51732b..f064f67b202 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -356,7 +356,6 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { event_name: HookEventName::SessionStart, - is_managed: false, matcher: None, command: "echo hook".to_string(), timeout_sec: 600, diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 392f15eee24..8fc176a4743 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -523,7 +523,6 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { event_name: HookEventName::Stop, - is_managed: false, matcher: None, command: "echo hook".to_string(), timeout_sec: 600, diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index 8aaf3ad608e..a04711eb409 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -414,7 +414,6 @@ mod tests { fn handler() -> ConfiguredHandler { ConfiguredHandler { event_name: HookEventName::UserPromptSubmit, - is_managed: false, matcher: None, command: "echo hook".to_string(), timeout_sec: 5, From e94e6a13d96ccd1551989fa918a656983180ec6b Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 28 Apr 2026 22:16:23 -0700 Subject: [PATCH 56/64] Simplify hook test fixtures --- codex-rs/hooks/src/config_rules.rs | 87 +++++++++++--------------- codex-rs/hooks/src/engine/discovery.rs | 63 +++++-------------- 2 files changed, 51 insertions(+), 99 deletions(-) diff --git a/codex-rs/hooks/src/config_rules.rs b/codex-rs/hooks/src/config_rules.rs index 257bf96562f..b9fa8715041 100644 --- a/codex-rs/hooks/src/config_rules.rs +++ b/codex-rs/hooks/src/config_rules.rs @@ -108,17 +108,17 @@ mod tests { #[test] fn disabled_hook_keys_from_stack_ignores_malformed_hook_events() { let key = "file:/tmp/hooks.json:pre_tool_use:0:0"; - let mut config = config_with_hook_override(key, Some(/*enabled*/ false)); - let TomlValue::Table(config_entries) = &mut config else { - unreachable!("config root should be a table"); - }; - let Some(TomlValue::Table(hook_entries)) = config_entries.get_mut("hooks") else { - unreachable!("hooks should be a table"); - }; - hook_entries.insert( - "SessionStart".to_string(), - TomlValue::String("not a matcher list".to_string()), - ); + let config: TomlValue = serde_json::from_value(serde_json::json!({ + "hooks": { + "state": { + (key): { + "enabled": false, + }, + }, + "SessionStart": "not a matcher list", + }, + })) + .expect("config TOML should deserialize"); let stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::User { @@ -140,25 +140,19 @@ mod tests { #[test] fn disabled_hook_keys_from_stack_ignores_malformed_state_entries() { let key = "file:/tmp/hooks.json:pre_tool_use:0:0"; - let mut config = config_with_hook_override(key, Some(/*enabled*/ false)); - let TomlValue::Table(config_entries) = &mut config else { - unreachable!("config root should be a table"); - }; - let Some(TomlValue::Table(hook_entries)) = config_entries.get_mut("hooks") else { - unreachable!("hooks should be a table"); - }; - let Some(TomlValue::Table(state_entries)) = hook_entries.get_mut("state") else { - unreachable!("state should be a table"); - }; - let mut malformed_state = TomlValue::Table(Default::default()); - let TomlValue::Table(malformed_state_entries) = &mut malformed_state else { - unreachable!("malformed state should be a table"); - }; - malformed_state_entries.insert( - "enabled".to_string(), - TomlValue::String("not a bool".to_string()), - ); - state_entries.insert("malformed".to_string(), malformed_state); + let config: TomlValue = serde_json::from_value(serde_json::json!({ + "hooks": { + "state": { + (key): { + "enabled": false, + }, + "malformed": { + "enabled": "not a bool", + }, + }, + }, + })) + .expect("config TOML should deserialize"); let stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::User { @@ -178,28 +172,17 @@ mod tests { } fn config_with_hook_override(key: &str, enabled: Option) -> TomlValue { - let mut config = TomlValue::Table(Default::default()); - let TomlValue::Table(config_entries) = &mut config else { - unreachable!("config root should be a table"); - }; - let mut hooks = TomlValue::Table(Default::default()); - let TomlValue::Table(hook_entries) = &mut hooks else { - unreachable!("hooks should be a table"); + let hook_state = match enabled { + Some(enabled) => serde_json::json!({ "enabled": enabled }), + None => serde_json::json!({}), }; - let mut state_entries = TomlValue::Table(Default::default()); - let TomlValue::Table(state_map) = &mut state_entries else { - unreachable!("state should be a table"); - }; - let mut hook_state = TomlValue::Table(Default::default()); - let TomlValue::Table(hook_state_entries) = &mut hook_state else { - unreachable!("hook state should be a table"); - }; - if let Some(enabled) = enabled { - hook_state_entries.insert("enabled".to_string(), TomlValue::Boolean(enabled)); - } - state_map.insert(key.to_string(), hook_state); - hook_entries.insert("state".to_string(), state_entries); - config_entries.insert("hooks".to_string(), hooks); - config + serde_json::from_value(serde_json::json!({ + "hooks": { + "state": { + (key): hook_state, + }, + }, + })) + .expect("config TOML should deserialize") } } diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index b51f07c8bde..7b848b3a246 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -694,53 +694,22 @@ mod tests { } fn config_with_malformed_state_and_session_start_hook() -> TomlValue { - let mut config = TomlValue::Table(Default::default()); - let TomlValue::Table(config_entries) = &mut config else { - unreachable!("config root should be a table"); - }; - - let mut hooks = TomlValue::Table(Default::default()); - let TomlValue::Table(hook_entries) = &mut hooks else { - unreachable!("hooks should be a table"); - }; - - let mut state_entries = TomlValue::Table(Default::default()); - let TomlValue::Table(state_map) = &mut state_entries else { - unreachable!("state should be a table"); - }; - let mut hook_state = TomlValue::Table(Default::default()); - let TomlValue::Table(hook_state_entries) = &mut hook_state else { - unreachable!("hook state should be a table"); - }; - hook_state_entries.insert( - "enabled".to_string(), - TomlValue::String("not a bool".to_string()), - ); - state_map.insert("some_key".to_string(), hook_state); - hook_entries.insert("state".to_string(), state_entries); - - let mut handler = TomlValue::Table(Default::default()); - let TomlValue::Table(handler_entries) = &mut handler else { - unreachable!("handler should be a table"); - }; - handler_entries.insert("type".to_string(), TomlValue::String("command".to_string())); - handler_entries.insert( - "command".to_string(), - TomlValue::String("echo hello".to_string()), - ); - - let mut matcher_group = TomlValue::Table(Default::default()); - let TomlValue::Table(group_entries) = &mut matcher_group else { - unreachable!("matcher group should be a table"); - }; - group_entries.insert("hooks".to_string(), TomlValue::Array(vec![handler])); - hook_entries.insert( - "SessionStart".to_string(), - TomlValue::Array(vec![matcher_group]), - ); - - config_entries.insert("hooks".to_string(), hooks); - config + serde_json::from_value(serde_json::json!({ + "hooks": { + "state": { + "some_key": { + "enabled": "not a bool", + }, + }, + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": "echo hello", + }], + }], + }, + })) + .expect("config TOML should deserialize") } #[test] From 319f1375d1ac100d6d1ca90c328dc1263bbbe4bf Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 29 Apr 2026 10:15:42 -0700 Subject: [PATCH 57/64] Tweak hooks browser plugin labels --- .../tui/src/bottom_pane/hooks_browser_view.rs | 40 +++++++++++++++---- ...r_view__tests__hooks_browser_handlers.snap | 4 +- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 86db0914ca1..6b987fd1f26 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -2,6 +2,7 @@ use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookMetadata; use codex_app_server_protocol::HookSource; +use codex_plugin::PluginId; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -320,9 +321,24 @@ impl HooksBrowserView { "Matcher", matcher, width, /*max_lines*/ None, )); } + // Plugin hooks show the marketplace here; other hooks show the config path. + let source_value = match hook.source { + HookSource::Plugin => hook + .plugin_id + .as_deref() + .and_then(|plugin_id| { + PluginId::parse(plugin_id) + .ok() + .map(|plugin_id| plugin_id.marketplace_name) + }) + .unwrap_or_else(|| { + format_directory_display(&hook.source_path, /*max_width*/ None) + }), + _ => format_directory_display(&hook.source_path, /*max_width*/ None), + }; lines.extend(detail_wrapped_lines( "Source", - &format_directory_display(&hook.source_path, /*max_width*/ None), + &source_value, width, /*max_lines*/ None, )); @@ -558,13 +574,21 @@ fn summary_source(hook: &HookMetadata, idx: usize) -> String { .filter(|message| !message.trim().is_empty()) .map(str::to_string) .unwrap_or_else(|| format!("Hook {}", idx + 1)); - match hook.source { - HookSource::Plugin => format!( - "{hook_label} - {}", - hook.plugin_id.as_deref().unwrap_or("Plugin") - ), - _ => format!("{hook_label} - {}", config_source_label(hook.source)), - } + let source_label = match hook.source { + // Parse the plugin name from the `@` id for display. + HookSource::Plugin => hook + .plugin_id + .as_deref() + .and_then(|plugin_id| { + PluginId::parse(plugin_id) + .ok() + .map(|plugin_id| plugin_id.plugin_name) + }) + .or_else(|| hook.plugin_id.clone()) + .unwrap_or_else(|| "Plugin".to_string()), + _ => config_source_label(hook.source).to_string(), + }; + format!("{hook_label} - {source_label}") } fn config_source_label(source: HookSource) -> &'static str { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap index 1ccb4814340..3826e24f12b 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap @@ -6,12 +6,12 @@ expression: "render_lines(&view, 112)" PreToolUse hooks Turn hooks on or off. Your changes are saved automatically. - [x] Hook 1 - superpowers@openai-curated + [x] Hook 1 - superpowers [ ] Hook 2 - User Config Event PreToolUse Matcher Bash - Source /tmp/hooks.json + Source openai-curated Command ${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh Timeout 30s From 8edff9fec9ee3c88b456faeade513fd1b3e42062 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 29 Apr 2026 10:25:50 -0700 Subject: [PATCH 58/64] Tweak stop hook description --- codex-rs/tui/src/bottom_pane/hooks_browser_view.rs | 2 +- ...m_pane__hooks_browser_view__tests__hooks_browser_events.snap | 2 +- ...s_browser_view__tests__hooks_browser_events_with_issues.snap | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 6b987fd1f26..6ac160aa064 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -563,7 +563,7 @@ fn event_description(event_name: HookEventName) -> &'static str { HookEventName::PostToolUse => "After a tool executes", HookEventName::SessionStart => "When a new session starts", HookEventName::UserPromptSubmit => "When the user submits a prompt", - HookEventName::Stop => "Before Codex concludes a response", + HookEventName::Stop => "Right before Codex ends its turn", } } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap index 74b0b0e9880..522105c30d9 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events.snap @@ -12,6 +12,6 @@ expression: "render_lines(&view, 112)" PostToolUse 0 0 After a tool executes SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt - Stop 0 0 Before Codex concludes a response + Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap index 22b1dfcec53..18e3b9f849a 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_events_with_issues.snap @@ -16,6 +16,6 @@ expression: "render_lines(&view, 112)" PostToolUse 0 0 After a tool executes SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt - Stop 0 0 Before Codex concludes a response + Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close From d9eec6c76c7e6d51e5d09334a44cda7d26dfdd86 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 29 Apr 2026 10:37:50 -0700 Subject: [PATCH 59/64] update snapshot --- ...__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap index 6562b4b56e6..865d19031fb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__hooks_popup_shows_list_diagnostics.snap @@ -15,6 +15,6 @@ expression: popup PostToolUse 0 0 After a tool executes SessionStart 0 0 When a new session starts UserPromptSubmit 0 0 When the user submits a prompt - Stop 0 0 Before Codex concludes a response + Stop 0 0 Right before Codex ends its turn Press enter to view hooks; esc to close From 2c0a84afca8fcd2ee1e0d3c7b88006090876dbf6 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 29 Apr 2026 10:44:24 -0700 Subject: [PATCH 60/64] Serialize hook toggle writes --- codex-rs/tui/src/app.rs | 4 ++++ codex-rs/tui/src/app/background_requests.rs | 22 +++++++++++++++++- codex-rs/tui/src/app/event_dispatch.rs | 25 ++++++++++++++++++--- codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 2 ++ codex-rs/tui/src/app_event.rs | 2 ++ 6 files changed, 52 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 68a32956987..64274ee6a58 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -587,6 +587,9 @@ pub(crate) struct App { // overwrite a newer toggle, even if the plugin is toggled from different // cwd contexts. pending_plugin_enabled_writes: HashMap>, + // Serialize hook enablement writes per hook so stale completions cannot + // persist an older toggle after a newer one. + pending_hook_enabled_writes: HashMap>, } fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option { @@ -955,6 +958,7 @@ See the Codex keymap documentation for supported actions and examples." pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_plugin_enabled_writes: HashMap::new(), + pending_hook_enabled_writes: HashMap::new(), }; if let Some(started) = initial_started_thread { app.enqueue_primary_thread_session(started.session, started.turns) diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index aa319a717fe..e3a2156aad4 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -239,15 +239,35 @@ impl App { app_server: &AppServerSession, key: String, enabled: bool, + ) { + if let Some(queued_enabled) = self.pending_hook_enabled_writes.get_mut(&key) { + *queued_enabled = Some(enabled); + return; + } + + self.pending_hook_enabled_writes.insert(key.clone(), None); + self.spawn_hook_enabled_write(app_server, key, enabled); + } + + pub(super) fn spawn_hook_enabled_write( + &mut self, + app_server: &AppServerSession, + key: String, + enabled: bool, ) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { + let key_for_event = key.clone(); let result = write_hook_enabled(request_handle, key, enabled) .await .map(|_| ()) .map_err(|err| format!("Failed to update hook config: {err}")); - app_event_tx.send(AppEvent::HookEnabledSet { result }); + app_event_tx.send(AppEvent::HookEnabledSet { + key: key_for_event, + enabled, + result, + }); }); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 7e55786de79..3b78a27d3a9 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1612,9 +1612,28 @@ impl App { AppEvent::SetHookEnabled { key, enabled } => { self.set_hook_enabled(app_server, key, enabled); } - AppEvent::HookEnabledSet { result } => { - if let Err(err) = result { - self.chat_widget.add_error_message(err); + AppEvent::HookEnabledSet { + key, + enabled, + result, + } => { + let queued_enabled = self + .pending_hook_enabled_writes + .get_mut(&key) + .and_then(Option::take); + let should_apply_result = if let Some(queued_enabled) = queued_enabled + && (result.is_err() || queued_enabled != enabled) + { + self.spawn_hook_enabled_write(app_server, key.clone(), queued_enabled); + false + } else { + true + }; + if should_apply_result { + self.pending_hook_enabled_writes.remove(&key); + if let Err(err) = result { + self.chat_widget.add_error_message(err); + } } } AppEvent::OpenPermissionsPopup => { diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index fd88161cade..23f48a4b877 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -59,6 +59,7 @@ pub(super) async fn make_test_app() -> App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_plugin_enabled_writes: HashMap::new(), + pending_hook_enabled_writes: HashMap::new(), } } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 29f82ab85d4..38c27fbc428 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -3731,6 +3731,7 @@ async fn make_test_app() -> App { pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_plugin_enabled_writes: HashMap::new(), + pending_hook_enabled_writes: HashMap::new(), } } @@ -3791,6 +3792,7 @@ async fn make_test_app_with_channels() -> ( pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), pending_plugin_enabled_writes: HashMap::new(), + pending_hook_enabled_writes: HashMap::new(), }, rx, op_rx, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0c38f3d78d9..7ac3d605e72 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -699,6 +699,8 @@ pub(crate) enum AppEvent { /// Result of persisting hook enabled state. HookEnabledSet { + key: String, + enabled: bool, result: Result<(), String>, }, From 0981af75837259ebdc7f9a1bbb5683d3f46ee1ea Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 29 Apr 2026 11:18:54 -0700 Subject: [PATCH 61/64] Normalize hook snapshot paths in tests --- codex-rs/tui/src/bottom_pane/hooks_browser_view.rs | 12 +++++++----- .../tui/src/chatwidget/tests/popups_and_settings.rs | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 6ac160aa064..c7f78713125 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -681,9 +681,9 @@ mod tests { let mut buf = Buffer::empty(area); view.render(area, &mut buf); - let rendered = (0..area.height) + (0..area.height) .map(|row| { - (0..area.width) + let rendered = (0..area.width) .map(|col| { let symbol = buf[(area.x + col, area.y + row)].symbol(); if symbol.is_empty() { @@ -692,11 +692,13 @@ mod tests { symbol.to_string() } }) - .collect::() + .collect::(); + let normalized = + rendered.replace(&test_path_display("/tmp/hooks.json"), "/tmp/hooks.json"); + format!("{normalized:width$}", width = area.width as usize) }) .collect::>() - .join("\n"); - rendered.replace(&test_path_display("/tmp/hooks.json"), "/tmp/hooks.json") + .join("\n") } #[allow(clippy::too_many_arguments)] diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index c9c155d306a..f3ba9629f2b 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -124,7 +124,7 @@ async fn hooks_popup_shows_list_diagnostics() { }), ); - let popup = render_bottom_popup(&chat, /*width*/ 112); + let popup = normalize_snapshot_paths(render_bottom_popup(&chat, /*width*/ 112)); assert_chatwidget_snapshot!("hooks_popup_shows_list_diagnostics", popup); } From 1f4dab37e2aa93bf5131001d0be591a2e743eb7d Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 29 Apr 2026 11:55:49 -0700 Subject: [PATCH 62/64] Normalize hooks popup snapshot path --- codex-rs/tui/src/chatwidget/tests/helpers.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d26a503a89c..760df2a1bdf 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -35,12 +35,18 @@ pub(super) fn truncated_path_variants(path: &str) -> Vec { pub(super) fn normalize_snapshot_paths(text: impl Into) -> String { let mut text = text.into(); + + for unix_path in ["/tmp/project", "/tmp/hooks.json"] { + let platform_path = test_path_display(unix_path); + if platform_path != unix_path { + text = text.replace(&platform_path, unix_path); + } + } + let platform_test_cwd = test_path_display("/tmp/project"); if platform_test_cwd == "/tmp/project" { text } else { - text = text.replace(&platform_test_cwd, "/tmp/project"); - for platform_prefix in truncated_path_variants(&platform_test_cwd) .into_iter() .rev() From 8052447eab50b7f8bc392c070a3dfb198adf2570 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Thu, 30 Apr 2026 10:33:53 -0700 Subject: [PATCH 63/64] match copies from app --- .../tui/src/bottom_pane/hooks_browser_view.rs | 120 +++++++++++------- ..._hooks_browser_capped_command_details.snap | 4 +- ...r_view__tests__hooks_browser_handlers.snap | 6 +- ..._tests__hooks_browser_managed_handler.snap | 6 +- ...ests__hooks_browser_scrolled_handlers.snap | 18 +-- ...ooks_browser_selected_managed_handler.snap | 8 +- 6 files changed, 92 insertions(+), 70 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index c7f78713125..1c283c9f5bd 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -2,7 +2,6 @@ use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookMetadata; use codex_app_server_protocol::HookSource; -use codex_plugin::PluginId; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -85,7 +84,9 @@ impl HooksBrowserView { let active = self .hooks .iter() - .filter(|hook| hook.event_name == event_name && hook.enabled) + .filter(|hook| { + hook.event_name == event_name && (hook.enabled || hook.is_managed) + }) .count(); EventRow { event_name, @@ -291,12 +292,12 @@ impl HooksBrowserView { self.handlers_for_event(event_name) .enumerate() .map(|(idx, hook)| { - let marker = if hook.enabled { 'x' } else { ' ' }; - let row = if hook.is_managed { - format!("[x] Admin managed {}", summary_source(hook, idx)) + let marker = if hook.enabled || hook.is_managed { + 'x' } else { - format!("[{marker}] {}", summary_source(hook, idx)) + ' ' }; + let row = format!("[{marker}] {}", hook_title(idx)); let mut line = Line::from(row); line = truncate_line_with_ellipsis_if_overflow(line, width); if hook.is_managed { @@ -321,24 +322,9 @@ impl HooksBrowserView { "Matcher", matcher, width, /*max_lines*/ None, )); } - // Plugin hooks show the marketplace here; other hooks show the config path. - let source_value = match hook.source { - HookSource::Plugin => hook - .plugin_id - .as_deref() - .and_then(|plugin_id| { - PluginId::parse(plugin_id) - .ok() - .map(|plugin_id| plugin_id.marketplace_name) - }) - .unwrap_or_else(|| { - format_directory_display(&hook.source_path, /*max_width*/ None) - }), - _ => format_directory_display(&hook.source_path, /*max_width*/ None), - }; lines.extend(detail_wrapped_lines( "Source", - &source_value, + &detail_source_value(hook), width, /*max_lines*/ None, )); @@ -377,7 +363,7 @@ impl HooksBrowserView { ]) } else if selected_hook.is_some_and(|hook| hook.is_managed) { Line::from(vec![ - "Admin managed; press ".into(), + "Managed hooks are always on; press ".into(), key_hint::plain(KeyCode::Esc).into(), " to go back".into(), ]) @@ -567,42 +553,49 @@ fn event_description(event_name: HookEventName) -> &'static str { } } -fn summary_source(hook: &HookMetadata, idx: usize) -> String { - let hook_label = hook - .status_message - .as_deref() - .filter(|message| !message.trim().is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("Hook {}", idx + 1)); - let source_label = match hook.source { - // Parse the plugin name from the `@` id for display. +fn hook_title(idx: usize) -> String { + format!("Hook {}", idx + 1) +} + +fn hook_source_summary(hook: &HookMetadata) -> String { + match hook.source { HookSource::Plugin => hook .plugin_id .as_deref() - .and_then(|plugin_id| { - PluginId::parse(plugin_id) - .ok() - .map(|plugin_id| plugin_id.plugin_name) - }) - .or_else(|| hook.plugin_id.clone()) + .map(|plugin_id| format!("Plugin - {plugin_id}")) .unwrap_or_else(|| "Plugin".to_string()), _ => config_source_label(hook.source).to_string(), - }; - format!("{hook_label} - {source_label}") + } +} + +fn detail_source_value(hook: &HookMetadata) -> String { + match hook.source { + HookSource::Plugin => hook_source_summary(hook), + HookSource::System + | HookSource::Mdm + | HookSource::CloudRequirements + | HookSource::LegacyManagedConfigFile + | HookSource::LegacyManagedConfigMdm => config_source_label(hook.source).to_string(), + _ => format!( + "{} - {}", + config_source_label(hook.source), + format_directory_display(&hook.source_path, /*max_width*/ None) + ), + } } fn config_source_label(source: HookSource) -> &'static str { match source { - HookSource::System => "System Config", - HookSource::User => "User Config", - HookSource::Project => "Project Config", - HookSource::Mdm => "MDM", - HookSource::SessionFlags => "Session Flags", + HookSource::System => "Admin config", + HookSource::User => "User config", + HookSource::Project => "Project config", + HookSource::Mdm => "Admin config", + HookSource::SessionFlags => "Session flags", HookSource::Plugin => unreachable!("plugin hooks are handled by summary_source"), - HookSource::CloudRequirements => "Cloud Requirements", - HookSource::LegacyManagedConfigFile => "Legacy Managed Config", - HookSource::LegacyManagedConfigMdm => "Legacy Managed MDM", - HookSource::Unknown => "Unknown Source", + HookSource::CloudRequirements => "Admin config", + HookSource::LegacyManagedConfigFile => "Admin config", + HookSource::LegacyManagedConfigMdm => "Admin config", + HookSource::Unknown => "Unknown source", } } @@ -922,6 +915,35 @@ mod tests { ); } + #[test] + fn managed_hooks_count_as_active() { + let (tx_raw, _rx) = unbounded_channel::(); + let view = HooksBrowserView::new( + vec![hook( + "path:managed", + HookEventName::PreToolUse, + HookSource::System, + /*plugin_id*/ None, + "/enterprise/hooks/pre-tool-use-check.sh", + /*enabled*/ false, + /*is_managed*/ true, + /*display_order*/ 0, + )], + Vec::new(), + Vec::new(), + AppEventSender::new(tx_raw), + ); + + let rows = view.event_rows(); + let pre_tool_use = rows + .into_iter() + .find(|row| row.event_name == HookEventName::PreToolUse) + .expect("pre tool use row"); + + assert_eq!(pre_tool_use.installed, 1); + assert_eq!(pre_tool_use.active, 1); + } + fn assert_unmanaged_toggle_key(key_code: KeyCode) { let (tx_raw, mut rx) = unbounded_channel::(); let mut view = HooksBrowserView::new( diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap index ee6fbc2a370..e998689e66e 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap @@ -6,11 +6,11 @@ expression: "render_lines(&view, 44)" PreToolUse hooks Turn hooks on or off. Your changes are s - [x] Hook 1 - User Config + [x] Hook 1 Event PreToolUse Matcher Bash - Source /tmp/hooks.json + Source User config - /tmp/hooks.json Command one two three four five six seven eight nine ten eleven twelve thirteen fourteen… diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap index 3826e24f12b..c44f4b866a3 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_handlers.snap @@ -6,12 +6,12 @@ expression: "render_lines(&view, 112)" PreToolUse hooks Turn hooks on or off. Your changes are saved automatically. - [x] Hook 1 - superpowers - [ ] Hook 2 - User Config + [x] Hook 1 + [ ] Hook 2 Event PreToolUse Matcher Bash - Source openai-curated + Source Plugin - superpowers@openai-curated Command ${CODEX_PLUGIN_ROOT}/hooks/pre-tool-use-check.sh Timeout 30s diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap index eee32f4ba71..21c59065f5d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_managed_handler.snap @@ -6,12 +6,12 @@ expression: "render_lines(&view, 112)" PermissionRequest hooks Turn hooks on or off. Your changes are saved automatically. - [x] Admin managed Hook 1 - System Config + [x] Hook 1 Event PermissionRequest Matcher Bash - Source /tmp/hooks.json + Source Admin config Command /enterprise/hooks/permission-check.sh Timeout 30s - Admin managed; press esc to go back + Managed hooks are always on; press esc to go back diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap index 29f58bb8052..4f4a4377c6a 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_scrolled_handlers.snap @@ -6,18 +6,18 @@ expression: "render_lines(&view, 112)" PreToolUse hooks Turn hooks on or off. Your changes are saved automatically. - [x] Hook 2 - User Config - [x] Hook 3 - User Config - [x] Hook 4 - User Config - [x] Hook 5 - User Config - [x] Hook 6 - User Config - [x] Hook 7 - User Config - [x] Hook 8 - User Config - [x] Hook 9 - User Config + [x] Hook 2 + [x] Hook 3 + [x] Hook 4 + [x] Hook 5 + [x] Hook 6 + [x] Hook 7 + [x] Hook 8 + [x] Hook 9 Event PreToolUse Matcher Bash - Source /tmp/hooks.json + Source User config - /tmp/hooks.json Command /tmp/hook-8.sh Timeout 30s diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap index c673a1b86d7..9a53b95d6d2 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_selected_managed_handler.snap @@ -6,13 +6,13 @@ expression: "render_lines(&view, 112)" PreToolUse hooks Turn hooks on or off. Your changes are saved automatically. - [x] Admin managed Hook 1 - System Config - [x] Admin managed Hook 2 - System Config + [x] Hook 1 + [x] Hook 2 Event PreToolUse Matcher Bash - Source /tmp/hooks.json + Source Admin config Command /enterprise/hooks/pre-tool-use-2.sh Timeout 30s - Admin managed; press esc to go back + Managed hooks are always on; press esc to go back From 691ce485acca528676dce7b1b77e95d6791ea288 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Thu, 30 Apr 2026 11:06:13 -0700 Subject: [PATCH 64/64] codex: fix CI failure on PR #19882 --- .../tui/src/bottom_pane/hooks_browser_view.rs | 27 ++++++++++--------- ..._hooks_browser_capped_command_details.snap | 2 +- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index 1c283c9f5bd..2f4c6a8a0d4 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -686,8 +686,9 @@ mod tests { } }) .collect::(); - let normalized = - rendered.replace(&test_path_display("/tmp/hooks.json"), "/tmp/hooks.json"); + let normalized = rendered + .replace(&test_path_display("/tmp/hooks.json"), "/tmp/hooks.json") + .replace(&test_path_display("/tmp/h.json"), "/tmp/h.json"); format!("{normalized:width$}", width = area.width as usize) }) .collect::>() @@ -876,17 +877,19 @@ mod tests { #[test] fn renders_command_details_with_three_line_cap() { let (tx_raw, _rx) = unbounded_channel::(); + let mut capped_command_hook = hook( + "path:long-command", + HookEventName::PreToolUse, + HookSource::User, + /*plugin_id*/ None, + "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty", + /*enabled*/ true, + /*is_managed*/ false, + /*display_order*/ 0, + ); + capped_command_hook.source_path = test_path_buf("/tmp/h.json").abs(); let mut view = HooksBrowserView::new( - vec![hook( - "path:long-command", - HookEventName::PreToolUse, - HookSource::User, - /*plugin_id*/ None, - "one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty", - /*enabled*/ true, - /*is_managed*/ false, - /*display_order*/ 0, - )], + vec![capped_command_hook], Vec::new(), Vec::new(), AppEventSender::new(tx_raw), diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap index e998689e66e..7af93e3c5a8 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__hooks_browser_view__tests__hooks_browser_capped_command_details.snap @@ -10,7 +10,7 @@ expression: "render_lines(&view, 44)" Event PreToolUse Matcher Bash - Source User config - /tmp/hooks.json + Source User config - /tmp/h.json Command one two three four five six seven eight nine ten eleven twelve thirteen fourteen…