diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 3571026193..884fcb0b35 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -543,7 +543,7 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand r#async, status_message, }, - CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {}, + CoreHookHandlerConfig::Prompt { .. } => ConfiguredHookHandler::Prompt {}, CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {}, } } diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index eee66896af..57a47055e2 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -150,7 +150,17 @@ pub enum HookHandlerConfig { status_message: Option, }, #[serde(rename = "prompt")] - Prompt {}, + Prompt { + prompt: String, + #[serde(default)] + model: Option, + #[serde(default, rename = "timeout", alias = "timeoutSec")] + timeout_sec: Option, + #[serde(default, rename = "statusMessage")] + status_message: Option, + #[serde(default, rename = "continueOnBlock")] + continue_on_block: bool, + }, #[serde(rename = "agent")] Agent {}, } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b281b44bb7..94fe3a9ca1 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1057,6 +1057,27 @@ }, { "properties": { + "continueOnBlock": { + "default": false, + "type": "boolean" + }, + "model": { + "default": null, + "type": "string" + }, + "prompt": { + "type": "string" + }, + "statusMessage": { + "default": null, + "type": "string" + }, + "timeout": { + "default": null, + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, "type": { "enum": [ "prompt" @@ -1065,6 +1086,7 @@ } }, "required": [ + "prompt", "type" ], "type": "object" diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 124dfb18c0..01abf8ae8a 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -238,6 +238,7 @@ pub struct ModelClient { pub struct ModelClientSession { client: ModelClient, websocket_session: WebsocketSession, + cache_websocket_session_on_drop: bool, /// Turn state for sticky routing. /// /// This is an `OnceLock` that stores the turn state value received from the server @@ -382,6 +383,18 @@ impl ModelClient { ModelClientSession { client: self.clone(), websocket_session: self.take_cached_websocket_session(), + cache_websocket_session_on_drop: true, + turn_state: Arc::new(OnceLock::new()), + } + } + + /// Creates a fresh streaming session that does not read or write the session's cached + /// WebSocket state. + pub(crate) fn new_isolated_session(&self) -> ModelClientSession { + ModelClientSession { + client: self.clone(), + websocket_session: WebsocketSession::default(), + cache_websocket_session_on_drop: false, turn_state: Arc::new(OnceLock::new()), } } @@ -951,9 +964,11 @@ impl ModelClient { impl Drop for ModelClientSession { fn drop(&mut self) { - let websocket_session = std::mem::take(&mut self.websocket_session); - self.client - .store_cached_websocket_session(websocket_session); + if self.cache_websocket_session_on_drop { + let websocket_session = std::mem::take(&mut self.websocket_session); + self.client + .store_cached_websocket_session(websocket_session); + } } } diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index f8d904036a..02d8c8c2e1 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -2,6 +2,7 @@ use super::AuthRequestTelemetryContext; use super::ModelClient; use super::PendingUnauthorizedRetry; use super::UnauthorizedRecoveryExecution; +use super::WebsocketSession; use super::X_CODEX_INSTALLATION_ID_HEADER; use super::X_CODEX_PARENT_THREAD_ID_HEADER; use super::X_CODEX_TURN_METADATA_HEADER; @@ -116,6 +117,24 @@ fn test_model_info() -> ModelInfo { .expect("deserialize test model info") } +#[test] +fn isolated_session_does_not_touch_cached_websocket_state() { + let model_client = test_model_client(SessionSource::Cli); + let cached_websocket_session = WebsocketSession::default(); + cached_websocket_session.set_connection_reused(/*connection_reused*/ true); + model_client.store_cached_websocket_session(cached_websocket_session); + + let isolated_session = model_client.new_isolated_session(); + assert!(!isolated_session.websocket_session.connection_reused()); + drop(isolated_session); + + assert!( + model_client + .take_cached_websocket_session() + .connection_reused() + ); +} + fn test_session_telemetry() -> SessionTelemetry { SessionTelemetry::new( ThreadId::new(), diff --git a/codex-rs/core/src/hook_prompt.rs b/codex-rs/core/src/hook_prompt.rs new file mode 100644 index 0000000000..e125645337 --- /dev/null +++ b/codex-rs/core/src/hook_prompt.rs @@ -0,0 +1,160 @@ +use std::sync::Arc; + +use codex_hooks::PromptHookRequest; +use codex_hooks::PromptHookRunner; +use codex_models_manager::ModelsManagerConfig; +use codex_models_manager::manager::SharedModelsManager; +use codex_otel::SessionTelemetry; +use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::models::BaseInstructions; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_rollout_trace::InferenceTraceContext; +use futures::StreamExt; +use serde_json::json; + +use crate::client::ModelClient; +use crate::client_common::Prompt; +use crate::client_common::ResponseEvent; +use crate::config::Config; +use crate::stream_events_utils::raw_assistant_output_text_from_item; + +const PROMPT_HOOK_BASE_INSTRUCTIONS: &str = r#"You evaluate a Codex prompt hook. + +The user message contains: +1. The hook author's instructions. +2. The hook input JSON. + +Decide whether the hook input satisfies the hook author's instructions. + +Return only JSON: +{"ok": true} +or +{"ok": false, "reason": "concise actionable reason"} + +Use ok:false only when the hook criteria fail. Do not answer the user's task. Do not include Markdown or extra text."#; + +pub(crate) fn build_prompt_hook_runner( + model_client: ModelClient, + models_manager: SharedModelsManager, + config: &Config, + session_telemetry: SessionTelemetry, + service_tier: Option, +) -> PromptHookRunner { + let models_manager_config = config.to_models_manager_config(); + PromptHookRunner::new(move |request| { + let model_client = model_client.clone(); + let models_manager = Arc::clone(&models_manager); + let models_manager_config = models_manager_config.clone(); + let session_telemetry = session_telemetry.clone(); + let service_tier = service_tier.clone(); + async move { + run_prompt_hook( + model_client, + models_manager, + models_manager_config, + session_telemetry, + service_tier, + request, + ) + .await + } + }) +} + +/// Run the hook as an isolated model request: resolve the configured model, +/// send the rendered hook prompt as the only user message, disable all +/// tools/personality/reasoning summaries, and constrain the final answer to the +/// small `{ ok, reason }` contract that `codex-hooks` maps back into existing +/// hook output semantics. +async fn run_prompt_hook( + model_client: ModelClient, + models_manager: SharedModelsManager, + models_manager_config: ModelsManagerConfig, + session_telemetry: SessionTelemetry, + service_tier: Option, + request: PromptHookRequest, +) -> anyhow::Result { + let model_info = models_manager + .get_model_info(request.model.as_str(), &models_manager_config) + .await; + let prompt = Prompt { + input: vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: request.prompt, + }], + phase: None, + }], + tools: Vec::new(), + parallel_tool_calls: false, + base_instructions: BaseInstructions { + text: PROMPT_HOOK_BASE_INSTRUCTIONS.to_string(), + }, + personality: None, + output_schema: Some(prompt_hook_output_schema()), + output_schema_strict: false, + }; + + let disabled_trace = InferenceTraceContext::disabled(); + let mut client_session = model_client.new_isolated_session(); + let mut stream = client_session + .stream( + &prompt, + &model_info, + &session_telemetry, + /*effort*/ None, + ReasoningSummaryConfig::None, + service_tier, + /*turn_metadata_header*/ None, + &disabled_trace, + ) + .await?; + let mut delta_text = String::new(); + let mut item_texts = Vec::new(); + while let Some(event) = stream.next().await { + match event? { + ResponseEvent::OutputItemDone(item) => { + if let Some(text) = raw_assistant_output_text_from_item(&item) { + item_texts.push(text); + } + } + ResponseEvent::OutputTextDelta(delta) => delta_text.push_str(delta.as_str()), + ResponseEvent::Completed { .. } => break, + ResponseEvent::Created + | ResponseEvent::OutputItemAdded(_) + | ResponseEvent::ServerModel(_) + | ResponseEvent::ModelVerifications(_) + | ResponseEvent::ServerReasoningIncluded(_) + | ResponseEvent::ToolCallInputDelta { .. } + | ResponseEvent::ReasoningSummaryDelta { .. } + | ResponseEvent::ReasoningContentDelta { .. } + | ResponseEvent::ReasoningSummaryPartAdded { .. } + | ResponseEvent::RateLimits(_) + | ResponseEvent::ModelsEtag(_) => {} + } + } + + if item_texts.is_empty() { + Ok(delta_text) + } else { + Ok(item_texts.join("")) + } +} + +fn prompt_hook_output_schema() -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "reason": { + "type": "string" + } + }, + "required": ["ok"], + "additionalProperties": false + }) +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 31ac529556..f70c9db4b5 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -41,6 +41,7 @@ mod goals; pub use goals::ExternalGoalPreviousStatus; pub use goals::ExternalGoalSet; mod guardian; +mod hook_prompt; mod hook_runtime; mod installation_id; pub(crate) mod landlock; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index a30a7e8a13..0d4506d178 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -58,6 +58,7 @@ use codex_features::Feature; use codex_features::unstable_features_warning_event; use codex_hooks::Hooks; use codex_hooks::HooksConfig; +use codex_hooks::PromptHookRunner; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::auth_env_telemetry::collect_auth_env_telemetry; @@ -1482,10 +1483,11 @@ impl Session { // layers such as request/session overrides that were present when this session // was created. let notify_config_contributors = !self.services.extensions.config_contributors().is_empty(); - let (previous_config, new_config, config) = { + let (previous_config, new_config, config, service_tier) = { let mut state = self.state.lock().await; let previous_config = notify_config_contributors .then(|| Self::build_effective_session_config(&state.session_configuration)); + let service_tier = state.session_configuration.service_tier.clone(); let mut config = (*state.session_configuration.original_config_do_not_use).clone(); config.config_layer_stack = config .config_layer_stack @@ -1496,15 +1498,23 @@ impl Session { state.session_configuration.original_config_do_not_use = Arc::clone(&config); let new_config = notify_config_contributors .then(|| Self::build_effective_session_config(&state.session_configuration)); - (previous_config, new_config, config) + (previous_config, new_config, config, service_tier) }; self.emit_config_changed_contributors(previous_config.as_ref(), new_config.as_ref()); self.services.skills_manager.clear_cache(); self.services.plugins_manager.clear_cache(); + let prompt_hook_runner = crate::hook_prompt::build_prompt_hook_runner( + self.services.model_client.clone(), + Arc::clone(&self.services.models_manager), + config.as_ref(), + self.services.session_telemetry.clone(), + service_tier, + ); let hooks = build_hooks_for_config( config.as_ref(), self.services.plugins_manager.as_ref(), self.services.user_shell.as_ref(), + Some(prompt_hook_runner), ) .await; @@ -3382,6 +3392,7 @@ async fn build_hooks_for_config( config: &Config, plugins_manager: &PluginsManager, user_shell: &crate::shell::Shell, + prompt_hook_runner: Option, ) -> Hooks { let mut hook_shell_argv = user_shell.derive_exec_args("", /*use_login_shell*/ false); let hook_shell_program = hook_shell_argv.remove(0); @@ -3399,7 +3410,7 @@ async fn build_hooks_for_config( plugin_hook_load_warnings, shell_program: Some(hook_shell_program), shell_args: hook_shell_argv, - prompt_hook_runner: None, + prompt_hook_runner, }) } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 05773abb8c..b3f0559c59 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -942,17 +942,6 @@ impl Session { (None, None) }; - 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(), - msg: EventMsg::Warning(WarningEvent { - message: warning.clone(), - }), - }); - } - let analytics_events_client = analytics_events_client.unwrap_or_else(|| { AnalyticsEventsClient::new( Arc::clone(&auth_manager), @@ -966,6 +955,49 @@ impl Session { SessionId::from(thread_id) }; let agent_control = agent_control.with_session_id(session_id); + let model_client = ModelClient::new( + Some(Arc::clone(&auth_manager)), + session_id, + thread_id, + installation_id.clone(), + session_configuration.provider.clone(), + session_configuration.session_source.clone(), + session_configuration.parent_thread_id, + config.model_verbosity, + config.features.enabled(Feature::EnableRequestCompression), + config.features.enabled(Feature::RuntimeMetrics), + Self::build_model_client_beta_features_header(config.as_ref()), + attestation_provider.clone(), + ) + .with_prompt_cache_key_override( + crate::guardian::prompt_cache_key_override_for_review_session( + &session_configuration.session_source, + session_configuration.parent_thread_id, + ), + ); + model_client.set_window_generation(window_generation); + let prompt_hook_runner = crate::hook_prompt::build_prompt_hook_runner( + model_client.clone(), + Arc::clone(&models_manager), + config.as_ref(), + session_telemetry.clone(), + session_configuration.service_tier.clone(), + ); + let hooks = build_hooks_for_config( + &config, + plugins_manager.as_ref(), + &default_shell, + Some(prompt_hook_runner), + ) + .await; + for warning in hooks.startup_warnings() { + post_session_configured_events.push(Event { + id: INITIAL_SUBMIT_ID.to_owned(), + msg: EventMsg::Warning(WarningEvent { + message: warning.clone(), + }), + }); + } let session_extension_data = codex_extension_api::ExtensionData::new(session_id.to_string()); let thread_extension_data = @@ -1031,32 +1063,10 @@ impl Session { live_thread: live_thread_init.as_ref().cloned(), thread_store: Arc::clone(&thread_store), attestation_provider: attestation_provider.clone(), - model_client: ModelClient::new( - Some(Arc::clone(&auth_manager)), - session_id, - thread_id, - installation_id.clone(), - session_configuration.provider.clone(), - session_configuration.session_source.clone(), - session_configuration.parent_thread_id, - config.model_verbosity, - config.features.enabled(Feature::EnableRequestCompression), - config.features.enabled(Feature::RuntimeMetrics), - Self::build_model_client_beta_features_header(config.as_ref()), - attestation_provider, - ) - .with_prompt_cache_key_override( - crate::guardian::prompt_cache_key_override_for_review_session( - &session_configuration.session_source, - session_configuration.parent_thread_id, - ), - ), + model_client, code_mode_service: crate::tools::code_mode::CodeModeService::new(), environment_manager, }; - services - .model_client - .set_window_generation(window_generation); let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) = watch::channel(false); diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 5813508e7a..c90fb5f42e 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -47,6 +47,7 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; @@ -242,6 +243,23 @@ if payload.get("prompt") == {blocked_prompt_json}: Ok(()) } +fn write_user_prompt_submit_prompt_hook(home: &Path) -> Result<()> { + let hooks = serde_json::json!({ + "hooks": { + "UserPromptSubmit": [{ + "hooks": [{ + "type": "prompt", + "prompt": "Reject prompts that mention secrets: $ARGUMENTS", + "statusMessage": "checking prompt", + }] + }] + } + }); + + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + fn write_session_start_and_user_prompt_submit_order_hooks(home: &Path) -> Result<()> { let session_start_script_path = home.join("session_start_order_hook.py"); let user_prompt_submit_script_path = home.join("user_prompt_submit_order_hook.py"); @@ -1713,6 +1731,68 @@ async fn multiple_blocking_stop_hooks_persist_multiple_hook_prompt_fragments() - Ok(()) } +#[tokio::test] +async fn user_prompt_submit_prompt_hook_runs_isolated_request() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = mount_sse_once( + &server, + sse(vec![ + ev_response_created("prompt-hook-resp"), + ev_assistant_message( + "prompt-hook-msg", + r#"{"ok":false,"reason":"do not mention secrets"}"#, + ), + ev_completed("prompt-hook-resp"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_model("gpt-5.4") + .with_pre_build_hook(|home| { + if let Err(error) = write_user_prompt_submit_prompt_hook(home) { + panic!("failed to write user prompt submit prompt hook fixture: {error}"); + } + }) + .with_config(trust_discovered_hooks); + let test = builder.build(&server).await?; + let prompt = "secret launch codes"; + + test.submit_turn(prompt).await?; + + let request = response.single_request(); + let body = request.body_json(); + assert_eq!(body["model"], json!("gpt-5.4")); + assert_eq!(body["tools"], json!([])); + assert!( + request + .instructions_text() + .starts_with("You evaluate a Codex prompt hook."), + "prompt hook request should use isolated hook instructions", + ); + let user_inputs = request.message_input_texts("user"); + assert_eq!( + user_inputs.len(), + 1, + "prompt hook request should contain exactly one user message", + ); + assert_eq!( + request.message_input_texts("developer"), + Vec::::new(), + "prompt hook request should not inherit developer context", + ); + let hook_input = user_inputs.first().context("prompt hook user input")?; + assert!(hook_input.contains("Reject prompts that mention secrets:")); + assert!( + hook_input.contains(prompt), + "prompt hook request should include the submitted prompt", + ); + + Ok(()) +} + #[tokio::test] async fn blocked_user_prompt_submit_persists_additional_context_for_next_turn() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/hooks/src/declarations.rs b/codex-rs/hooks/src/declarations.rs index 468fbc947a..19b5b09c4f 100644 --- a/codex-rs/hooks/src/declarations.rs +++ b/codex-rs/hooks/src/declarations.rs @@ -62,7 +62,13 @@ mod tests { pre_tool_use: vec![MatcherGroup { matcher: None, hooks: vec![ - HookHandlerConfig::Prompt {}, + HookHandlerConfig::Prompt { + prompt: "check $ARGUMENTS".to_string(), + model: None, + timeout_sec: None, + status_message: None, + continue_on_block: false, + }, HookHandlerConfig::Command { command: "echo hi".to_string(), command_windows: None, diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 8b7d6093b2..87c9da43fc 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -24,9 +24,12 @@ use serde::Serialize; use super::ConfiguredHandler; use super::ConfiguredHandlerKind; use super::HookListEntry; +use super::prompt_runner::PromptHookBehavior; +use super::prompt_runner::prompt_hook_behavior; use crate::config_rules::hook_states_from_stack; use crate::events::common::matcher_pattern_for_event; use crate::events::common::validate_matcher_pattern; +use crate::schema::hook_event_wire_name; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookSource; @@ -506,6 +509,9 @@ fn append_matcher_groups( handler_type: HookHandlerType::Command, matcher: matcher.map(ToOwned::to_owned), command: Some(command.clone()), + prompt: None, + model: None, + continue_on_block: None, timeout_sec, status_message: status_message.clone(), source_path: source.path.clone(), @@ -540,10 +546,92 @@ fn append_matcher_groups( } *display_order += 1; } - HookHandlerConfig::Prompt {} => warnings.push(format!( - "skipping prompt hook in {}: prompt hooks are not supported yet", - source.path.display() - )), + HookHandlerConfig::Prompt { + prompt, + model, + timeout_sec, + status_message, + continue_on_block, + } => { + if matches!( + prompt_hook_behavior(event_name), + PromptHookBehavior::Unsupported + ) { + warnings.push(format!( + "skipping prompt hook in {}: prompt hooks are not supported for {}", + source.path.display(), + hook_event_wire_name(event_name) + )); + continue; + } + if prompt.trim().is_empty() { + warnings.push(format!( + "skipping empty hook prompt in {}", + source.path.display() + )); + continue; + } + let timeout_sec = timeout_sec.unwrap_or(30).max(1); + let normalized_handler = HookHandlerConfig::Prompt { + prompt: prompt.clone(), + model: model.clone(), + timeout_sec: Some(timeout_sec), + status_message: status_message.clone(), + continue_on_block, + }; + let current_hash = hook_hash(event_name, matcher, &group, normalized_handler); + let key = + crate::hook_key(&source.key_source, event_name, group_index, handler_index); + let state = source.hook_states.get(&key); + let enabled = hook_enabled(source.is_managed, state); + let trusted_hash = hook_trusted_hash(source.is_managed, state); + let trust_status = + hook_trust_status(source.is_managed, ¤t_hash, trusted_hash); + hook_entries.push(HookListEntry { + key, + event_name, + handler_type: HookHandlerType::Prompt, + matcher: matcher.map(ToOwned::to_owned), + command: None, + prompt: Some(prompt.clone()), + model: model.clone(), + continue_on_block: Some(continue_on_block), + timeout_sec, + status_message: status_message.clone(), + source_path: source.path.clone(), + source: source.source, + plugin_id: source.plugin_id.clone(), + display_order: *display_order, + enabled, + is_managed: source.is_managed, + current_hash, + trust_status, + }); + if enabled + && (source.bypass_hook_trust + || matches!( + trust_status, + HookTrustStatus::Managed | HookTrustStatus::Trusted + )) + { + handlers.push(ConfiguredHandler { + event_name, + matcher: matcher.map(ToOwned::to_owned), + kind: ConfiguredHandlerKind::Prompt { + prompt, + model, + timeout_sec, + continue_on_block, + }, + status_message, + source_path: source.path.clone(), + source: source.source, + display_order: *display_order, + env: source.env.clone(), + }); + } + *display_order += 1; + } HookHandlerConfig::Agent {} => warnings.push(format!( "skipping agent hook in {}: agent hooks are not supported yet", source.path.display() @@ -666,6 +754,7 @@ mod tests { use codex_config::HookStateToml; use codex_config::MatcherGroup; use codex_config::TomlValue; + use codex_protocol::protocol::HookHandlerType; use codex_protocol::protocol::HookTrustStatus; fn source_path() -> AbsolutePathBuf { @@ -757,6 +846,19 @@ mod tests { } } + fn prompt_group(matcher: Option<&str>) -> MatcherGroup { + MatcherGroup { + matcher: matcher.map(str::to_string), + hooks: vec![HookHandlerConfig::Prompt { + prompt: "Check this hook input: $ARGUMENTS".to_string(), + model: Some("gpt-5-mini".to_string()), + timeout_sec: None, + status_message: Some("Checking prompt hook".to_string()), + continue_on_block: true, + }], + } + } + #[test] fn user_prompt_submit_ignores_invalid_matcher_during_discovery() { let mut handlers = Vec::new(); @@ -944,6 +1046,76 @@ mod tests { assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write")); } + #[test] + fn permission_request_prompt_hook_is_discovered_as_supported() { + let mut handlers = Vec::new(); + let mut hook_entries = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + let source_path = source_path(); + let hook_states = std::collections::HashMap::new(); + + append_matcher_groups( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + &hook_handler_source(&source_path, &hook_states), + HookEventName::PermissionRequest, + vec![prompt_group(Some(".*"))], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers.len(), 1); + assert_eq!(hook_entries.len(), 1); + assert_eq!(hook_entries[0].handler_type, HookHandlerType::Prompt); + assert_eq!(hook_entries[0].command, None); + assert_eq!( + hook_entries[0].prompt.as_deref(), + Some("Check this hook input: $ARGUMENTS") + ); + assert_eq!(hook_entries[0].timeout_sec, 30); + assert_eq!( + handlers[0].kind, + ConfiguredHandlerKind::Prompt { + prompt: "Check this hook input: $ARGUMENTS".to_string(), + model: Some("gpt-5-mini".to_string()), + timeout_sec: 30, + continue_on_block: true, + } + ); + } + + #[test] + fn unsupported_prompt_hook_is_skipped_during_discovery() { + let mut handlers = Vec::new(); + let mut hook_entries = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + let source_path = source_path(); + let hook_states = std::collections::HashMap::new(); + + append_matcher_groups( + &mut handlers, + &mut hook_entries, + &mut warnings, + &mut display_order, + &hook_handler_source(&source_path, &hook_states), + HookEventName::SessionStart, + vec![prompt_group(/*matcher*/ None)], + ); + + assert_eq!(handlers, Vec::::new()); + assert_eq!(hook_entries.len(), 0); + assert_eq!( + warnings, + vec![format!( + "skipping prompt hook in {}: prompt hooks are not supported for SessionStart", + source_path.display() + )] + ); + } + #[test] fn toml_hook_discovery_ignores_malformed_state_entries() { let layer = ConfigLayerEntry::new( diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 2b2ce0d3ed..039dcbde8a 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -60,10 +60,6 @@ pub(crate) enum ConfiguredHandlerKind { command: String, timeout_sec: u64, }, - #[allow( - dead_code, - reason = "constructed by prompt-hook discovery in the follow-up" - )] Prompt { prompt: String, model: Option, @@ -127,6 +123,9 @@ pub struct HookListEntry { pub handler_type: HookHandlerType, pub matcher: Option, pub command: Option, + pub prompt: Option, + pub model: Option, + pub continue_on_block: Option, pub timeout_sec: u64, pub status_message: Option, pub source_path: AbsolutePathBuf,