use std::time::Duration;use anyhow::Result;use app_test_support::TestAppServer;use app_test_support::create_final_assistant_message_sse_response;use app_test_support::create_mock_responses_server_sequence_unchecked;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::HookTrustStatus;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_app_server_protocol::ThreadStartParams;use codex_app_server_protocol::ThreadStartResponse;use codex_app_server_protocol::TurnStartParams;use codex_app_server_protocol::UserInput as V2UserInput;use codex_core::config::set_project_trust_level;use codex_protocol::config_types::TrustLevel;use codex_utils_absolute_path::AbsolutePathBuf;use core_test_support::skip_if_windows;use pretty_assertions::assert_eq;use serde::Serialize;use tempfile::TempDir;use tokio::time::timeout;const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);#[derive(Serialize)]struct NormalizedHookIdentity { event_name: &'static str, #[serde(flatten)] group: codex_config::MatcherGroup,}fn command_hook_hash( event_name: &'static str, matcher: Option<&str>, command: &str, timeout_sec: u64, status_message: Option<&str>,) -> String { let identity = NormalizedHookIdentity { event_name, group: codex_config::MatcherGroup { matcher: matcher.map(ToOwned::to_owned), hooks: vec![codex_config::HookHandlerConfig::Command { command: command.to_string(), command_windows: None, timeout_sec: Some(timeout_sec), r#async: false, status_message: status_message.map(ToOwned::to_owned), }], }, }; let Ok(value) = codex_config::TomlValue::try_from(identity) else { unreachable!("normalized hook identity should serialize to TOML"); }; codex_config::version_for_toml(&value)}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 = 5statusMessage = "running listed hook""#, )?; Ok(())}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"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"demo"}"#, )?; std::fs::write(plugin_root.join("hooks/hooks.json"), hooks_json)?; std::fs::write( codex_home.join("config.toml"), r#"[features]plugins = truehooks = true[plugins."demo@test"]enabled = true"#, )?; Ok(())}fn write_project_hook_config(dot_codex_folder: &std::path::Path, command: &str) -> Result<()> { std::fs::create_dir_all(dot_codex_folder)?; std::fs::write( dot_codex_folder.join("config.toml"), format!( r#"[features]hooks = true[hooks][[hooks.PreToolUse]]matcher = "Bash"[[hooks.PreToolUse.hooks]]type = "command"command = "{command}"timeout = 5"# ), )?; Ok(())}#[tokio::test]async fn hooks_list_shows_discovered_hook() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; write_user_hook_config(codex_home.path())?; let mut mcp = TestAppServer::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 config_path = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( codex_home.path().join("config.toml"), )?)?; assert_eq!( data, vec![HooksListEntry { cwd: cwd.path().to_path_buf(), hooks: vec![HookMetadata { key: format!("{}:pre_tool_use:0:0", config_path.as_path().display()), 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: config_path, source: HookSource::User, plugin_id: None, display_order: 0, enabled: true, is_managed: false, current_hash: command_hook_hash( "pre_tool_use", Some("Bash"), "python3 /tmp/listed-hook.py", /*timeout_sec*/ 5, Some("running listed hook"), ), trust_status: HookTrustStatus::Untrusted, }], 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 = TestAppServer::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 plugin_hooks_path = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( codex_home .path() .join("plugins/cache/test/demo/local/hooks/hooks.json"), )?)?; assert_eq!( data, vec![HooksListEntry { cwd: cwd.path().to_path_buf(), hooks: vec![HookMetadata { key: "demo@test:hooks/hooks.json:pre_tool_use:0:0".to_string(), 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: plugin_hooks_path, source: HookSource::Plugin, plugin_id: Some("demo@test".to_string()), display_order: 0, enabled: true, is_managed: false, current_hash: command_hook_hash( "pre_tool_use", Some("Bash"), "echo plugin hook", /*timeout_sec*/ 7, Some("running plugin hook"), ), trust_status: HookTrustStatus::Untrusted, }], warnings: Vec::new(), errors: Vec::new(), }] ); Ok(())}#[tokio::test]async fn hooks_list_warms_plugin_capabilities_for_thread_start() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; write_plugin_hook_config( codex_home.path(), r#"{ "hooks": { "PreToolUse": [ { "hooks": [ { "type": "command", "command": "echo plugin hook" } ] } ] }}"#, )?; let plugin_mcp_path = codex_home .path() .join("plugins/cache/test/demo/local/.mcp.json"); std::fs::write( &plugin_mcp_path, r#"{ "mcpServers": { "plugin-server": { "url": "http://127.0.0.1:1/mcp" } }}"#, )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let hooks_list_id = mcp .send_hooks_list_request(HooksListParams { cwds: vec![cwd.path().to_path_buf()], }) .await?; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(hooks_list_id)), ) .await??; std::fs::remove_file(plugin_mcp_path)?; let thread_start_id = mcp .send_thread_start_request(ThreadStartParams::default()) .await?; let _: ThreadStartResponse = to_response( timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), ) .await??, )?; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_matching_notification("plugin MCP server starting", |notification| { notification.method == "mcpServer/startupStatus/updated" && notification .params .as_ref() .and_then(|params| params.get("name")) .and_then(serde_json::Value::as_str) == Some("plugin-server") }), ) .await??; Ok(())}#[tokio::test]async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; write_plugin_hook_config(codex_home.path(), "{ not-json")?; let mut mcp = TestAppServer::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(())}#[tokio::test]async fn hooks_list_uses_each_cwds_effective_feature_enablement() -> Result<()> { let codex_home = TempDir::new()?; let workspace = TempDir::new()?; std::fs::write( codex_home.path().join("config.toml"), r#"[features]hooks = false"#, )?; std::fs::create_dir_all(workspace.path().join(".git"))?; std::fs::create_dir_all(workspace.path().join(".codex"))?; std::fs::write( workspace.path().join(".codex/config.toml"), r#"[features]hooks = true[hooks][[hooks.PreToolUse]]matcher = "Bash"[[hooks.PreToolUse.hooks]]type = "command"command = "echo project hook"timeout = 5"#, )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_hooks_list_request(HooksListParams { cwds: vec![ codex_home.path().to_path_buf(), workspace.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 project_config_path = AbsolutePathBuf::try_from(workspace.path().join(".codex/config.toml"))?; assert_eq!( data, vec![ HooksListEntry { cwd: codex_home.path().to_path_buf(), hooks: Vec::new(), warnings: Vec::new(), errors: Vec::new(), }, HooksListEntry { cwd: workspace.path().to_path_buf(), hooks: vec![HookMetadata { key: format!( "{}:pre_tool_use:0:0", project_config_path.as_path().display() ), event_name: HookEventName::PreToolUse, handler_type: HookHandlerType::Command, matcher: Some("Bash".to_string()), command: Some("echo project hook".to_string()), timeout_sec: 5, status_message: None, source_path: project_config_path, source: HookSource::Project, plugin_id: None, display_order: 0, enabled: true, is_managed: false, current_hash: command_hook_hash( "pre_tool_use", Some("Bash"), "echo project hook", /*timeout_sec*/ 5, /*status_message*/ None, ), trust_status: HookTrustStatus::Untrusted, }], warnings: Vec::new(), errors: Vec::new(), }, ] ); Ok(())}#[tokio::test]async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> { let codex_home = TempDir::new()?; let workspace = TempDir::new()?; let repo_root = workspace.path().join("repo"); let worktree_root = workspace.path().join("worktree"); let worktree_git_dir = repo_root.join(".git/worktrees/feature-x"); std::fs::create_dir_all(&worktree_git_dir)?; std::fs::create_dir_all(&worktree_root)?; std::fs::write( worktree_root.join(".git"), format!("gitdir: {}\n", worktree_git_dir.display()), )?; write_project_hook_config(&repo_root.join(".codex"), "echo root hook")?; write_project_hook_config(&worktree_root.join(".codex"), "echo worktree hook")?; set_project_trust_level(codex_home.path(), &repo_root, TrustLevel::Trusted)?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let list_id = mcp .send_hooks_list_request(HooksListParams { cwds: vec![repo_root.clone(), worktree_root.clone()], }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(list_id)), ) .await??; let HooksListResponse { data } = to_response(response)?; let repo_hook = data[0].hooks[0].clone(); let worktree_hook = data[1].hooks[0].clone(); let repo_config_path = AbsolutePathBuf::from_absolute_path(repo_root.join(".codex/config.toml"))?; assert_eq!(repo_hook.command.as_deref(), Some("echo root hook")); assert_eq!(worktree_hook.command.as_deref(), Some("echo root hook")); assert_eq!(repo_hook.key, worktree_hook.key); assert_eq!(repo_hook.source_path, repo_config_path); assert_eq!(worktree_hook.source_path, repo_config_path); let write_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { edits: vec![ConfigEdit { key_path: "hooks.state".to_string(), value: serde_json::json!({ repo_hook.key.clone(): { "trusted_hash": repo_hook.current_hash.clone() } }), 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 list_id = mcp .send_hooks_list_request(HooksListParams { cwds: vec![worktree_root], }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(list_id)), ) .await??; let HooksListResponse { data } = to_response(response)?; assert_eq!(data[0].hooks[0].trust_status, HookTrustStatus::Trusted); Ok(())}#[tokio::test]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())?; let mut mcp = TestAppServer::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_config_batch_write_request(ConfigBatchWriteParams { edits: vec![ConfigEdit { key_path: "hooks.state".to_string(), 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( 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.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: "hooks.state".to_string(), 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(())}#[tokio::test]async fn config_batch_write_updates_hook_trust_for_loaded_session() -> Result<()> { skip_if_windows!(Ok(())); let responses = vec![ create_final_assistant_message_sse_response("Warmup")?, create_final_assistant_message_sse_response("Untrusted turn")?, create_final_assistant_message_sse_response("Trusted turn")?, create_final_assistant_message_sse_response("Modified turn")?, ]; let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; let hook_script_path = codex_home.path().join("user_prompt_submit_hook.py"); let hook_log_path = codex_home.path().join("user_prompt_submit_hook_log.jsonl"); std::fs::write( &hook_script_path, format!( r#"import jsonfrom pathlib import Pathimport syspayload = json.load(sys.stdin)with Path(r"{hook_log_path}").open("a", encoding="utf-8") as handle: handle.write(json.dumps(payload) + "\n")"#, hook_log_path = hook_log_path.display(), ), )?; std::fs::write( codex_home.path().join("config.toml"), format!( r#"model = "mock-model"approval_policy = "never"sandbox_mode = "read-only"model_provider = "mock_provider"[model_providers.mock_provider]name = "Mock provider for test"base_url = "{server_uri}/v1"wire_api = "responses"request_max_retries = 0stream_max_retries = 0[hooks][[hooks.UserPromptSubmit]][[hooks.UserPromptSubmit.hooks]]type = "command"command = "python3 {hook_script_path}""#, server_uri = server.uri(), hook_script_path = hook_script_path.display(), ), )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let hook_list_id = mcp .send_hooks_list_request(HooksListParams { cwds: vec![codex_home.path().to_path_buf()], }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), ) .await??; let HooksListResponse { data } = to_response(response)?; let hook = data[0].hooks[0].clone(); assert_eq!(hook.trust_status, HookTrustStatus::Untrusted); let thread_start_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), ) .await??; let ThreadStartResponse { thread, .. } = to_response(response)?; let first_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), client_user_message_id: None, input: vec![V2UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), ) .await??; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; assert!(!std::fs::exists(&hook_log_path)?); let write_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { edits: vec![ConfigEdit { key_path: "hooks.state".to_string(), value: serde_json::json!({ hook.key.clone(): { "trusted_hash": hook.current_hash.clone() } }), 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 hook_list_id = mcp .send_hooks_list_request(HooksListParams { cwds: vec![codex_home.path().to_path_buf()], }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), ) .await??; let HooksListResponse { data } = to_response(response)?; let trusted_hook = &data[0].hooks[0]; assert_eq!(trusted_hook.key, hook.key); assert_eq!(trusted_hook.current_hash, hook.current_hash); assert_eq!(trusted_hook.trust_status, HookTrustStatus::Trusted); let second_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), client_user_message_id: None, input: vec![V2UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), ) .await??; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; assert_eq!( std::fs::read_to_string(&hook_log_path)? .lines() .filter(|line| !line.is_empty()) .count(), 1 ); let write_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { edits: vec![ConfigEdit { key_path: "hooks.UserPromptSubmit".to_string(), value: serde_json::json!([{ "hooks": [{ "type": "command", "command": format!("python3 {}", hook_script_path.display()), "statusMessage": "modified hook", }], }]), merge_strategy: MergeStrategy::Replace, }], 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 hook_list_id = mcp .send_hooks_list_request(HooksListParams { cwds: vec![codex_home.path().to_path_buf()], }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), ) .await??; let HooksListResponse { data } = to_response(response)?; let modified_hook = &data[0].hooks[0]; assert_eq!(modified_hook.key, hook.key); assert_ne!(modified_hook.current_hash, hook.current_hash); assert_eq!(modified_hook.trust_status, HookTrustStatus::Modified); let third_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, client_user_message_id: None, input: vec![V2UserInput::Text { text: "third turn".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(third_turn_id)), ) .await??; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; assert_eq!( std::fs::read_to_string(&hook_log_path)? .lines() .filter(|line| !line.is_empty()) .count(), 1 ); Ok(())}#[tokio::test]async fn config_batch_write_disables_hook_for_loaded_session() -> Result<()> { skip_if_windows!(Ok(())); let responses = vec![ create_final_assistant_message_sse_response("Warmup")?, create_final_assistant_message_sse_response("First turn")?, create_final_assistant_message_sse_response("Second turn")?, ]; let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; let hook_script_path = codex_home.path().join("user_prompt_submit_hook.py"); let hook_log_path = codex_home.path().join("user_prompt_submit_hook_log.jsonl"); std::fs::write( &hook_script_path, format!( r#"import jsonfrom pathlib import Pathimport syspayload = json.load(sys.stdin)with Path(r"{hook_log_path}").open("a", encoding="utf-8") as handle: handle.write(json.dumps(payload) + "\n")"#, hook_log_path = hook_log_path.display(), ), )?; std::fs::write( codex_home.path().join("config.toml"), format!( r#"model = "mock-model"approval_policy = "never"sandbox_mode = "read-only"model_provider = "mock_provider"[model_providers.mock_provider]name = "Mock provider for test"base_url = "{server_uri}/v1"wire_api = "responses"request_max_retries = 0stream_max_retries = 0[hooks][[hooks.UserPromptSubmit]][[hooks.UserPromptSubmit.hooks]]type = "command"command = "python3 {hook_script_path}""#, server_uri = server.uri(), hook_script_path = hook_script_path.display(), ), )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let hook_list_id = mcp .send_hooks_list_request(HooksListParams { cwds: vec![codex_home.path().to_path_buf()], }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), ) .await??; let HooksListResponse { data } = to_response(response)?; let hook = &data[0].hooks[0]; assert_eq!(hook.enabled, true); let write_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { edits: vec![ConfigEdit { key_path: "hooks.state".to_string(), value: serde_json::json!({ hook.key.clone(): { "trusted_hash": hook.current_hash.clone() } }), 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 thread_start_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), ) .await??; let ThreadStartResponse { thread, .. } = to_response(response)?; let first_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), client_user_message_id: None, input: vec![V2UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), ) .await??; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; assert_eq!( std::fs::read_to_string(&hook_log_path)? .lines() .filter(|line| !line.is_empty()) .count(), 1 ); let write_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { edits: vec![ConfigEdit { key_path: "hooks.state".to_string(), 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( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(write_id)), ) .await??; let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; let second_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, client_user_message_id: None, input: vec![V2UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), ) .await??; timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; assert_eq!( std::fs::read_to_string(&hook_log_path)? .lines() .filter(|line| !line.is_empty()) .count(), 1 ); Ok(())}