mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why Admins need persistent defaults for the model, reasoning effort, and service tier shown when the Desktop App creates a new thread. These are initialization defaults rather than runtime constraints: the App should use them to initialize its draft while still allowing a user to make an explicit selection. The app-server therefore needs to expose the managed values before thread creation without changing `thread/start` behavior for other clients. ## What changed - Parse `model`, `model_reasoning_effort`, and `service_tier` from `[models.new_thread]` in `requirements.toml`. - Compose the `models` requirements through the existing requirements-layer precedence rules. - Expose the resolved values through `configRequirements/read` as `requirements.models.newThread`. - Add the corresponding app-server protocol types and regenerate the JSON and TypeScript schema fixtures. - Document the new `configRequirements/read` fields in the app-server README. ## Scope This PR is data plumbing only. It does not apply these values during `thread/start` and does not change thread creation for existing app-server clients, resumed or forked sessions, internal or subagent sessions, `codex exec`, or the TUI. A companion Desktop App change owns draft initialization, sends the effective settings for ordinary and prewarmed starts, and preserves explicit user changes. ## Validation - Requirements deserialization coverage for `[models.new_thread]` - Requirements-layer precedence coverage - App-server API mapping coverage - `configRequirements/read` integration coverage - Regenerated app-server JSON and TypeScript schema fixtures
331 lines
11 KiB
Rust
331 lines
11 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.
|
|
//! - `permissions.filesystem.deny_read` is a high-priority-first union across
|
|
//! layers.
|
|
|
|
use crate::ConfigRequirementsToml;
|
|
use crate::ConfigRequirementsWithSources;
|
|
use crate::RequirementSource;
|
|
use crate::Sourced;
|
|
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::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)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(super) 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 {
|
|
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);
|
|
let mut rules = None;
|
|
let mut hooks = HookMergeState::new(hook_directory_field);
|
|
let mut hooks_output = None;
|
|
let mut deny_read = DenyReadMergeState::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);
|
|
}
|
|
output.rules = rules;
|
|
output.hooks = hooks_output;
|
|
deny_read.apply_to(&mut output.permissions);
|
|
|
|
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 {
|
|
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_appshots,
|
|
allow_remote_control,
|
|
computer_use,
|
|
windows,
|
|
feature_requirements,
|
|
hooks: _,
|
|
mcp_servers,
|
|
plugins,
|
|
marketplaces,
|
|
apps,
|
|
rules: _,
|
|
enforce_residency,
|
|
network,
|
|
permissions,
|
|
models,
|
|
guardian_policy_config,
|
|
} = requirements;
|
|
|
|
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_appshots, &["allow_appshots"]);
|
|
set_sourced!(allow_remote_control, &["allow_remote_control"]);
|
|
set_sourced!(computer_use, &["computer_use"]);
|
|
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, &["experimental_network"]);
|
|
set_sourced!(permissions, &["permissions"]);
|
|
set_sourced!(models, &["models"]);
|
|
|
|
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"]),
|
|
));
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
if !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;
|