mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
## Why Managed OpenTelemetry requirements could not specify span attributes or tracestate metadata. ## What changed - Accept `otel.span_attributes` and `otel.tracestate` in requirements, merge them by attribute or member field, and expose them through app-server configuration and requirements reads. - Protect required entries and their ancestors from configuration writes while preserving unmanaged sibling values and their origins. For example, requiring `otel.tracestate.vendor.tenant` leaves `vendor.region` writable. Empty metadata maps impose no requirements. - Select Rustls when configuring an OTLP HTTP client certificate and private key so PEM client identities are accepted. ## Testing Add coverage for metadata precedence, effective configuration, field-level write protection, and preservation of unmanaged values. Add blocking and asynchronous HTTP client construction tests using generated PEM certificates and keys. GitOrigin-RevId: ada6000cc5e575b9a33451ec4aa58b6686dc20f7
425 lines
15 KiB
Rust
425 lines
15 KiB
Rust
//! Requirements layers are composed in the same order as config layers: lowest
|
|
//! precedence first, highest precedence last. Most fields use the same
|
|
//! TOML-level merge policy as config: lower-priority layers provide defaults,
|
|
//! and higher-priority layers override scalar/list values while recursively
|
|
//! extending tables.
|
|
//!
|
|
//! A few fields carry domain-specific meaning that raw TOML replacement would
|
|
//! break:
|
|
//! - `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.
|
|
//! - `otel` replaces exact managed values, treats exporters atomically, and
|
|
//! merges span attributes and fields within each tracestate member by key.
|
|
//! - `permissions.filesystem.deny_read` is a high-priority-first union across
|
|
//! layers.
|
|
//! - `auto_review.required_on_models` is a high-priority-first union across layers.
|
|
|
|
use crate::ConfigRequirementsToml;
|
|
use crate::ConfigRequirementsWithSources;
|
|
use crate::RequirementSource;
|
|
use crate::Sourced;
|
|
use crate::config_requirements::merge_otel_requirements;
|
|
use crate::merge::merge_toml_values;
|
|
use std::cell::OnceCell;
|
|
use std::io;
|
|
use thiserror::Error;
|
|
use toml::Value as TomlValue;
|
|
|
|
use super::hooks::HookDirectoryField;
|
|
use super::hooks::HookMergeState;
|
|
use super::layer::ComposableRequirementsLayer;
|
|
use super::layer::RequirementsLayerEntry;
|
|
use super::models::AutoReviewModelsMergeState;
|
|
use super::permissions::DenyReadMergeState;
|
|
|
|
#[derive(Debug, Error, PartialEq, Eq)]
|
|
pub enum RequirementsCompositionError {
|
|
#[error("failed to parse requirements layer {layer_source}: {message}")]
|
|
Parse {
|
|
layer_source: RequirementSource,
|
|
message: String,
|
|
},
|
|
#[error("failed to parse merged requirements: {message}")]
|
|
ComposedParse { message: String },
|
|
#[error(
|
|
"failed to compose requirements field `{field}` between {existing_source} and {incoming_source}: {message}"
|
|
)]
|
|
Conflict {
|
|
field: String,
|
|
existing_source: RequirementSource,
|
|
incoming_source: RequirementSource,
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
impl From<RequirementsCompositionError> for io::Error {
|
|
fn from(error: RequirementsCompositionError) -> Self {
|
|
io::Error::new(io::ErrorKind::InvalidData, error)
|
|
}
|
|
}
|
|
|
|
pub fn compose_requirements(
|
|
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
|
|
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
|
|
compose_requirements_with_hostname_resolver(layers, crate::host_name)
|
|
}
|
|
|
|
/// Composes requirements using an explicitly supplied execution-host hostname.
|
|
pub fn compose_requirements_for_hostname(
|
|
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
|
|
hostname: Option<&str>,
|
|
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
|
|
let hostname = hostname.map(str::to_string);
|
|
compose_requirements_with_hostname_resolver_and_hook_directory(
|
|
layers,
|
|
move || hostname.clone(),
|
|
HookDirectoryField::current_platform(),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) fn compose_requirements_for_hostname_and_hook_directory(
|
|
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
|
|
hostname: Option<&str>,
|
|
hook_directory_field: HookDirectoryField,
|
|
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
|
|
let hostname = hostname.map(str::to_string);
|
|
compose_requirements_with_hostname_resolver_and_hook_directory(
|
|
layers,
|
|
move || hostname.clone(),
|
|
hook_directory_field,
|
|
)
|
|
}
|
|
|
|
fn compose_requirements_with_hostname_resolver(
|
|
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
|
|
hostname_resolver: impl Fn() -> Option<String>,
|
|
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
|
|
compose_requirements_with_hostname_resolver_and_hook_directory(
|
|
layers,
|
|
hostname_resolver,
|
|
HookDirectoryField::current_platform(),
|
|
)
|
|
}
|
|
|
|
fn compose_requirements_with_hostname_resolver_and_hook_directory(
|
|
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
|
|
hostname_resolver: impl Fn() -> Option<String>,
|
|
hook_directory_field: HookDirectoryField,
|
|
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
|
|
// Evaluate every layer in this composition against the same hostname while
|
|
// keeping resolution lazy when no layer needs remote sandbox matching.
|
|
let hostname = OnceCell::new();
|
|
let cached_hostname_resolver = || hostname.get_or_init(&hostname_resolver).clone();
|
|
let mut stack = RequirementsLayerStack::new(hook_directory_field);
|
|
for layer in layers {
|
|
stack.add_layer(layer, &cached_hostname_resolver)?;
|
|
}
|
|
stack.compose()
|
|
}
|
|
|
|
struct RequirementsLayerStack {
|
|
layers: Vec<ComposableRequirementsLayer>,
|
|
hook_directory_field: HookDirectoryField,
|
|
}
|
|
|
|
impl RequirementsLayerStack {
|
|
fn new(hook_directory_field: HookDirectoryField) -> Self {
|
|
Self {
|
|
layers: Vec::new(),
|
|
hook_directory_field,
|
|
}
|
|
}
|
|
|
|
fn add_layer(
|
|
&mut self,
|
|
layer: RequirementsLayerEntry,
|
|
hostname_resolver: &dyn Fn() -> Option<String>,
|
|
) -> Result<(), RequirementsCompositionError> {
|
|
self.layers.push(ComposableRequirementsLayer::from_entry(
|
|
layer,
|
|
hostname_resolver,
|
|
)?);
|
|
Ok(())
|
|
}
|
|
|
|
fn compose(
|
|
self,
|
|
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
|
|
let Self {
|
|
layers,
|
|
hook_directory_field,
|
|
} = self;
|
|
|
|
let mut merged_toml = TomlValue::Table(toml::map::Map::new());
|
|
for layer in &layers {
|
|
// An endpoint and its credentials must come from the same requirements layer.
|
|
if layer.regular_toml.get("config_server").is_some()
|
|
&& let Some(table) = merged_toml.as_table_mut()
|
|
{
|
|
table.remove("config_server");
|
|
}
|
|
merge_toml_values(&mut merged_toml, &layer.regular_toml);
|
|
}
|
|
|
|
let requirements: ConfigRequirementsToml =
|
|
merged_toml.try_into().map_err(|err: toml::de::Error| {
|
|
RequirementsCompositionError::ComposedParse {
|
|
message: err.to_string(),
|
|
}
|
|
})?;
|
|
let mut output = ConfigRequirementsWithSources::default();
|
|
populate_merged_regular_fields_with_sources(&mut output, requirements, &layers);
|
|
// Merge OTEL requirements so higher-priority exact values replace
|
|
// lower-priority values while exporters stay atomic.
|
|
let mut otel = None;
|
|
let mut otel_sources = Vec::new();
|
|
for layer in &layers {
|
|
if let Some(incoming) = layer.domain_fields.otel.as_deref()
|
|
&& merge_otel_requirements(&mut otel, incoming)
|
|
{
|
|
otel_sources.push(layer.source.clone());
|
|
}
|
|
}
|
|
output.otel = otel.map(|value| {
|
|
Box::new(Sourced::new(
|
|
value,
|
|
RequirementSource::composite(otel_sources.into_iter().rev()),
|
|
))
|
|
});
|
|
let mut rules = None;
|
|
let mut hooks = HookMergeState::new(hook_directory_field);
|
|
let mut hooks_output = None;
|
|
let mut deny_read = DenyReadMergeState::default();
|
|
let mut auto_review_models = AutoReviewModelsMergeState::default();
|
|
// Regular TOML fields are folded low-to-high like config. These custom
|
|
// fields append or union values, so process them high-to-low to keep
|
|
// priority order visible in the output.
|
|
for layer in layers.iter().rev() {
|
|
let domain_fields = &layer.domain_fields;
|
|
super::rules::merge(&mut rules, domain_fields.rules.clone(), &layer.source);
|
|
hooks.merge(
|
|
&mut hooks_output,
|
|
domain_fields.hooks.clone(),
|
|
&layer.source,
|
|
)?;
|
|
deny_read.merge(domain_fields.permissions.clone(), &layer.source);
|
|
auto_review_models.merge(domain_fields.auto_review.clone(), &layer.source);
|
|
}
|
|
output.rules = rules;
|
|
output.hooks = hooks_output;
|
|
deny_read.apply_to(&mut output.permissions);
|
|
auto_review_models.apply_to(&mut output.auto_review);
|
|
|
|
let output_is_empty = output.clone().into_toml().is_empty();
|
|
Ok((!output_is_empty).then_some(output))
|
|
}
|
|
}
|
|
|
|
fn populate_merged_regular_fields_with_sources(
|
|
output: &mut ConfigRequirementsWithSources,
|
|
requirements: ConfigRequirementsToml,
|
|
layers: &[ComposableRequirementsLayer],
|
|
) {
|
|
macro_rules! set_sourced {
|
|
($field:ident, $keys:expr) => {
|
|
if let Some(value) = $field {
|
|
output.$field = Some(Sourced::new(
|
|
value,
|
|
source_for_top_level_keys(layers, $keys),
|
|
));
|
|
}
|
|
};
|
|
}
|
|
|
|
// Destructure without `..` so every new requirements field must choose
|
|
// whether it belongs in the regular TOML merge path or in a special merger.
|
|
let ConfigRequirementsToml {
|
|
config_server,
|
|
allowed_login_methods,
|
|
allowed_chatgpt_workspaces,
|
|
cli_auth_credentials_store,
|
|
chatgpt_base_url,
|
|
sqlite_home,
|
|
unsafe_enforce_sqlite_journal_mode,
|
|
log_dir,
|
|
model_catalog_json,
|
|
model_provider,
|
|
model_providers,
|
|
check_for_update_on_startup,
|
|
allow_login_shell,
|
|
feedback,
|
|
allowed_approval_policies,
|
|
allowed_approvals_reviewers,
|
|
allowed_sandbox_modes,
|
|
allowed_permission_profiles,
|
|
default_permissions,
|
|
remote_sandbox_config: _,
|
|
allowed_web_search_modes,
|
|
allow_managed_hooks_only,
|
|
allow_browser_and_computer_use,
|
|
allow_appshots,
|
|
allow_remote_control,
|
|
computer_use,
|
|
browser_use,
|
|
in_app_browser,
|
|
windows,
|
|
feature_requirements,
|
|
hooks: _,
|
|
mcp_servers,
|
|
plugins,
|
|
marketplaces,
|
|
otel: _,
|
|
apps,
|
|
rules: _,
|
|
enforce_residency,
|
|
network,
|
|
application,
|
|
permissions,
|
|
auto_review,
|
|
models,
|
|
additional_developer_instructions,
|
|
guardian_policy_config,
|
|
guardian_extra_policy,
|
|
} = requirements;
|
|
|
|
set_sourced!(config_server, &["config_server"]);
|
|
set_sourced!(allowed_login_methods, &["allowed_login_methods"]);
|
|
set_sourced!(allowed_chatgpt_workspaces, &["allowed_chatgpt_workspaces"]);
|
|
set_sourced!(cli_auth_credentials_store, &["cli_auth_credentials_store"]);
|
|
set_sourced!(chatgpt_base_url, &["chatgpt_base_url"]);
|
|
set_sourced!(sqlite_home, &["sqlite_home"]);
|
|
set_sourced!(
|
|
unsafe_enforce_sqlite_journal_mode,
|
|
&["unsafe_enforce_sqlite_journal_mode"]
|
|
);
|
|
set_sourced!(log_dir, &["log_dir"]);
|
|
set_sourced!(model_catalog_json, &["model_catalog_json"]);
|
|
set_sourced!(model_provider, &["model_provider"]);
|
|
set_sourced!(model_providers, &["model_providers"]);
|
|
set_sourced!(
|
|
check_for_update_on_startup,
|
|
&["check_for_update_on_startup"]
|
|
);
|
|
set_sourced!(allow_login_shell, &["allow_login_shell"]);
|
|
set_sourced!(feedback, &["feedback"]);
|
|
set_sourced!(allowed_approval_policies, &["allowed_approval_policies"]);
|
|
set_sourced!(
|
|
allowed_approvals_reviewers,
|
|
&["allowed_approvals_reviewers"]
|
|
);
|
|
set_sourced!(allowed_sandbox_modes, &["allowed_sandbox_modes"]);
|
|
set_sourced!(
|
|
allowed_permission_profiles,
|
|
&["allowed_permission_profiles"]
|
|
);
|
|
set_sourced!(default_permissions, &["default_permissions"]);
|
|
set_sourced!(allowed_web_search_modes, &["allowed_web_search_modes"]);
|
|
set_sourced!(allow_managed_hooks_only, &["allow_managed_hooks_only"]);
|
|
set_sourced!(
|
|
allow_browser_and_computer_use,
|
|
&["allow_browser_and_computer_use"]
|
|
);
|
|
set_sourced!(allow_appshots, &["allow_appshots"]);
|
|
set_sourced!(allow_remote_control, &["allow_remote_control"]);
|
|
set_sourced!(auto_review, &["auto_review"]);
|
|
set_sourced!(computer_use, &["computer_use"]);
|
|
set_sourced!(browser_use, &["browser_use"]);
|
|
set_sourced!(in_app_browser, &["in_app_browser"]);
|
|
set_sourced!(windows, &["windows"]);
|
|
set_sourced!(feature_requirements, &["features", "feature_requirements"]);
|
|
set_sourced!(mcp_servers, &["mcp_servers"]);
|
|
set_sourced!(plugins, &["plugins"]);
|
|
set_sourced!(marketplaces, &["marketplaces"]);
|
|
set_sourced!(apps, &["apps"]);
|
|
set_sourced!(enforce_residency, &["enforce_residency"]);
|
|
set_sourced!(network, &["network_proxy"]);
|
|
set_sourced!(application, &["application"]);
|
|
set_sourced!(permissions, &["permissions"]);
|
|
set_sourced!(models, &["models"]);
|
|
set_sourced!(
|
|
additional_developer_instructions,
|
|
&["additional_developer_instructions"]
|
|
);
|
|
|
|
if let Some(guardian_policy_config) =
|
|
guardian_policy_config.filter(|value| !value.trim().is_empty())
|
|
{
|
|
output.guardian_policy_config = Some(Sourced::new(
|
|
guardian_policy_config,
|
|
source_for_top_level_keys(layers, &["guardian_policy_config"]),
|
|
));
|
|
}
|
|
if let Some(guardian_extra_policy) =
|
|
guardian_extra_policy.filter(|value| !value.trim().is_empty())
|
|
{
|
|
output.guardian_extra_policy = Some(Sourced::new(
|
|
guardian_extra_policy,
|
|
source_for_top_level_keys(layers, &["guardian_extra_policy"]),
|
|
));
|
|
}
|
|
}
|
|
|
|
fn source_for_top_level_keys(
|
|
layers: &[ComposableRequirementsLayer],
|
|
keys: &[&str],
|
|
) -> RequirementSource {
|
|
let matching_layers = layers
|
|
.iter()
|
|
.filter_map(|layer| {
|
|
top_level_value_for_keys(&layer.regular_toml, keys).map(|value| (&layer.source, value))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let Some((winning_source, winning_value)) = matching_layers.last() else {
|
|
return RequirementSource::Unknown;
|
|
};
|
|
let winning_source = (*winning_source).clone();
|
|
|
|
// The config_server block is replaced as a whole, including its source attribution.
|
|
if keys == ["config_server"] || !winning_value.is_table() {
|
|
return winning_source;
|
|
}
|
|
|
|
let table_sources = matching_layers
|
|
.into_iter()
|
|
.rev()
|
|
.filter_map(|(source, value)| value.is_table().then_some(source.clone()))
|
|
.collect::<Vec<_>>();
|
|
if table_sources.len() > 1 {
|
|
RequirementSource::composite(table_sources)
|
|
} else {
|
|
winning_source
|
|
}
|
|
}
|
|
|
|
fn top_level_value_for_keys<'a>(value: &'a TomlValue, keys: &[&str]) -> Option<&'a TomlValue> {
|
|
let table = value.as_table()?;
|
|
keys.iter().find_map(|key| table.get(*key))
|
|
}
|
|
|
|
pub(super) fn merge_output_source(existing: &mut RequirementSource, incoming: &RequirementSource) {
|
|
if existing != incoming {
|
|
*existing = RequirementSource::composite([existing.clone(), incoming.clone()]);
|
|
}
|
|
}
|
|
|
|
pub(super) fn composition_conflict(
|
|
field: String,
|
|
existing_source: RequirementSource,
|
|
incoming_source: RequirementSource,
|
|
message: impl Into<String>,
|
|
) -> RequirementsCompositionError {
|
|
RequirementsCompositionError::Conflict {
|
|
field,
|
|
existing_source,
|
|
incoming_source,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "stack_tests.rs"]
|
|
mod tests;
|