codex: support hooks in config.toml and requirements.toml (#18893)

## Summary

Support the existing hooks schema in inline TOML so hooks can be
configured from both `config.toml` and enterprise-managed
`requirements.toml` without requiring a separate `hooks.json` payload.

This gives enterprise admins a way to ship managed hook policy through
the existing requirements channel while still leaving script delivery to
MDM or other device-management tooling, and it keeps `hooks.json`
working unchanged for existing users.

This also lays the groundwork for follow-on managed filtering work such
as #15937, while continuing to respect project trust gating from #14718.
It does **not** implement `allow_managed_hooks_only` itself.

NOTE: yes, it's a bit unfortunate that the toml isn't formatted as
closely as normal to our default styling. This is because we're trying
to stay compatible with the spec for plugins/hooks that we'll need to
support & the main usecase here is embedding into requirements.toml

## What changed

- moved the shared hook serde model out of `codex-rs/hooks` into
`codex-rs/config` so the same schema can power `hooks.json`, inline
`config.toml` hooks, and managed `requirements.toml` hooks
- added `hooks` support to both `ConfigToml` and
`ConfigRequirementsToml`, including requirements-side `managed_dir` /
`windows_managed_dir`
- treated requirements-managed hooks as one constrained value via
`Constrained`, so managed hook policy is merged atomically and cannot
drift across requirement sources
- updated hook discovery to load requirements-managed hooks first, then
per-layer `hooks.json`, then per-layer inline TOML hooks, with a warning
when a single layer defines both representations
- threaded managed hook metadata through discovered handlers and exposed
requirements hooks in app-server responses, generated schemas, and
`/debug-config`
- added hook/config coverage in `codex-rs/config`, `codex-rs/hooks`,
`codex-rs/core/src/config_loader/tests.rs`, and
`codex-rs/core/tests/suite/hooks.rs`

## Testing

- `cargo test -p codex-config`
- `cargo test -p codex-hooks`
- `cargo test -p codex-app-server config_api`

## Documentation

Companion updates are needed in the developers website repo for:

- the hooks guide
- the config reference, sample, basic, and advanced pages
- the enterprise managed configuration guide

---------

Co-authored-by: Michael Bolin <mbolin@openai.com>
This commit is contained in:
Andrei Eternal
2026-04-22 21:20:09 -07:00
committed by GitHub
parent 9955eacd22
commit 2b2de3f38b
35 changed files with 2464 additions and 270 deletions

View File

@@ -12,9 +12,12 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ConfiguredHookHandler;
use codex_app_server_protocol::ConfiguredHookMatcherGroup;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ManagedHooksRequirements;
use codex_app_server_protocol::NetworkDomainPermission;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::NetworkUnixSocketPermission;
@@ -22,6 +25,10 @@ use codex_app_server_protocol::SandboxMode;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config_loader::ConfigRequirementsToml;
use codex_core::config_loader::HookEventsToml;
use codex_core::config_loader::HookHandlerConfig as CoreHookHandlerConfig;
use codex_core::config_loader::ManagedHooksRequirementsToml;
use codex_core::config_loader::MatcherGroup as CoreMatcherGroup;
use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement;
use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::plugins::PluginId;
@@ -290,6 +297,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
feature_requirements: requirements
.feature_requirements
.map(|requirements| requirements.entries),
hooks: requirements.hooks.map(map_hooks_requirements_to_api),
enforce_residency: requirements
.enforce_residency
.map(map_residency_requirement_to_api),
@@ -297,6 +305,71 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
}
}
fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements {
let ManagedHooksRequirementsToml {
managed_dir,
windows_managed_dir,
hooks,
} = hooks;
let HookEventsToml {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = hooks;
ManagedHooksRequirements {
managed_dir,
windows_managed_dir,
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
permission_request: map_hook_matcher_groups_to_api(permission_request),
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
session_start: map_hook_matcher_groups_to_api(session_start),
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
stop: map_hook_matcher_groups_to_api(stop),
}
}
fn map_hook_matcher_groups_to_api(
groups: Vec<CoreMatcherGroup>,
) -> Vec<ConfiguredHookMatcherGroup> {
groups
.into_iter()
.map(map_hook_matcher_group_to_api)
.collect()
}
fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup {
ConfiguredHookMatcherGroup {
matcher: group.matcher,
hooks: group
.hooks
.into_iter()
.map(map_hook_handler_to_api)
.collect(),
}
}
fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler {
match handler {
CoreHookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => ConfiguredHookHandler::Command {
command,
timeout_sec,
r#async,
status_message,
},
CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {},
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
}
}
fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option<SandboxMode> {
match mode {
CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly),
@@ -476,6 +549,22 @@ mod tests {
("personality".to_string(), true),
]),
}),
hooks: Some(ManagedHooksRequirementsToml {
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
hooks: HookEventsToml {
pre_tool_use: vec![CoreMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![CoreHookHandlerConfig::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}),
mcp_servers: None,
apps: None,
rules: None,
@@ -542,6 +631,27 @@ mod tests {
("personality".to_string(), true),
])),
);
assert_eq!(
mapped.hooks,
Some(ManagedHooksRequirements {
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
pre_tool_use: vec![ConfiguredHookMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![ConfiguredHookHandler::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
permission_request: Vec::new(),
post_tool_use: Vec::new(),
session_start: Vec::new(),
user_prompt_submit: Vec::new(),
stop: Vec::new(),
}),
);
assert_eq!(
mapped.enforce_residency,
Some(codex_app_server_protocol::ResidencyRequirement::Us),
@@ -582,6 +692,7 @@ mod tests {
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,
@@ -641,6 +752,7 @@ mod tests {
allowed_web_search_modes: Some(Vec::new()),
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
apps: None,
rules: None,

View File

@@ -48,6 +48,7 @@ use opentelemetry_sdk::trace::SpanData;
use pretty_assertions::assert_eq;
use serial_test::serial;
use std::collections::BTreeMap;
use std::future::Future;
use std::path::Path;
use std::sync::Arc;
use std::sync::OnceLock;
@@ -291,6 +292,28 @@ fn build_test_processor(
(processor, outgoing_rx)
}
fn run_current_thread_test_with_stack<F>(name: &str, future: F) -> Result<()>
where
F: Future<Output = Result<()>> + Send + 'static,
{
const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024;
let handle = std::thread::Builder::new()
.name(name.to_string())
.stack_size(TEST_STACK_SIZE_BYTES)
.spawn(move || -> Result<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(Box::pin(future))
})?;
match handle.join() {
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!("{name} thread panicked")),
}
}
fn span_attr<'a>(span: &'a SpanData, key: &str) -> Option<&'a str> {
span.attributes
.iter()
@@ -568,83 +591,96 @@ where
spans.into_iter().skip(baseline_len).collect()
}
#[tokio::test(flavor = "current_thread")]
#[test]
#[serial(app_server_tracing)]
async fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> {
let mut harness = TracingHarness::new().await?;
fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Result<()> {
run_current_thread_test_with_stack(
"thread_start_jsonrpc_span_exports_server_span_and_parents_children",
async {
let mut harness = TracingHarness::new().await?;
let RemoteTrace {
trace_id: remote_trace_id,
parent_span_id: remote_parent_span_id,
context: remote_trace,
..
} = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022");
let RemoteTrace {
trace_id: remote_trace_id,
parent_span_id: remote_parent_span_id,
context: remote_trace,
..
} = RemoteTrace::new("00000000000000000000000000000011", "0000000000000022");
let _: ThreadStartResponse = harness
.start_thread(/*request_id*/ 20_002, /*trace*/ None)
.await;
let untraced_spans = wait_for_exported_spans(harness.tracing, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("thread/start")
})
})
.await;
let untraced_server_span = find_rpc_span_with_trace(
&untraced_spans,
SpanKind::Server,
"thread/start",
untraced_spans
.iter()
.rev()
.find(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.system") == Some("jsonrpc")
&& span_attr(span, "rpc.method") == Some("thread/start")
let _: ThreadStartResponse = harness
.start_thread(/*request_id*/ 20_002, /*trace*/ None)
.await;
let untraced_spans = wait_for_exported_spans(harness.tracing, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("thread/start")
})
})
.unwrap_or_else(|| {
panic!(
"missing latest thread/start server span; exported spans:\n{}",
format_spans(&untraced_spans)
)
.await;
let untraced_server_span = find_rpc_span_with_trace(
&untraced_spans,
SpanKind::Server,
"thread/start",
untraced_spans
.iter()
.rev()
.find(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.system") == Some("jsonrpc")
&& span_attr(span, "rpc.method") == Some("thread/start")
})
.unwrap_or_else(|| {
panic!(
"missing latest thread/start server span; exported spans:\n{}",
format_spans(&untraced_spans)
)
})
.span_context
.trace_id(),
);
assert_has_internal_descendant_at_min_depth(
&untraced_spans,
untraced_server_span,
/*min_depth*/ 1,
);
let baseline_len = untraced_spans.len();
let _: ThreadStartResponse = harness
.start_thread(/*request_id*/ 20_003, Some(remote_trace))
.await;
let spans = wait_for_new_exported_spans(harness.tracing, baseline_len, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("thread/start")
&& span.span_context.trace_id() == remote_trace_id
}) && spans.iter().any(|span| {
span.name.as_ref() == "app_server.thread_start.notify_started"
&& span.span_context.trace_id() == remote_trace_id
})
})
.span_context
.trace_id(),
);
assert_has_internal_descendant_at_min_depth(
&untraced_spans,
untraced_server_span,
/*min_depth*/ 1,
);
.await;
let baseline_len = untraced_spans.len();
let _: ThreadStartResponse = harness
.start_thread(/*request_id*/ 20_003, Some(remote_trace))
.await;
let spans = wait_for_new_exported_spans(harness.tracing, baseline_len, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("thread/start")
&& span.span_context.trace_id() == remote_trace_id
}) && spans.iter().any(|span| {
span.name.as_ref() == "app_server.thread_start.notify_started"
&& span.span_context.trace_id() == remote_trace_id
})
})
.await;
let server_request_span =
find_rpc_span_with_trace(&spans, SpanKind::Server, "thread/start", remote_trace_id);
assert_eq!(server_request_span.name.as_ref(), "thread/start");
assert_eq!(server_request_span.parent_span_id, remote_parent_span_id);
assert!(server_request_span.parent_span_is_remote);
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID);
assert_has_internal_descendant_at_min_depth(
&spans,
server_request_span,
/*min_depth*/ 1,
);
assert_has_internal_descendant_at_min_depth(
&spans,
server_request_span,
/*min_depth*/ 2,
);
harness.shutdown().await;
let server_request_span =
find_rpc_span_with_trace(&spans, SpanKind::Server, "thread/start", remote_trace_id);
assert_eq!(server_request_span.name.as_ref(), "thread/start");
assert_eq!(server_request_span.parent_span_id, remote_parent_span_id);
assert!(server_request_span.parent_span_is_remote);
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
assert_ne!(server_request_span.span_context.span_id(), SpanId::INVALID);
assert_has_internal_descendant_at_min_depth(&spans, server_request_span, /*min_depth*/ 1);
assert_has_internal_descendant_at_min_depth(&spans, server_request_span, /*min_depth*/ 2);
harness.shutdown().await;
Ok(())
Ok(())
},
)
}
#[tokio::test(flavor = "current_thread")]