diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bf3563ed5e..6557305edf 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11728,6 +11728,16 @@ }, "type": "array" }, + "UserInstructions": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ConfiguredHookHandler" + }, + { + "type": "null" + } + ] + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 12b959b184..72c0e85c52 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8132,6 +8132,16 @@ }, "type": "array" }, + "UserInstructions": { + "anyOf": [ + { + "$ref": "#/definitions/ConfiguredHookHandler" + }, + { + "type": "null" + } + ] + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 4e35f7aead..3ccc3dca59 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -339,6 +339,16 @@ }, "type": "array" }, + "UserInstructions": { + "anyOf": [ + { + "$ref": "#/definitions/ConfiguredHookHandler" + }, + { + "type": "null" + } + ] + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts index 1143bd017f..fe9256e6b3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ConfiguredHookHandler } from "./ConfiguredHookHandler"; import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; -export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, }; +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserInstructions: ConfiguredHookHandler | null, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index f615b25d44..d518223855 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -440,6 +440,9 @@ pub struct ManagedHooksRequirements { #[serde(rename = "SessionStart")] #[ts(rename = "SessionStart")] pub session_start: Vec, + #[serde(rename = "UserInstructions")] + #[ts(rename = "UserInstructions")] + pub user_instructions: Option, #[serde(rename = "UserPromptSubmit")] #[ts(rename = "UserPromptSubmit")] pub user_prompt_submit: Vec, 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 b6dbb30b08..85ce63773a 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -408,6 +408,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed pre_compact, post_compact, session_start, + user_instructions, user_prompt_submit, subagent_start, subagent_stop, @@ -423,6 +424,7 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed pre_compact: map_hook_matcher_groups_to_api(pre_compact), post_compact: map_hook_matcher_groups_to_api(post_compact), session_start: map_hook_matcher_groups_to_api(session_start), + user_instructions: user_instructions.map(map_hook_handler_to_api), user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), subagent_start: map_hook_matcher_groups_to_api(subagent_start), subagent_stop: map_hook_matcher_groups_to_api(subagent_stop), @@ -574,9 +576,14 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::map_requirements_toml_to_api; + use codex_app_server_protocol::ConfiguredHookHandler; + use codex_app_server_protocol::ManagedHooksRequirements; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_config::ComputerUseRequirementsToml; use codex_config::ConfigRequirementsToml; + use codex_config::HookEventsToml; + use codex_config::HookHandlerConfig; + use codex_config::ManagedHooksRequirementsToml; use codex_config::ModelsRequirementsToml; use codex_config::NewThreadModelDefaultsToml; use codex_config::WindowsRequirementsToml; @@ -595,6 +602,51 @@ mod tests { assert_eq!(mapped.hooks, None); } + #[test] + fn requirements_api_includes_user_instructions_hooks() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + hooks: Some(ManagedHooksRequirementsToml { + hooks: HookEventsToml { + user_instructions: Some(HookHandlerConfig::Command { + command: "python3 instructions.py".to_string(), + command_windows: None, + timeout_sec: Some(10), + r#async: false, + status_message: Some("loading instructions".to_string()), + }), + ..HookEventsToml::default() + }, + ..ManagedHooksRequirementsToml::default() + }), + ..ConfigRequirementsToml::default() + }); + + assert_eq!( + mapped.hooks, + Some(ManagedHooksRequirements { + managed_dir: None, + windows_managed_dir: None, + pre_tool_use: Vec::new(), + permission_request: Vec::new(), + post_tool_use: Vec::new(), + pre_compact: Vec::new(), + post_compact: Vec::new(), + session_start: Vec::new(), + user_instructions: Some(ConfiguredHookHandler::Command { + command: "python3 instructions.py".to_string(), + command_windows: None, + timeout_sec: Some(10), + r#async: false, + status_message: Some("loading instructions".to_string()), + }), + user_prompt_submit: Vec::new(), + subagent_start: Vec::new(), + subagent_stop: Vec::new(), + stop: Vec::new(), + }) + ); + } + #[test] fn requirements_api_includes_permission_default_and_allowlist() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 8e18cb935a..18410b2af0 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -46,6 +46,14 @@ pub struct HookEventsToml { pub post_compact: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, + // A single handler owns the durable user-instruction snapshot, so multiple + // handlers would have ambiguous replacement semantics. + #[serde( + rename = "UserInstructions", + default, + skip_serializing_if = "Option::is_none" + )] + pub user_instructions: Option, #[serde(rename = "UserPromptSubmit", default)] pub user_prompt_submit: Vec, #[serde(rename = "SubagentStart", default)] @@ -65,6 +73,7 @@ impl HookEventsToml { pre_compact, post_compact, session_start, + user_instructions, user_prompt_submit, subagent_start, subagent_stop, @@ -76,6 +85,7 @@ impl HookEventsToml { && pre_compact.is_empty() && post_compact.is_empty() && session_start.is_empty() + && user_instructions.is_none() && user_prompt_submit.is_empty() && subagent_start.is_empty() && subagent_stop.is_empty() @@ -90,12 +100,13 @@ impl HookEventsToml { pre_compact, post_compact, session_start, + user_instructions, user_prompt_submit, subagent_start, subagent_stop, stop, } = self; - [ + let matcher_group_handlers = [ pre_tool_use, permission_request, post_tool_use, @@ -106,14 +117,16 @@ impl HookEventsToml { subagent_start, subagent_stop, stop, - ] - .into_iter() - .flatten() - .map(|group| group.hooks.len()) - .sum() + ]; + matcher_group_handlers + .into_iter() + .flatten() + .map(|group| group.hooks.len()) + .sum::() + + user_instructions.iter().count() } - pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 10] { + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 11] { [ (HookEventName::PreToolUse, self.pre_tool_use), (HookEventName::PermissionRequest, self.permission_request), @@ -121,6 +134,16 @@ impl HookEventsToml { (HookEventName::PreCompact, self.pre_compact), (HookEventName::PostCompact, self.post_compact), (HookEventName::SessionStart, self.session_start), + ( + HookEventName::UserInstructions, + self.user_instructions + .map(|handler| MatcherGroup { + matcher: None, + hooks: vec![handler], + }) + .into_iter() + .collect(), + ), (HookEventName::UserPromptSubmit, self.user_prompt_submit), (HookEventName::SubagentStart, self.subagent_start), (HookEventName::SubagentStop, self.subagent_stop), diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 3d51840fc2..e4c28c198e 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -112,6 +112,53 @@ statusMessage = "checking" ); } +#[test] +fn user_instructions_event_deserializes_from_toml() { + let parsed: HookEventsToml = toml::from_str( + r#" +[UserInstructions] +type = "command" +command = "python3 /tmp/user_instructions.py" +"#, + ) + .expect("UserInstructions hook TOML should deserialize"); + + assert_eq!( + parsed, + HookEventsToml { + user_instructions: Some(HookHandlerConfig::Command { + command: "python3 /tmp/user_instructions.py".to_string(), + command_windows: None, + timeout_sec: None, + r#async: false, + status_message: None, + }), + ..Default::default() + } + ); +} + +#[test] +fn user_instructions_event_rejects_multiple_hooks() { + let error = toml::from_str::( + r#" +[[UserInstructions]] +type = "command" +command = "python3 /tmp/first.py" + +[[UserInstructions]] +type = "command" +command = "python3 /tmp/second.py" +"#, + ) + .expect_err("UserInstructions must be a singleton"); + + assert!( + error.to_string().contains("UserInstructions"), + "unexpected parse error: {error}" + ); +} + #[test] fn hooks_toml_deserializes_inline_events_and_state_map() { let parsed: HooksToml = toml::from_str( diff --git a/codex-rs/config/src/requirements_layers/hooks.rs b/codex-rs/config/src/requirements_layers/hooks.rs index 94aefd3285..0ea356299c 100644 --- a/codex-rs/config/src/requirements_layers/hooks.rs +++ b/codex-rs/config/src/requirements_layers/hooks.rs @@ -1,8 +1,9 @@ -//! Hook events are append-only across requirements layers. The managed hook -//! directory is different: only one directory is usable on a given platform, so -//! conflicting values for the active platform fail closed. The inactive platform -//! field is first-filled to allow the same layer stack to carry OS-specific -//! directories. +//! Hook events are append-only across requirements layers, except for +//! `UserInstructions`, which is a singleton and fails closed on conflicts. The +//! managed hook directory is also a singleton: only one directory is usable on a +//! given platform, so conflicting values for the active platform fail closed. +//! The inactive platform field is first-filled to allow the same layer stack to +//! carry OS-specific directories. use crate::HookEventsToml; use crate::ManagedHooksRequirementsToml; @@ -48,6 +49,7 @@ impl HookDirectoryField { pub(super) struct HookMergeState { directory_field: HookDirectoryField, dir_sources: BTreeMap, + user_instructions_source: Option, } impl HookMergeState { @@ -55,6 +57,7 @@ impl HookMergeState { Self { directory_field, dir_sources: BTreeMap::new(), + user_instructions_source: None, } } @@ -78,6 +81,9 @@ impl HookMergeState { &incoming.windows_managed_dir, source, ); + if incoming.hooks.user_instructions.is_some() { + self.user_instructions_source = Some(source.clone()); + } *target = Some(Sourced::new(incoming, source.clone())); return Ok(()); }; @@ -99,6 +105,11 @@ impl HookMergeState { incoming_inactive_dir, source, ); + changed |= self.merge_user_instructions( + &mut existing.value.hooks.user_instructions, + incoming.hooks.user_instructions.take(), + source, + )?; changed |= append_hook_events(&mut existing.value.hooks, incoming.hooks); if changed { merge_output_source(&mut existing.source, source); @@ -178,6 +189,39 @@ impl HookMergeState { false } } + + fn merge_user_instructions( + &mut self, + existing: &mut Option, + incoming: Option, + incoming_source: &RequirementSource, + ) -> Result { + let Some(incoming) = incoming else { + return Ok(false); + }; + + match existing { + Some(existing_value) if existing_value != &incoming => { + let existing_source = self + .user_instructions_source + .clone() + .unwrap_or_else(|| incoming_source.clone()); + Err(composition_conflict( + "hooks.UserInstructions".to_string(), + existing_source, + incoming_source.clone(), + "multiple requirements layers configure different UserInstructions hooks" + .to_string(), + )) + } + Some(_) => Ok(false), + None => { + *existing = Some(incoming); + self.user_instructions_source = Some(incoming_source.clone()); + Ok(true) + } + } + } } fn take_hook_dir( @@ -210,11 +254,13 @@ fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) - pre_compact, post_compact, session_start, + user_instructions, user_prompt_submit, subagent_start, subagent_stop, stop, } = incoming; + debug_assert!(user_instructions.is_none()); let mut changed = false; changed |= append_vec(&mut existing.pre_tool_use, pre_tool_use); diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index 9a42650bf3..4de7b8b9ba 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -9,7 +9,7 @@ //! - `remote_sandbox_config` is evaluated within each layer before merging. //! - `rules.prefix_rules` append high-priority rules first. //! - `hooks` append high-priority event groups first while failing closed on -//! active managed-dir conflicts. +//! active managed-dir and `UserInstructions` conflicts. //! - `permissions.filesystem.deny_read` is a high-priority-first union across //! layers. diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 92e6bd8b46..fed949276b 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -802,6 +802,73 @@ managed_dir = "/managed/high" assert!(err.to_string().contains("Low (req_low)")); } +#[test] +fn user_instructions_hook_is_a_singleton_across_requirements_layers() { + let err = compose_with_hook_directory_field( + vec![ + layer( + "req_low", + "Low", + r#" +[hooks.UserInstructions] +type = "command" +command = "low" +"#, + ), + layer( + "req_high", + "High", + r#" +[hooks.UserInstructions] +type = "command" +command = "high" +"#, + ), + ], + HookDirectoryField::ManagedDir, + ) + .expect_err("conflicting UserInstructions hooks should fail closed"); + + assert!(err.to_string().contains("hooks.UserInstructions")); + assert!(err.to_string().contains("High (req_high)")); + assert!(err.to_string().contains("Low (req_low)")); + + let composed = compose_with_hook_directory_field( + vec![ + layer( + "req_low", + "Low", + r#" +[hooks.UserInstructions] +type = "command" +command = "same" +"#, + ), + layer( + "req_high", + "High", + r#" +[hooks.UserInstructions] +type = "command" +command = "same" +"#, + ), + ], + HookDirectoryField::ManagedDir, + ) + .expect("matching UserInstructions hooks should compose") + .expect("requirements present"); + + assert_eq!( + composed + .hooks + .expect("hooks requirements") + .hooks + .handler_count(), + 1 + ); +} + #[test] fn active_windows_managed_dir_conflicts_fail_closed() { let err = compose_with_hook_directory_field( diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 0edebe3699..13dd6696db 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -118,7 +118,7 @@ enum RawPluginManifestMcpServers { enum RawPluginManifestHooks { Path(String), Paths(Vec), - Inline(HooksFile), + Inline(Box), InlineList(Vec), Invalid(JsonValue), } @@ -285,7 +285,7 @@ fn resolve_manifest_hooks( } RawPluginManifestHooks::Inline(hooks) => { Some(codex_plugin::manifest::PluginManifestHooks::Inline(vec![ - hooks, + *hooks, ])) } RawPluginManifestHooks::InlineList(hooks) => (!hooks.is_empty()) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 876180dd7e..6109e5e65d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1310,6 +1310,9 @@ }, "type": "array" }, + "UserInstructions": { + "$ref": "#/definitions/HookHandlerConfig" + }, "UserPromptSubmit": { "default": [], "items": {