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-30 04:46:32 +00:00
committed by GitHub
co-authored by Codex
parent ac4332c05b
commit 8f3c06cc97
39 changed files with 1212 additions and 181 deletions
+28 -11
View File
@@ -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
}
@@ -474,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",
@@ -605,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]
+14 -11
View File
@@ -163,17 +163,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()
}
+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 {
+2 -1
View File
@@ -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<PathBuf>,
pub(crate) analytics_events_client: AnalyticsEventsClient,
pub(crate) hooks: Hooks,
pub(crate) hooks: ArcSwap<Hooks>,
pub(crate) rollout_thread_trace: ThreadTraceContext,
pub(crate) user_shell: Arc<crate::shell::Shell>,
pub(crate) shell_snapshot_tx: watch::Sender<Option<Arc<crate::shell_snapshot::ShellSnapshot>>>,
@@ -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(