diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ed8207e24ed..a841e29205d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -197,6 +197,11 @@ client_request_definitions! { response: v2::ConfigWriteResponse, }, + ConfigRequirementsRead => "configRequirements/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: v2::ConfigRequirementsReadResponse, + }, + GetAccount => "account/read" { params: v2::GetAccountParams, response: v2::GetAccountResponse, @@ -711,6 +716,22 @@ mod tests { Ok(()) } + #[test] + fn serialize_config_requirements_read() -> Result<()> { + let request = ClientRequest::ConfigRequirementsRead { + request_id: RequestId::Integer(1), + params: None, + }; + assert_eq!( + json!({ + "method": "configRequirements/read", + "id": 1, + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_account_login_api_key() -> Result<()> { let request = ClientRequest::LoginAccount { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ee061158065..0b2e2c0a305 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -453,6 +453,22 @@ pub struct ConfigReadResponse { pub layers: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigRequirements { + pub allowed_approval_policies: Option>, + pub allowed_sandbox_modes: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigRequirementsReadResponse { + /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). + pub requirements: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 88d96dda56b..4457f4d2726 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -88,6 +88,7 @@ Example (from OpenAI's official VSCode extension): - `config/read` — fetch the effective config on disk after resolving config layering. - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk. +- `configRequirements/read` — fetch the loaded requirements allow-lists from `requirements.toml` and/or MDM (or `null` if none are configured). ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3b0e4a9db16..d17dc76b4b2 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -510,6 +510,9 @@ impl CodexMessageProcessor { | ClientRequest::ConfigBatchWrite { .. } => { warn!("Config request reached CodexMessageProcessor unexpectedly"); } + ClientRequest::ConfigRequirementsRead { .. } => { + warn!("ConfigRequirementsRead request reached CodexMessageProcessor unexpectedly"); + } ClientRequest::GetAccountRateLimits { request_id, params: _, diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index b06de3f1e45..25434ce92bf 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -3,13 +3,18 @@ use crate::error_code::INVALID_REQUEST_ERROR_CODE; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ConfigRequirements; +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::JSONRPCErrorError; +use codex_app_server_protocol::SandboxMode; use codex_core::config::ConfigService; use codex_core::config::ConfigServiceError; +use codex_core::config_loader::ConfigRequirementsToml; use codex_core::config_loader::LoaderOverrides; +use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement; use serde_json::json; use std::path::PathBuf; use toml::Value as TomlValue; @@ -37,6 +42,19 @@ impl ConfigApi { self.service.read(params).await.map_err(map_error) } + pub(crate) async fn config_requirements_read( + &self, + ) -> Result { + let requirements = self + .service + .read_requirements() + .await + .map_err(map_error)? + .map(map_requirements_toml_to_api); + + Ok(ConfigRequirementsReadResponse { requirements }) + } + pub(crate) async fn write_value( &self, params: ConfigValueWriteParams, @@ -52,6 +70,32 @@ impl ConfigApi { } } +fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { + ConfigRequirements { + allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { + policies + .into_iter() + .map(codex_app_server_protocol::AskForApproval::from) + .collect() + }), + allowed_sandbox_modes: requirements.allowed_sandbox_modes.map(|modes| { + modes + .into_iter() + .filter_map(map_sandbox_mode_requirement_to_api) + .collect() + }), + } +} + +fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option { + match mode { + CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly), + CoreSandboxModeRequirement::WorkspaceWrite => Some(SandboxMode::WorkspaceWrite), + CoreSandboxModeRequirement::DangerFullAccess => Some(SandboxMode::DangerFullAccess), + CoreSandboxModeRequirement::ExternalSandbox => None, + } +} + fn map_error(err: ConfigServiceError) -> JSONRPCErrorError { if let Some(code) = err.write_error_code() { return config_write_error(code, err.to_string()); @@ -73,3 +117,38 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> })), } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::protocol::AskForApproval as CoreAskForApproval; + use pretty_assertions::assert_eq; + + #[test] + fn map_requirements_toml_to_api_converts_core_enums() { + let requirements = ConfigRequirementsToml { + allowed_approval_policies: Some(vec![ + CoreAskForApproval::Never, + CoreAskForApproval::OnRequest, + ]), + allowed_sandbox_modes: Some(vec![ + CoreSandboxModeRequirement::ReadOnly, + CoreSandboxModeRequirement::ExternalSandbox, + ]), + }; + + let mapped = map_requirements_toml_to_api(requirements); + + assert_eq!( + mapped.allowed_approval_policies, + Some(vec![ + codex_app_server_protocol::AskForApproval::Never, + codex_app_server_protocol::AskForApproval::OnRequest, + ]) + ); + assert_eq!( + mapped.allowed_sandbox_modes, + Some(vec![SandboxMode::ReadOnly]), + ); + } +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 731342e3c59..60e938bb18f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -158,6 +158,12 @@ impl MessageProcessor { ClientRequest::ConfigBatchWrite { request_id, params } => { self.handle_config_batch_write(request_id, params).await; } + ClientRequest::ConfigRequirementsRead { + request_id, + params: _, + } => { + self.handle_config_requirements_read(request_id).await; + } other => { self.codex_message_processor.process_request(other).await; } @@ -210,4 +216,11 @@ impl MessageProcessor { Err(error) => self.outgoing.send_error(request_id, error).await, } } + + async fn handle_config_requirements_read(&self, request_id: RequestId) { + match self.config_api.config_requirements_read().await { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } } diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index d65ed10a050..913c02df1d0 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -4,6 +4,7 @@ use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigRequirementsToml; use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::config_loader::merge_toml_values; @@ -157,6 +158,22 @@ impl ConfigService { }) } + pub async fn read_requirements( + &self, + ) -> Result, ConfigServiceError> { + let layers = self + .load_thread_agnostic_config() + .await + .map_err(|err| ConfigServiceError::io("failed to read configuration layers", err))?; + + let requirements = layers.requirements_toml().clone(); + if requirements.is_empty() { + Ok(None) + } else { + Ok(Some(requirements)) + } + } + pub async fn write_value( &self, params: ConfigValueWriteParams, diff --git a/codex-rs/core/src/config_loader/config_requirements.rs b/codex-rs/core/src/config_loader/config_requirements.rs index feb854df696..efbf9d61e51 100644 --- a/codex-rs/core/src/config_loader/config_requirements.rs +++ b/codex-rs/core/src/config_loader/config_requirements.rs @@ -58,6 +58,10 @@ impl From for SandboxModeRequirement { } impl ConfigRequirementsToml { + pub fn is_empty(&self) -> bool { + self.allowed_approval_policies.is_none() && self.allowed_sandbox_modes.is_none() + } + /// For every field in `other` that is `Some`, if the corresponding field in /// `self` is `None`, copy the value from `other` into `self`. pub fn merge_unset_fields(&mut self, mut other: ConfigRequirementsToml) { diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 2dbba678acf..bb995fda213 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -12,7 +12,6 @@ mod tests; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigToml; -use crate::config_loader::config_requirements::ConfigRequirementsToml; use crate::config_loader::layer_io::LoadedConfigLayers; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::config_types::SandboxMode; @@ -25,6 +24,8 @@ use std::path::Path; use toml::Value as TomlValue; pub use config_requirements::ConfigRequirements; +pub use config_requirements::ConfigRequirementsToml; +pub use config_requirements::SandboxModeRequirement; pub use merge::merge_toml_values; pub use state::ConfigLayerEntry; pub use state::ConfigLayerStack; @@ -201,7 +202,9 @@ pub async fn load_config_layers_state( )); } - ConfigLayerStack::new(layers, config_requirements_toml.try_into()?) + let requirements_toml = config_requirements_toml.clone(); + let requirements = config_requirements_toml.try_into()?; + ConfigLayerStack::new(layers, requirements, requirements_toml) } /// Attempts to load a config.toml file from `config_toml`. diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs index 0ef14403d6d..2b01a22644b 100644 --- a/codex-rs/core/src/config_loader/state.rs +++ b/codex-rs/core/src/config_loader/state.rs @@ -1,4 +1,5 @@ use crate::config_loader::ConfigRequirements; +use crate::config_loader::ConfigRequirementsToml; use super::fingerprint::record_origins; use super::fingerprint::version_for_toml; @@ -86,18 +87,25 @@ pub struct ConfigLayerStack { /// Constraints that must be enforced when deriving a [Config] from the /// layers. requirements: ConfigRequirements, + + /// Raw requirements data as loaded from requirements.toml/MDM/legacy + /// sources. This preserves the original allow-lists so they can be + /// surfaced via APIs. + requirements_toml: ConfigRequirementsToml, } impl ConfigLayerStack { pub fn new( layers: Vec, requirements: ConfigRequirements, + requirements_toml: ConfigRequirementsToml, ) -> std::io::Result { let user_layer_index = verify_layer_ordering(&layers)?; Ok(Self { layers, user_layer_index, requirements, + requirements_toml, }) } @@ -111,6 +119,10 @@ impl ConfigLayerStack { &self.requirements } + pub fn requirements_toml(&self) -> &ConfigRequirementsToml { + &self.requirements_toml + } + /// Creates a new [ConfigLayerStack] using the specified values to inject a /// "user layer" into the stack. If such a layer already exists, it is /// replaced; otherwise, it is inserted into the stack at the appropriate @@ -131,6 +143,7 @@ impl ConfigLayerStack { layers, user_layer_index: self.user_layer_index, requirements: self.requirements.clone(), + requirements_toml: self.requirements_toml.clone(), } } None => { @@ -151,6 +164,7 @@ impl ConfigLayerStack { layers, user_layer_index: Some(user_layer_index), requirements: self.requirements.clone(), + requirements_toml: self.requirements_toml.clone(), } } } diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 4c8ad068721..d8880a9b3c4 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -421,6 +421,7 @@ mod tests { use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; + use crate::config_loader::ConfigRequirementsToml; use crate::features::Feature; use crate::features::Features; use codex_app_server_protocol::ConfigLayerSource; @@ -441,7 +442,12 @@ mod tests { ConfigLayerSource::Project { dot_codex_folder }, TomlValue::Table(Default::default()), ); - ConfigLayerStack::new(vec![layer], ConfigRequirements::default()).expect("ConfigLayerStack") + ConfigLayerStack::new( + vec![layer], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("ConfigLayerStack") } #[tokio::test] @@ -573,7 +579,11 @@ mod tests { TomlValue::Table(Default::default()), ), ]; - let config_stack = ConfigLayerStack::new(layers, ConfigRequirements::default())?; + let config_stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; let policy = load_exec_policy(&config_stack).await?; diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 35aeaec7f85..3eac22ebff1 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -316,6 +316,7 @@ mod tests { use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; + use crate::config_loader::ConfigRequirementsToml; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -377,7 +378,11 @@ mod tests { TomlValue::Table(toml::map::Map::new()), ), ]; - let stack = ConfigLayerStack::new(layers, ConfigRequirements::default())?; + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; let got = skill_roots_from_layer_stack(&stack) .into_iter()