config: add singleton UserInstructions declarations

This commit is contained in:
Adam Perry
2026-07-06 21:21:09 +00:00
parent 32d15fae79
commit 7df31eacac
13 changed files with 288 additions and 16 deletions
@@ -11728,6 +11728,16 @@
},
"type": "array"
},
"UserInstructions": {
"anyOf": [
{
"$ref": "#/definitions/v2/ConfiguredHookHandler"
},
{
"type": "null"
}
]
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/v2/ConfiguredHookMatcherGroup"
@@ -8132,6 +8132,16 @@
},
"type": "array"
},
"UserInstructions": {
"anyOf": [
{
"$ref": "#/definitions/ConfiguredHookHandler"
},
{
"type": "null"
}
]
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -339,6 +339,16 @@
},
"type": "array"
},
"UserInstructions": {
"anyOf": [
{
"$ref": "#/definitions/ConfiguredHookHandler"
},
{
"type": "null"
}
]
},
"UserPromptSubmit": {
"items": {
"$ref": "#/definitions/ConfiguredHookMatcherGroup"
@@ -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: ConfiguredHookHandler | null, 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: Option<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,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<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 +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 {
+30 -7
View File
@@ -46,6 +46,14 @@ pub struct HookEventsToml {
pub post_compact: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
// 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<HookHandlerConfig>,
#[serde(rename = "UserPromptSubmit", default)]
pub user_prompt_submit: Vec<MatcherGroup>,
#[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::<usize>()
+ user_instructions.iter().count()
}
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 +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),
+47
View File
@@ -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::<HookEventsToml>(
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(
@@ -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<HookDirectoryField, RequirementSource>,
user_instructions_source: Option<RequirementSource>,
}
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<crate::HookHandlerConfig>,
incoming: Option<crate::HookHandlerConfig>,
incoming_source: &RequirementSource,
) -> Result<bool, super::stack::RequirementsCompositionError> {
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);
@@ -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,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(
+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())
+3
View File
@@ -1310,6 +1310,9 @@
},
"type": "array"
},
"UserInstructions": {
"$ref": "#/definitions/HookHandlerConfig"
},
"UserPromptSubmit": {
"default": [],
"items": {