Add persisted hook enablement state (#19840)

## Why

After `hooks/list` exposes the hook inventory, clients need a way to
persist user hook preferences, make those changes effective in
already-open sessions, and distinguish user-controllable hooks from
managed requirements without adding another bespoke app-server write
API.

## What

- Extends `hooks/list` entries with effective `enabled` state.
- Persists user-level hook state under `hooks.state.<hook-id>` so the
model can grow beyond a single boolean over time.
- Uses the existing `config/batchWrite` path for hook state updates
instead of introducing a dedicated hook write RPC.
- Refreshes live session hook engines after config writes so
already-open threads observe updated enablement without a restart.

## Stack

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

## Reviewer Notes

The generated schema files account for much of the raw diff. The core
behavior is in:

- `hooks/src/config_rules.rs`, which resolves per-hook user state from
the config layer stack.
- `hooks/src/engine/discovery.rs`, which projects effective enablement
into `hooks/list` from source-derived managedness.
- `config/src/hook_config.rs`, which defines the new `hooks.state`
representation.
- `core/src/session/mod.rs`, which rebuilds live hook state after user
config reloads.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav
2026-04-29 21:46:32 -07:00
committed by GitHub
Unverified
parent ac4332c05b
commit 8f3c06cc97
39 changed files with 1212 additions and 181 deletions
+63 -10
View File
@@ -1381,6 +1381,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
@@ -1406,16 +1409,36 @@ 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);
config.tool_suggest =
resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack);
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);
config.tool_suggest =
resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack);
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();
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(
@@ -3201,8 +3224,8 @@ impl Session {
}
}
pub(crate) fn hooks(&self) -> &Hooks {
&self.services.hooks
pub(crate) fn hooks(&self) -> Arc<Hooks> {
self.services.hooks.load_full()
}
pub(crate) fn user_shell(&self) -> Arc<shell::Shell> {
@@ -3328,5 +3351,35 @@ fn errors_to_info(errors: &[SkillError]) -> Vec<SkillErrorInfo> {
use codex_memories_read::build_memory_tool_developer_instructions;
/// Builds the hook engine for one config snapshot, including any enabled plugin hooks.
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,
})
}
#[cfg(test)]
pub(crate) mod tests;
+3 -24
View File
@@ -765,29 +765,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(),
@@ -824,7 +803,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,
+41 -4
View File
@@ -1156,6 +1156,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(())
}
#[tokio::test]
async fn reload_user_config_layer_updates_effective_tool_suggest_config() {
let (session, _turn_context) = make_session_and_context().await;
@@ -3476,10 +3513,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,
@@ -4905,10 +4942,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,
+3 -2
View File
@@ -520,7 +520,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 {
@@ -530,7 +531,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 {