From e9e1a2922cfcc136f77fb4208b05f6712bc97ea4 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Thu, 25 Jun 2026 22:47:27 +0000 Subject: [PATCH] config: add repeatable UserInstructions declarations --- .../codex_app_server_protocol.schemas.json | 7 ++ .../codex_app_server_protocol.v2.schemas.json | 7 ++ .../v2/ConfigRequirementsReadResponse.json | 7 ++ .../typescript/v2/ManagedHooksRequirements.ts | 3 +- .../src/protocol/v2/config.rs | 3 + .../request_processors/config_processor.rs | 73 +++++++++++++++++++ codex-rs/config/src/hook_config.rs | 34 +++++++-- codex-rs/config/src/hooks_tests.rs | 62 ++++++++++++++++ .../config/src/requirements_layers/hooks.rs | 11 +-- .../config/src/requirements_layers/stack.rs | 2 +- .../src/requirements_layers/stack_tests.rs | 44 +++++++++++ codex-rs/core-plugins/src/manifest.rs | 4 +- codex-rs/core/config.schema.json | 7 ++ 13 files changed, 248 insertions(+), 16 deletions(-) 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..345464c547 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,12 @@ }, "type": "array" }, + "UserInstructions": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookHandler" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" @@ -11757,6 +11763,7 @@ "Stop", "SubagentStart", "SubagentStop", + "UserInstructions", "UserPromptSubmit" ], "type": "object" 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..0a44d7f935 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,12 @@ }, "type": "array" }, + "UserInstructions": { + "items": { + "$ref": "#/definitions/ConfiguredHookHandler" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -8161,6 +8167,7 @@ "Stop", "SubagentStart", "SubagentStop", + "UserInstructions", "UserPromptSubmit" ], "type": "object" 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..8429763260 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,12 @@ }, "type": "array" }, + "UserInstructions": { + "items": { + "$ref": "#/definitions/ConfiguredHookHandler" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -368,6 +374,7 @@ "Stop", "SubagentStart", "SubagentStop", + "UserInstructions", "UserPromptSubmit" ], "type": "object" 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..8e2b3b81bb 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: Array, 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..a610d21c5e 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: Vec, #[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..96f9eca300 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,10 @@ 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 + .into_iter() + .map(map_hook_handler_to_api) + .collect(), 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 +579,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 +605,69 @@ 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: vec![ + HookHandlerConfig::Command { + command: "python3 first.py".to_string(), + command_windows: None, + timeout_sec: Some(10), + r#async: false, + status_message: Some("loading first instructions".to_string()), + }, + HookHandlerConfig::Command { + command: "python3 second.py".to_string(), + command_windows: None, + timeout_sec: Some(20), + r#async: false, + status_message: Some("loading second 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: vec![ + ConfiguredHookHandler::Command { + command: "python3 first.py".to_string(), + command_windows: None, + timeout_sec: Some(10), + r#async: false, + status_message: Some("loading first instructions".to_string()), + }, + ConfiguredHookHandler::Command { + command: "python3 second.py".to_string(), + command_windows: None, + timeout_sec: Some(20), + r#async: false, + status_message: Some("loading second 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..41556daae5 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -46,6 +46,10 @@ pub struct HookEventsToml { pub post_compact: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, + // UserInstructions is unconditional, so it stores handlers directly + // instead of wrapping them in matcher groups. + #[serde(rename = "UserInstructions", default)] + pub user_instructions: Vec, #[serde(rename = "UserPromptSubmit", default)] pub user_prompt_submit: Vec, #[serde(rename = "SubagentStart", default)] @@ -65,6 +69,7 @@ impl HookEventsToml { pre_compact, post_compact, session_start, + user_instructions, user_prompt_submit, subagent_start, subagent_stop, @@ -76,6 +81,7 @@ impl HookEventsToml { && pre_compact.is_empty() && post_compact.is_empty() && session_start.is_empty() + && user_instructions.is_empty() && user_prompt_submit.is_empty() && subagent_start.is_empty() && subagent_stop.is_empty() @@ -90,12 +96,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 +113,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.len() } - 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 +130,17 @@ impl HookEventsToml { (HookEventName::PreCompact, self.pre_compact), (HookEventName::PostCompact, self.post_compact), (HookEventName::SessionStart, self.session_start), + ( + HookEventName::UserInstructions, + if self.user_instructions.is_empty() { + Vec::new() + } else { + vec![MatcherGroup { + matcher: None, + hooks: self.user_instructions, + }] + }, + ), (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..d732c9f60d 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -112,6 +112,68 @@ 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: vec![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_preserves_multiple_hooks() { + let parsed = toml::from_str::( + r#" +[[UserInstructions]] +type = "command" +command = "python3 /tmp/first.py" + +[[UserInstructions]] +type = "command" +command = "python3 /tmp/second.py" +"#, + ) + .expect("multiple UserInstructions hooks should deserialize"); + + assert_eq!( + parsed.user_instructions, + vec![ + HookHandlerConfig::Command { + command: "python3 /tmp/first.py".to_string(), + command_windows: None, + timeout_sec: None, + r#async: false, + status_message: None, + }, + HookHandlerConfig::Command { + command: "python3 /tmp/second.py".to_string(), + command_windows: None, + timeout_sec: None, + r#async: false, + status_message: None, + }, + ] + ); +} + #[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..9fe0ba6c75 100644 --- a/codex-rs/config/src/requirements_layers/hooks.rs +++ b/codex-rs/config/src/requirements_layers/hooks.rs @@ -1,8 +1,8 @@ //! 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. +//! directory is 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; @@ -210,12 +210,12 @@ 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; - let mut changed = false; changed |= append_vec(&mut existing.pre_tool_use, pre_tool_use); changed |= append_vec(&mut existing.permission_request, permission_request); @@ -223,6 +223,7 @@ fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) - changed |= append_vec(&mut existing.pre_compact, pre_compact); changed |= append_vec(&mut existing.post_compact, post_compact); changed |= append_vec(&mut existing.session_start, session_start); + changed |= append_vec(&mut existing.user_instructions, user_instructions); changed |= append_vec(&mut existing.user_prompt_submit, user_prompt_submit); changed |= append_vec(&mut existing.subagent_start, subagent_start); changed |= append_vec(&mut existing.subagent_stop, subagent_stop); 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..d3cea30281 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -802,6 +802,50 @@ managed_dir = "/managed/high" assert!(err.to_string().contains("Low (req_low)")); } +#[test] +fn user_instructions_hooks_append_across_requirements_layers() { + let composed = 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("UserInstructions hooks should compose") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +[[hooks.UserInstructions]] +type = "command" +command = "high" + +[[hooks.UserInstructions]] +type = "command" +command = "low" +"# + ) + ); +} + #[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..baf211e2ee 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1310,6 +1310,13 @@ }, "type": "array" }, + "UserInstructions": { + "default": [], + "items": { + "$ref": "#/definitions/HookHandlerConfig" + }, + "type": "array" + }, "UserPromptSubmit": { "default": [], "items": {