config: add repeatable UserInstructions declarations

This commit is contained in:
Adam Perry
2026-07-07 00:56:13 +00:00
parent 935c40c5be
commit e9e1a2922c
13 changed files with 248 additions and 16 deletions
@@ -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"
@@ -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"
@@ -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"
@@ -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<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array<ConfiguredHookMatcherGroup>, PermissionRequest: Array<ConfiguredHookMatcherGroup>, PostToolUse: Array<ConfiguredHookMatcherGroup>, PreCompact: Array<ConfiguredHookMatcherGroup>, PostCompact: Array<ConfiguredHookMatcherGroup>, SessionStart: Array<ConfiguredHookMatcherGroup>, UserInstructions: Array<ConfiguredHookHandler>, UserPromptSubmit: Array<ConfiguredHookMatcherGroup>, SubagentStart: Array<ConfiguredHookMatcherGroup>, SubagentStop: Array<ConfiguredHookMatcherGroup>, Stop: Array<ConfiguredHookMatcherGroup>, };
@@ -440,6 +440,9 @@ pub struct ManagedHooksRequirements {
#[serde(rename = "SessionStart")]
#[ts(rename = "SessionStart")]
pub session_start: Vec<ConfiguredHookMatcherGroup>,
#[serde(rename = "UserInstructions")]
#[ts(rename = "UserInstructions")]
pub user_instructions: Vec<ConfiguredHookHandler>,
#[serde(rename = "UserPromptSubmit")]
#[ts(rename = "UserPromptSubmit")]
pub user_prompt_submit: Vec<ConfiguredHookMatcherGroup>,
@@ -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<String>) ->
#[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 {
+27 -7
View File
@@ -46,6 +46,10 @@ pub struct HookEventsToml {
pub post_compact: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
// UserInstructions is unconditional, so it stores handlers directly
// instead of wrapping them in matcher groups.
#[serde(rename = "UserInstructions", default)]
pub user_instructions: Vec<HookHandlerConfig>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<MatcherGroup>,
#[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::<usize>()
+ user_instructions.len()
}
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 10] {
pub fn into_matcher_groups(self) -> [(HookEventName, Vec<MatcherGroup>); 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),
+62
View File
@@ -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::<HookEventsToml>(
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(
@@ -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);
@@ -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.
@@ -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(
+2 -2
View File
@@ -118,7 +118,7 @@ enum RawPluginManifestMcpServers {
enum RawPluginManifestHooks {
Path(String),
Paths(Vec<String>),
Inline(HooksFile),
Inline(Box<HooksFile>),
InlineList(Vec<HooksFile>),
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())
+7
View File
@@ -1310,6 +1310,13 @@
},
"type": "array"
},
"UserInstructions": {
"default": [],
"items": {
"$ref": "#/definitions/HookHandlerConfig"
},
"type": "array"
},
"UserPromptSubmit": {
"default": [],
"items": {