mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
Require managed configuration from the configured server (#56055)
## Why Local administrators need to require managed configuration from a specific customer-hosted server before a session can start. ## What changed - Add a local managed `config_server` requirement with an HTTPS base URL and optional OAuth settings. Validate endpoints and derive the bundle route as `codex/config/bundle` beneath the base URL. - Replace the entire `config_server` block when a higher-priority requirements layer supplies one, keeping the endpoint and OAuth settings together. Reject this setting in fetched policy. - Track the applied server through configuration snapshots, role overrides, and refreshes. Reject session startup and managed refreshes when the applied server does not match the requirement; user refreshes retain the session's managed configuration. ## Testing Add coverage for missing, mismatched, and matching server configuration at session startup, whole-block replacement, rejection of server settings in fetched policy, and preservation across role overrides and refreshes. GitOrigin-RevId: 51e8a1738dabdb117ae858120f6fb09ae2ff1261
This commit is contained in:
Generated
+1
@@ -2823,6 +2823,7 @@ dependencies = [
|
||||
"codex-features",
|
||||
"codex-file-system",
|
||||
"codex-git-utils",
|
||||
"codex-http-client",
|
||||
"codex-model-provider-info",
|
||||
"codex-network-proxy",
|
||||
"codex-protocol",
|
||||
|
||||
@@ -168,6 +168,7 @@ async fn mcp_refresh_rejects_superseded_app_server_policy(
|
||||
let home = tempfile::tempdir()?;
|
||||
let policy = CloudConfigBundlePolicy::default();
|
||||
let mut snapshot = CloudConfigBundleSnapshot {
|
||||
config_server: None,
|
||||
bundle: Ok(None),
|
||||
binding: None,
|
||||
};
|
||||
@@ -215,6 +216,7 @@ async fn mcp_refresh_rejects_superseded_app_server_policy(
|
||||
policy.observe_remote_bundle(&replacement);
|
||||
if status == codex_config::CloudConfigBundleBindingStatus::Stale {
|
||||
policy.publish_snapshot(&mut CloudConfigBundleSnapshot {
|
||||
config_server: None,
|
||||
bundle: Ok(Some(replacement)),
|
||||
binding: None,
|
||||
});
|
||||
|
||||
@@ -133,6 +133,7 @@ where
|
||||
self.latest_bundle
|
||||
.get_or_init(|| async {
|
||||
let mut snapshot = CloudConfigBundleSnapshot {
|
||||
config_server: None,
|
||||
bundle: self.load_startup_bundle_with_timeout().await,
|
||||
binding: None,
|
||||
};
|
||||
@@ -579,6 +580,7 @@ where
|
||||
let mut latest = latest.lock().await;
|
||||
if result.is_ok() {
|
||||
*latest = CloudConfigBundleSnapshot {
|
||||
config_server: None,
|
||||
bundle: result,
|
||||
binding: None,
|
||||
};
|
||||
|
||||
@@ -332,6 +332,7 @@ async fn retired_policy_discards_staged_cache_and_retains_previous_payload() {
|
||||
let loader = codex_config::CloudConfigBundleLoader::new(async { Ok(None) })
|
||||
.with_ema_policy_snapshots(service.policy.clone(), || async {
|
||||
CloudConfigBundleSnapshot {
|
||||
config_server: None,
|
||||
bundle: Ok(None),
|
||||
binding: None,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ codex-execpolicy = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
codex-file-system = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
codex-http-client = { workspace = true }
|
||||
codex-model-provider-info = { workspace = true }
|
||||
codex-network-proxy = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
|
||||
@@ -398,6 +398,7 @@ impl CloudConfigBundlePolicyChanges {
|
||||
pub struct CloudConfigBundleSnapshot {
|
||||
pub bundle: Result<Option<CloudConfigBundle>, CloudConfigBundleLoadError>,
|
||||
pub binding: Option<CloudConfigBundleBinding>,
|
||||
pub config_server: Option<crate::ConfigServer>,
|
||||
}
|
||||
|
||||
type CloudConfigBundleSnapshotGetter =
|
||||
@@ -408,6 +409,7 @@ pub struct CloudConfigBundleLoader {
|
||||
getter: Arc<CloudConfigBundleGetter>,
|
||||
snapshot_getter: Option<Arc<CloudConfigBundleSnapshotGetter>>,
|
||||
ema_policy: Option<CloudConfigBundlePolicy>,
|
||||
config_server: Option<crate::ConfigServer>,
|
||||
}
|
||||
|
||||
impl CloudConfigBundleLoader {
|
||||
@@ -433,6 +435,7 @@ impl CloudConfigBundleLoader {
|
||||
getter: Arc::new(move || getter().boxed()),
|
||||
snapshot_getter: None,
|
||||
ema_policy: None,
|
||||
config_server: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +443,7 @@ impl CloudConfigBundleLoader {
|
||||
pub fn from_snapshot(snapshot: CloudConfigBundleSnapshot) -> Self {
|
||||
let bundle = snapshot.bundle.clone();
|
||||
let mut loader = Self::from_getter(move || std::future::ready(bundle.clone()));
|
||||
loader.config_server = snapshot.config_server.clone();
|
||||
loader.ema_policy = snapshot
|
||||
.binding
|
||||
.as_ref()
|
||||
@@ -465,13 +469,27 @@ impl CloudConfigBundleLoader {
|
||||
}
|
||||
|
||||
pub async fn get_snapshot(&self) -> CloudConfigBundleSnapshot {
|
||||
match &self.snapshot_getter {
|
||||
let mut snapshot = match &self.snapshot_getter {
|
||||
Some(getter) => getter().await,
|
||||
None => CloudConfigBundleSnapshot {
|
||||
bundle: self.get().await,
|
||||
binding: None,
|
||||
config_server: None,
|
||||
},
|
||||
};
|
||||
if snapshot.bundle.is_ok() && self.config_server.is_some() {
|
||||
snapshot.config_server = self.config_server.clone();
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
pub fn with_config_server(mut self, service_config: crate::ConfigServer) -> Self {
|
||||
self.config_server = Some(service_config);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn config_server(&self) -> Option<&crate::ConfigServer> {
|
||||
self.config_server.as_ref()
|
||||
}
|
||||
|
||||
pub fn ema_policy_changes(&self) -> Option<CloudConfigBundlePolicyChanges> {
|
||||
|
||||
@@ -1033,6 +1033,8 @@ pub(crate) fn merge_app_requirements_descending(
|
||||
/// Base config deserialized from system `requirements.toml` or MDM.
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
pub struct ConfigRequirementsToml {
|
||||
/// Required customer-hosted configuration server. Only local managed requirements may configure it.
|
||||
pub config_server: Option<crate::ConfigServer>,
|
||||
pub allowed_login_methods: Option<Vec<ForcedLoginMethod>>,
|
||||
pub allowed_chatgpt_workspaces: Option<Vec<String>>,
|
||||
pub cli_auth_credentials_store: Option<AuthCredentialsStoreMode>,
|
||||
@@ -1144,6 +1146,7 @@ impl<T> std::ops::Deref for Sourced<T> {
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct ConfigRequirementsWithSources {
|
||||
pub config_server: Option<Sourced<crate::ConfigServer>>,
|
||||
pub allowed_login_methods: Option<Sourced<Vec<ForcedLoginMethod>>>,
|
||||
pub allowed_chatgpt_workspaces: Option<Sourced<Vec<String>>>,
|
||||
pub cli_auth_credentials_store: Option<Sourced<AuthCredentialsStoreMode>>,
|
||||
@@ -1207,6 +1210,7 @@ impl ConfigRequirementsWithSources {
|
||||
// Destructure without `..` so adding fields to `ConfigRequirementsToml`
|
||||
// forces this merge logic to be updated.
|
||||
let ConfigRequirementsToml {
|
||||
config_server: _,
|
||||
allowed_login_methods: _,
|
||||
allowed_chatgpt_workspaces: _,
|
||||
cli_auth_credentials_store: _,
|
||||
@@ -1272,6 +1276,7 @@ impl ConfigRequirementsWithSources {
|
||||
other,
|
||||
source,
|
||||
{
|
||||
config_server,
|
||||
allowed_login_methods,
|
||||
allowed_chatgpt_workspaces,
|
||||
cli_auth_credentials_store,
|
||||
@@ -1358,6 +1363,7 @@ impl ConfigRequirementsWithSources {
|
||||
|
||||
pub fn into_toml(self) -> ConfigRequirementsToml {
|
||||
let ConfigRequirementsWithSources {
|
||||
config_server,
|
||||
allowed_login_methods,
|
||||
allowed_chatgpt_workspaces,
|
||||
cli_auth_credentials_store,
|
||||
@@ -1402,6 +1408,7 @@ impl ConfigRequirementsWithSources {
|
||||
guardian_extra_policy,
|
||||
} = self;
|
||||
ConfigRequirementsToml {
|
||||
config_server: config_server.map(|sourced| sourced.value),
|
||||
allowed_login_methods: allowed_login_methods.map(|sourced| sourced.value),
|
||||
allowed_chatgpt_workspaces: allowed_chatgpt_workspaces.map(|sourced| sourced.value),
|
||||
cli_auth_credentials_store: cli_auth_credentials_store.map(|sourced| sourced.value),
|
||||
@@ -1492,6 +1499,11 @@ impl From<SandboxMode> for SandboxModeRequirement {
|
||||
}
|
||||
|
||||
impl ConfigRequirementsToml {
|
||||
/// Whether local administration requires a remotely delivered managed bundle.
|
||||
pub fn requires_remote_config(&self) -> bool {
|
||||
self.config_server.is_some()
|
||||
}
|
||||
|
||||
pub fn apply_remote_sandbox_config(&mut self, hostname: Option<&str>) {
|
||||
let Some(remote_sandbox_config) = self.remote_sandbox_config.as_ref() else {
|
||||
return;
|
||||
@@ -1509,7 +1521,8 @@ impl ConfigRequirementsToml {
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.allowed_login_methods.is_none()
|
||||
self.config_server.is_none()
|
||||
&& self.allowed_login_methods.is_none()
|
||||
&& self.allowed_chatgpt_workspaces.is_none()
|
||||
&& self.cli_auth_credentials_store.is_none()
|
||||
&& self.chatgpt_base_url.is_none()
|
||||
@@ -1752,6 +1765,7 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
// defaults also remain there because they are initialization values;
|
||||
// model-specific auto-review requirements are runtime constraints.
|
||||
let ConfigRequirementsWithSources {
|
||||
config_server: _,
|
||||
allowed_login_methods,
|
||||
allowed_chatgpt_workspaces,
|
||||
cli_auth_credentials_store,
|
||||
@@ -2305,6 +2319,7 @@ mod tests {
|
||||
|
||||
fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources {
|
||||
let ConfigRequirementsToml {
|
||||
config_server,
|
||||
allowed_login_methods,
|
||||
allowed_chatgpt_workspaces,
|
||||
cli_auth_credentials_store,
|
||||
@@ -2350,6 +2365,8 @@ mod tests {
|
||||
guardian_extra_policy,
|
||||
} = toml;
|
||||
ConfigRequirementsWithSources {
|
||||
config_server: config_server
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
allowed_login_methods: allowed_login_methods
|
||||
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
|
||||
allowed_chatgpt_workspaces: allowed_chatgpt_workspaces
|
||||
@@ -2884,6 +2901,7 @@ mod tests {
|
||||
// Intentionally constructed without `..Default::default()` so adding a new field to
|
||||
// `ConfigRequirementsToml` forces this test to be updated.
|
||||
let other = ConfigRequirementsToml {
|
||||
config_server: None,
|
||||
allowed_login_methods: Some(vec![ForcedLoginMethod::Chatgpt]),
|
||||
allowed_chatgpt_workspaces: Some(vec!["managed-workspace".to_string()]),
|
||||
cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring),
|
||||
@@ -2934,6 +2952,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
target,
|
||||
ConfigRequirementsWithSources {
|
||||
config_server: None,
|
||||
allowed_login_methods: Some(Sourced::new(
|
||||
vec![ForcedLoginMethod::Chatgpt],
|
||||
source.clone(),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Trusted enrollment settings for a customer-hosted configuration server.
|
||||
//!
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
|
||||
pub use codex_model_provider_info::OAuthSessionConfig as ConfigServerOAuth;
|
||||
|
||||
// Deployment-specific prefixes belong in base_url.
|
||||
const BUNDLE_PATH: &str = "codex/config/bundle";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(try_from = "ConfigServerInput")]
|
||||
pub struct ConfigServer {
|
||||
pub base_url: String,
|
||||
// Boxing keeps config-loader futures small.
|
||||
pub oauth: Option<Box<ConfigServerOAuth>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ConfigServerInput {
|
||||
base_url: String,
|
||||
oauth: Option<Box<ConfigServerOAuth>>,
|
||||
}
|
||||
|
||||
impl TryFrom<ConfigServerInput> for ConfigServer {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(input: ConfigServerInput) -> Result<Self, Self::Error> {
|
||||
let service = Self {
|
||||
base_url: input.base_url,
|
||||
oauth: input.oauth,
|
||||
};
|
||||
service.endpoint_url()?;
|
||||
Ok(service)
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigServer {
|
||||
/// Validates the trusted settings and appends the fixed bundle route to the base URL.
|
||||
pub fn endpoint_url(&self) -> Result<String, String> {
|
||||
let validate_https_url = |value: &str| {
|
||||
codex_http_client::validate_endpoint_url(value, "config_server")
|
||||
.ok()
|
||||
.filter(|url| url.scheme() == "https")
|
||||
.ok_or("config_server URLs must use HTTPS without userinfo or fragments")
|
||||
};
|
||||
let mut endpoint = validate_https_url(&self.base_url)?;
|
||||
if endpoint.query().is_some() {
|
||||
return Err("config_server.base_url must not contain a query".into());
|
||||
}
|
||||
endpoint
|
||||
.path_segments_mut()
|
||||
.map_err(|_| "config_server.base_url cannot be used as a base URL")?
|
||||
.pop_if_empty()
|
||||
.extend(BUNDLE_PATH.split('/'));
|
||||
if let Some(oauth) = &self.oauth {
|
||||
validate_https_url(&oauth.authorization_url)?;
|
||||
validate_https_url(&oauth.token_url)?;
|
||||
if let Some(url) = &oauth.device_authorization_url {
|
||||
validate_https_url(url)?;
|
||||
}
|
||||
if oauth.client_id.trim().is_empty() || oauth.redirect_port == Some(0) {
|
||||
return Err("config_server OAuth requires a client ID and a nonzero redirect port when specified".into());
|
||||
}
|
||||
}
|
||||
Ok(endpoint.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ConfigServer {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ConfigServer").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod codex_home_symlink;
|
||||
mod computer_use;
|
||||
mod config_layer_source;
|
||||
mod config_requirements;
|
||||
mod config_server;
|
||||
pub mod config_toml;
|
||||
mod constraint;
|
||||
mod diagnostics;
|
||||
@@ -145,6 +146,8 @@ pub use config_requirements::WebSearchModeRequirement;
|
||||
pub use config_requirements::WindowsRequirementsToml;
|
||||
pub use config_requirements::WindowsSandboxImplementationToml;
|
||||
pub use config_requirements::sandbox_mode_requirement_for_permission_profile;
|
||||
pub use config_server::ConfigServer;
|
||||
pub use config_server::ConfigServerOAuth;
|
||||
pub use config_toml::effective_sandbox_mode;
|
||||
pub use constraint::Constrained;
|
||||
pub use constraint::ConstraintError;
|
||||
|
||||
@@ -207,10 +207,12 @@ pub async fn load_config_layers_state(
|
||||
let mut bundle_requirements_layers = Vec::new();
|
||||
let mut cloud_config_layers = Vec::new();
|
||||
let mut cloud_config_binding = None;
|
||||
let mut applied_config_server = None;
|
||||
|
||||
if !overrides.ignore_managed_requirements {
|
||||
let snapshot = cloud_config_bundle.get_snapshot().await;
|
||||
cloud_config_binding = snapshot.binding;
|
||||
applied_config_server = snapshot.config_server;
|
||||
if let Some(bundle) = snapshot.bundle.map_err(io::Error::other)? {
|
||||
let cloud_config_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?;
|
||||
let bundle_layers = if strict_config {
|
||||
@@ -500,6 +502,7 @@ pub async fn load_config_layers_state(
|
||||
config_requirements_toml.into_toml(),
|
||||
)?
|
||||
.with_cloud_config_binding(cloud_config_binding)
|
||||
.with_applied_config_server(applied_config_server)
|
||||
.with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules);
|
||||
config_layer_stack.is_projectless = is_projectless;
|
||||
startup_warnings.extend(ignored_config_warning(
|
||||
|
||||
@@ -101,6 +101,16 @@ impl ComposableRequirementsLayer {
|
||||
.map(|base_dir| AbsolutePathBufGuard::new(base_dir.as_path()));
|
||||
let mut regular_toml = parse_layer_toml(&toml, &source)?;
|
||||
|
||||
if matches!(source, RequirementSource::EnterpriseManaged { .. })
|
||||
&& regular_toml.get("config_server").is_some()
|
||||
{
|
||||
return Err(RequirementsCompositionError::Parse {
|
||||
layer_source: source.clone(),
|
||||
message: "config_server may only be configured in local managed requirements"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
|
||||
// These fields can only be set locally; ignore them before validating cloud policy.
|
||||
strip_cloud_auth_requirements(&source, &mut regular_toml);
|
||||
|
||||
|
||||
@@ -152,6 +152,12 @@ impl RequirementsLayerStack {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -211,6 +217,7 @@ fn populate_merged_regular_fields_with_sources(
|
||||
// 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,
|
||||
@@ -256,6 +263,7 @@ fn populate_merged_regular_fields_with_sources(
|
||||
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"]);
|
||||
@@ -343,7 +351,8 @@ fn source_for_top_level_keys(
|
||||
};
|
||||
let winning_source = (*winning_source).clone();
|
||||
|
||||
if !winning_value.is_table() {
|
||||
// The config_server block is replaced as a whole, including its source attribution.
|
||||
if keys == ["config_server"] || !winning_value.is_table() {
|
||||
return winning_source;
|
||||
}
|
||||
|
||||
|
||||
@@ -1611,3 +1611,62 @@ request_headers = [{ name = "x-old", value = "old" }]"#,
|
||||
assert_eq!(composed, expected_requirements(higher));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_priority_config_server_replaces_previous_oauth_settings() {
|
||||
let actual = crate::compose_requirements([
|
||||
RequirementsLayerEntry::from_toml(
|
||||
RequirementSource::SystemRequirementsToml {
|
||||
file: AbsolutePathBuf::from_absolute_path(
|
||||
std::env::temp_dir().join("requirements.toml"),
|
||||
)
|
||||
.unwrap(),
|
||||
},
|
||||
r#"
|
||||
[config_server]
|
||||
base_url = "https://old.example/settings"
|
||||
[config_server.oauth]
|
||||
authorization_url = "https://login.old.example/authorize"
|
||||
token_url = "https://login.old.example/token"
|
||||
client_id = "old-client"
|
||||
scopes = ["policy.read"]
|
||||
"#,
|
||||
),
|
||||
RequirementsLayerEntry::from_toml(
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
"[config_server]\nbase_url = 'https://new.example/settings'",
|
||||
),
|
||||
])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
actual,
|
||||
ConfigRequirementsWithSources {
|
||||
config_server: Some(Sourced::new(
|
||||
crate::ConfigServer {
|
||||
base_url: "https://new.example/settings".into(),
|
||||
oauth: None,
|
||||
},
|
||||
RequirementSource::LegacyManagedConfigTomlFromMdm,
|
||||
)),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetched_policy_cannot_change_config_server() {
|
||||
let result = crate::compose_requirements([RequirementsLayerEntry::from_toml(
|
||||
RequirementSource::EnterpriseManaged {
|
||||
id: "remote".into(),
|
||||
name: "Remote policy".into(),
|
||||
},
|
||||
"[config_server]\nbase_url = 'https://replacement.example/settings'",
|
||||
)]);
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("only be configured in local managed requirements")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -249,6 +249,8 @@ pub struct ConfigLayerStack {
|
||||
cloud_config_binding: Option<crate::CloudConfigBundleBinding>,
|
||||
/// Runtime-only cancellation inherited by config snapshots and pending EMA startups.
|
||||
mcp_ema_cancellation: crate::McpEmaCancellation,
|
||||
/// Identifies the service that supplied the loaded configuration, for comparison with the configured service.
|
||||
applied_config_server: Option<std::sync::Arc<crate::ConfigServer>>,
|
||||
/// Cached TOML projection derived only from `requirements_toml`.
|
||||
/// Construction validates provider definitions and reports serialization errors,
|
||||
/// so `effective_config()` can replace complete entries without a fallible conversion.
|
||||
@@ -282,6 +284,7 @@ impl PartialEq for ConfigLayerStack {
|
||||
let Self {
|
||||
cloud_config_binding: _,
|
||||
mcp_ema_cancellation: _,
|
||||
applied_config_server,
|
||||
model_provider_requirements,
|
||||
layers,
|
||||
requirements,
|
||||
@@ -290,7 +293,8 @@ impl PartialEq for ConfigLayerStack {
|
||||
startup_warnings,
|
||||
is_projectless,
|
||||
} = self;
|
||||
model_provider_requirements == &other.model_provider_requirements
|
||||
applied_config_server == &other.applied_config_server
|
||||
&& model_provider_requirements == &other.model_provider_requirements
|
||||
&& layers == &other.layers
|
||||
&& requirements == &other.requirements
|
||||
&& requirements_toml == &other.requirements_toml
|
||||
@@ -315,6 +319,7 @@ impl ConfigLayerStack {
|
||||
let stack = Self {
|
||||
cloud_config_binding: None,
|
||||
mcp_ema_cancellation: crate::McpEmaCancellation::default(),
|
||||
applied_config_server: None,
|
||||
model_provider_requirements,
|
||||
layers,
|
||||
requirements,
|
||||
@@ -382,6 +387,29 @@ impl ConfigLayerStack {
|
||||
&self.mcp_ema_cancellation
|
||||
}
|
||||
|
||||
pub fn with_applied_config_server(
|
||||
mut self,
|
||||
service_config: Option<crate::ConfigServer>,
|
||||
) -> Self {
|
||||
self.applied_config_server = service_config.map(std::sync::Arc::new);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn applied_config_server(&self) -> Option<&crate::ConfigServer> {
|
||||
self.applied_config_server.as_deref()
|
||||
}
|
||||
|
||||
/// Require configuration from the configured service before starting a session.
|
||||
pub fn ensure_config_server_applied(&self) -> std::io::Result<()> {
|
||||
if self.requirements_toml.config_server.as_ref() != self.applied_config_server() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"Required managed configuration has not been loaded; authenticate and reload configuration before starting a session",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retains session layers while adopting current MCP, plugin, and feature restrictions.
|
||||
/// Rejected refreshes must not restore an earlier policy on the next user reload.
|
||||
pub fn with_mcp_requirements_from(&self, incoming: &Self) -> Self {
|
||||
@@ -514,6 +542,7 @@ impl ConfigLayerStack {
|
||||
layers,
|
||||
cloud_config_binding: self.cloud_config_binding.clone(),
|
||||
mcp_ema_cancellation: self.mcp_ema_cancellation.clone(),
|
||||
applied_config_server: self.applied_config_server.clone(),
|
||||
model_provider_requirements: self.model_provider_requirements.clone(),
|
||||
requirements: self.requirements.clone(),
|
||||
requirements_toml: self.requirements_toml.clone(),
|
||||
@@ -552,6 +581,7 @@ impl ConfigLayerStack {
|
||||
layers,
|
||||
cloud_config_binding: self.cloud_config_binding.clone(),
|
||||
mcp_ema_cancellation: self.mcp_ema_cancellation.clone(),
|
||||
applied_config_server: self.applied_config_server.clone(),
|
||||
model_provider_requirements: self.model_provider_requirements.clone(),
|
||||
requirements: self.requirements.clone(),
|
||||
requirements_toml: self.requirements_toml.clone(),
|
||||
|
||||
@@ -259,6 +259,7 @@ mod role_overrides {
|
||||
)?
|
||||
.with_cloud_config_binding(config.config_layer_stack.cloud_config_binding().cloned())
|
||||
.with_mcp_ema_cancellation(config.config_layer_stack.mcp_ema_cancellation().clone())
|
||||
.with_applied_config_server(config.config_layer_stack.applied_config_server().cloned())
|
||||
.with_user_and_project_exec_policy_rules_ignored(
|
||||
config
|
||||
.config_layer_stack
|
||||
|
||||
@@ -432,13 +432,29 @@ async fn apply_role_cannot_expand_parent_authority() {
|
||||
let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
|
||||
let policy = CloudConfigBundlePolicy::default();
|
||||
let mut snapshot = CloudConfigBundleSnapshot {
|
||||
config_server: None,
|
||||
bundle: Ok(None),
|
||||
binding: None,
|
||||
};
|
||||
policy.publish_snapshot(&mut snapshot);
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_cloud_config_binding(snapshot.binding);
|
||||
let config_server = codex_config::ConfigServer {
|
||||
base_url: "https://policy.example/settings".into(),
|
||||
oauth: None,
|
||||
};
|
||||
let mut requirements = config.config_layer_stack.requirements_toml().clone();
|
||||
requirements.config_server = Some(config_server.clone());
|
||||
config.config_layer_stack = codex_config::ConfigLayerStack::new(
|
||||
config
|
||||
.config_layer_stack
|
||||
.all_layers_low_to_high()
|
||||
.cloned()
|
||||
.collect(),
|
||||
config.config_layer_stack.requirements().clone(),
|
||||
requirements,
|
||||
)
|
||||
.expect("parent managed config")
|
||||
.with_cloud_config_binding(snapshot.binding)
|
||||
.with_applied_config_server(Some(config_server));
|
||||
config.notify = Some(vec!["parent-notifier".to_string()]);
|
||||
for feature in [Feature::MemoryTool, Feature::RequestPermissionsTool] {
|
||||
config
|
||||
@@ -487,6 +503,11 @@ command = "attacker-command"
|
||||
.await
|
||||
.expect("custom role should apply");
|
||||
|
||||
assert_eq!(
|
||||
config.config_layer_stack.applied_config_server(),
|
||||
parent.config_layer_stack.applied_config_server()
|
||||
);
|
||||
|
||||
policy.observe_remote_bundle(
|
||||
&CloudConfigBundleFixture::enterprise_config("model = 'managed'").into_bundle(),
|
||||
);
|
||||
|
||||
@@ -5541,6 +5541,10 @@ fn filter_plugin_mcp_servers_by_matchers_enforces_name_and_invocation() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebuild_with_session_layers_refreshes_requirements() -> std::io::Result<()> {
|
||||
let service_config = codex_config::ConfigServer {
|
||||
base_url: "https://policy.example/settings".into(),
|
||||
oauth: None,
|
||||
};
|
||||
let codex_home = TempDir::new()?;
|
||||
let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path());
|
||||
let project_dot_codex =
|
||||
@@ -5580,6 +5584,7 @@ async fn rebuild_with_session_layers_refreshes_requirements() -> std::io::Result
|
||||
),
|
||||
]);
|
||||
let requirements_toml = codex_config::ConfigRequirementsToml {
|
||||
config_server: Some(service_config.clone()),
|
||||
mcp_servers: Some(mcp_requirements.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -5626,7 +5631,8 @@ async fn rebuild_with_session_layers_refreshes_requirements() -> std::io::Result
|
||||
requirements,
|
||||
requirements_toml,
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
.map_err(std::io::Error::other)?
|
||||
.with_applied_config_server(Some(service_config));
|
||||
let thread_layer_stack = ConfigLayerStack::new(
|
||||
vec![
|
||||
ConfigLayerEntry::new(
|
||||
@@ -5701,6 +5707,26 @@ async fn rebuild_with_session_layers_refreshes_requirements() -> std::io::Result
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
config.config_layer_stack.applied_config_server(),
|
||||
refreshed_layer_stack.applied_config_server()
|
||||
);
|
||||
|
||||
// User-only refreshes keep this policy; managed refreshes must supply it.
|
||||
let mut missing_policy = config.clone();
|
||||
missing_policy.config_layer_stack = missing_policy
|
||||
.config_layer_stack
|
||||
.with_applied_config_server(/*service_config*/ None);
|
||||
assert!(
|
||||
config
|
||||
.resolve_runtime_refresh(&missing_policy, RuntimeConfigRefresh::User)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.resolve_runtime_refresh(&missing_policy, RuntimeConfigRefresh::Mcp)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
config.mcp_servers.get(),
|
||||
@@ -10414,6 +10440,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
|
||||
let fixture = create_test_fixture()?;
|
||||
|
||||
let requirements_toml = codex_config::ConfigRequirementsToml {
|
||||
config_server: None,
|
||||
model_provider: None,
|
||||
model_providers: None,
|
||||
allowed_login_methods: None,
|
||||
|
||||
@@ -2025,6 +2025,7 @@ impl Config {
|
||||
refreshed_layers.requirements_toml().clone(),
|
||||
)?
|
||||
.with_cloud_config_binding(refreshed_layers.cloud_config_binding().cloned())
|
||||
.with_applied_config_server(refreshed_layers.applied_config_server().cloned())
|
||||
.with_user_and_project_exec_policy_rules_ignored(
|
||||
refreshed_layers.ignore_user_and_project_exec_policy_rules(),
|
||||
))
|
||||
|
||||
@@ -50,6 +50,8 @@ impl Config {
|
||||
)?
|
||||
.with_user_layer_from(&self.config_layer_stack),
|
||||
};
|
||||
// User refreshes retain the session's managed configuration.
|
||||
layers.ensure_config_server_applied()?;
|
||||
let cfg: McpRefreshToml = layers
|
||||
.effective_config()
|
||||
.try_into()
|
||||
|
||||
@@ -596,6 +596,9 @@ impl Session {
|
||||
}
|
||||
|
||||
async fn spawn_internal(args: SessionSpawnArgs) -> CodexResult<(Arc<Self>, SessionIo)> {
|
||||
args.config
|
||||
.config_layer_stack
|
||||
.ensure_config_server_applied()?;
|
||||
args.config.validate_windows_mxc_requirement()?;
|
||||
let SessionSpawnArgs {
|
||||
startup,
|
||||
|
||||
@@ -22,6 +22,78 @@ use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_requires_managed_configuration_from_configured_service() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let home = TempDir::new()?;
|
||||
let requirements_path = home.path().join("requirements.toml");
|
||||
let required_config_server = codex_config::ConfigServer {
|
||||
base_url: "https://required.example/managed/settings".into(),
|
||||
oauth: None,
|
||||
};
|
||||
std::fs::write(
|
||||
&requirements_path,
|
||||
format!(
|
||||
"[config_server]\n{}",
|
||||
toml::to_string(&required_config_server)?
|
||||
),
|
||||
)?;
|
||||
let policy = CloudConfigBundleFixture::enterprise_requirement(
|
||||
"allowed_approval_policies = ['on-request']",
|
||||
)
|
||||
.into_loader();
|
||||
for (loader, should_start) in [
|
||||
(CloudConfigBundleLoader::default(), false),
|
||||
(
|
||||
policy
|
||||
.clone()
|
||||
.with_config_server(codex_config::ConfigServer {
|
||||
base_url: "https://other.example/managed/settings".into(),
|
||||
oauth: None,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
(policy.with_config_server(required_config_server), true),
|
||||
] {
|
||||
let managed = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.loader_overrides(LoaderOverrides {
|
||||
system_requirements_path: Some(requirements_path.clone()),
|
||||
..LoaderOverrides::without_managed_config_for_tests()
|
||||
})
|
||||
.cloud_config_bundle(loader)
|
||||
.build()
|
||||
.await?;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.config_layer_stack = managed.config_layer_stack;
|
||||
config.permissions = managed.permissions;
|
||||
});
|
||||
match builder.build_with_auto_env(&server).await {
|
||||
Ok(test) => {
|
||||
assert!(
|
||||
should_start,
|
||||
"session started without required company policy"
|
||||
);
|
||||
assert_eq!(
|
||||
test.session_configured.approval_policy,
|
||||
AskForApproval::OnRequest
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
assert!(
|
||||
!should_start,
|
||||
"matching managed configuration should start a session: {error:#}"
|
||||
);
|
||||
assert!(
|
||||
format!("{error:#}")
|
||||
.contains("Required managed configuration has not been loaded")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_mxc_opt_out_blocks_preferred_and_explicit_selection() -> Result<()> {
|
||||
for mode in [None, Some("elevated"), Some("mxc")] {
|
||||
|
||||
@@ -305,6 +305,7 @@ http_headers = {{ Authorization = "Bearer ordinary-access" }}
|
||||
CloudConfigBundleFixture::enterprise_config(policy).into_bundle(),
|
||||
)),
|
||||
binding: None,
|
||||
config_server: None,
|
||||
};
|
||||
delivered_policy.publish_snapshot(&mut snapshot);
|
||||
let test = test_codex()
|
||||
@@ -648,6 +649,7 @@ http_headers = {{ Authorization = "Bearer ordinary-access" }}
|
||||
let mut snapshot = codex_config::CloudConfigBundleSnapshot {
|
||||
bundle: Ok(Some(denied)),
|
||||
binding: None,
|
||||
config_server: None,
|
||||
};
|
||||
delivered_policy.publish_snapshot(&mut snapshot);
|
||||
}
|
||||
|
||||
@@ -410,6 +410,7 @@ async fn policy_suspension_requires_fresh_authority_without_cancelling_admitted_
|
||||
let mut snapshot = CloudConfigBundleSnapshot {
|
||||
bundle: Ok(None),
|
||||
binding: None,
|
||||
config_server: None,
|
||||
};
|
||||
policy.publish_snapshot(&mut snapshot);
|
||||
Arc::get_mut(&mut client)
|
||||
|
||||
@@ -943,6 +943,7 @@ interrupt_message = false
|
||||
};
|
||||
|
||||
let requirements_toml = ConfigRequirementsToml {
|
||||
config_server: None,
|
||||
application: None,
|
||||
allowed_login_methods: None,
|
||||
allowed_chatgpt_workspaces: None,
|
||||
|
||||
Reference in New Issue
Block a user