mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user