diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 751eae93f7..37b2d87631 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2994,6 +2994,7 @@ dependencies = [ "bytes", "clatter", "codex-api", + "codex-config", "codex-exec-server-protocol", "codex-exec-server-test-support", "codex-file-system", @@ -3004,11 +3005,13 @@ dependencies = [ "codex-sandboxing", "codex-test-binary-support", "codex-utils-absolute-path", + "codex-utils-home-dir", "codex-utils-path-uri", "codex-utils-pty", "codex-utils-rustls-provider", "codex-websocket-client", "ctor 0.6.3", + "dirs", "futures", "http 1.4.0", "libc", diff --git a/codex-rs/exec-server-protocol/src/environment_config.rs b/codex-rs/exec-server-protocol/src/environment_config.rs new file mode 100644 index 0000000000..33a3c27dd8 --- /dev/null +++ b/codex-rs/exec-server-protocol/src/environment_config.rs @@ -0,0 +1,50 @@ +use codex_utils_path_uri::PathUri; +use serde::Deserialize; +use serde::Serialize; + +pub const ENVIRONMENT_CONFIG_READ_METHOD: &str = "environmentConfig/read"; + +/// Selects executor-local config and requirements fields by literal TOML key path. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentConfigReadParams { + pub cwd: PathUri, + pub config_paths: Vec>, + pub requirements_paths: Vec>, +} + +/// Executor-local config and requirements layers selected for one environment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentConfigReadResponse { + /// Executor user home used to expand `~` in path-bearing values. + pub user_home_dir: Option, + /// Executor Codex home used as the base directory for cloud-provided layers. + pub codex_home_dir: PathUri, + /// Executor hostname used to select matching remote sandbox requirements. + pub hostname: Option, + pub config: EnvironmentConfigLayerStack, + pub requirements: EnvironmentConfigLayerStack, +} + +/// One ordered set of selected TOML layers. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentConfigLayerStack { + /// Layers ordered from lowest to highest precedence. + pub layers: Vec, + /// Position at which cloud-provided layers should be inserted. + pub cloud_insertion_index: usize, +} + +/// One selected executor-local TOML layer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentConfigLayer { + /// Opaque provenance for diagnostics. Callers must not derive behavior from it. + pub source: String, + /// Directory used to interpret relative paths in this layer. + pub base_dir: PathUri, + /// Selected raw TOML. Path-bearing values have not been normalized. + pub toml: String, +} diff --git a/codex-rs/exec-server-protocol/src/lib.rs b/codex-rs/exec-server-protocol/src/lib.rs index 969da3f9d9..bf6a057fe9 100644 --- a/codex-rs/exec-server-protocol/src/lib.rs +++ b/codex-rs/exec-server-protocol/src/lib.rs @@ -1,8 +1,10 @@ +mod environment_config; mod network_policy; mod process_id; mod protocol; pub mod rpc; +pub use environment_config::*; pub use network_policy::*; pub use process_id::ProcessId; pub use protocol::*; diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 1f4c68fbe3..19814fe1e6 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -17,6 +17,7 @@ base64 = { workspace = true } bytes = { workspace = true } clatter = { workspace = true } codex-api = { workspace = true } +codex-config = { workspace = true } codex-http-client = { workspace = true } codex-exec-server-protocol = { workspace = true } codex-file-system = { workspace = true } @@ -25,10 +26,12 @@ codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-home-dir = { workspace = true } codex-utils-path-uri = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-rustls-provider = { workspace = true } codex-websocket-client = { workspace = true } +dirs = { workspace = true } futures = { workspace = true } http = { workspace = true } prost = "0.14.3" diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 2c9c7aee6a..333a7832e2 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -45,6 +45,7 @@ use crate::process::ExecProcessEventReceiver; use crate::protocol::CAPABILITY_ROOTS_DISCOVER_METHOD; use crate::protocol::CapabilityRootsDiscoverParams; use crate::protocol::CapabilityRootsDiscoverResponse; +use crate::protocol::ENVIRONMENT_CONFIG_READ_METHOD; use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::ENVIRONMENT_STATUS_METHOD; use crate::protocol::EXEC_CLOSED_METHOD; @@ -55,6 +56,8 @@ use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_SIGNAL_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; use crate::protocol::EXEC_WRITE_METHOD; +use crate::protocol::EnvironmentConfigReadParams; +use crate::protocol::EnvironmentConfigReadResponse; use crate::protocol::EnvironmentInfo; use crate::protocol::EnvironmentStatus; use crate::protocol::ExecClosedNotification; @@ -736,6 +739,13 @@ impl ExecServerClient { ) } + pub async fn read_environment_config( + &self, + params: EnvironmentConfigReadParams, + ) -> Result { + self.call(ENVIRONMENT_CONFIG_READ_METHOD, ¶ms).await + } + pub async fn environment_status(&self) -> Result { // Health checks only reuse an existing RPC connection and never initiate recovery. let rpc_client = self.rpc_client_without_recovery()?; diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 70072731fa..9b5f914841 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -12,6 +12,8 @@ use codex_protocol::capabilities::SelectedCapabilityRoot; use crate::CapabilityRootsDiscoverParams; use crate::CapabilityRootsDiscoverResponse; +use crate::EnvironmentConfigReadParams; +use crate::EnvironmentConfigReadResponse; use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; @@ -24,6 +26,7 @@ use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::client_api::ExecServerTransportParams; use crate::environment_bootstrap::PreparedEnvironmentManager; use crate::environment_bootstrap::PreparedEnvironmentSource; +use crate::environment_config::read_environment_config; use crate::environment_provider::DefaultEnvironmentProvider; use crate::environment_provider::EnvironmentDefault; use crate::environment_provider::EnvironmentProvider; @@ -881,6 +884,19 @@ impl Environment { } } + /// Reads selected executor-local configuration fields for this environment. + pub async fn read_environment_config( + &self, + params: EnvironmentConfigReadParams, + ) -> Result { + match &self.remote_client { + Some(client) => client.get().await?.read_environment_config(params).await, + None => read_environment_config(self.filesystem.as_ref(), params) + .await + .map_err(|error| ExecServerError::Protocol(error.to_string())), + } + } + /// Discovers plugin and skill manifests through the environment's high-level discovery API. pub async fn discover_capability_roots( &self, diff --git a/codex-rs/exec-server/src/environment_config.rs b/codex-rs/exec-server/src/environment_config.rs new file mode 100644 index 0000000000..7124e6f3f6 --- /dev/null +++ b/codex-rs/exec-server/src/environment_config.rs @@ -0,0 +1,98 @@ +use codex_config::CONFIG_TOML_FILE; +use codex_config::format_config_layer_source; +use codex_config::host_name; +use codex_config::loader::LocalTomlLayerStack; +use codex_config::loader::load_local_config_layers; +use codex_exec_server_protocol::EnvironmentConfigLayer; +use codex_exec_server_protocol::EnvironmentConfigLayerStack; +use codex_exec_server_protocol::EnvironmentConfigReadParams; +use codex_exec_server_protocol::EnvironmentConfigReadResponse; +use codex_file_system::ExecutorFileSystem; +use codex_utils_home_dir::find_codex_home; +use codex_utils_path_uri::PathUri; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ReadEnvironmentConfigError { + #[error("{0}")] + InvalidParams(String), + #[error("{0}")] + Internal(String), +} + +pub(crate) async fn read_environment_config( + file_system: &dyn ExecutorFileSystem, + params: EnvironmentConfigReadParams, +) -> Result { + validate_paths(¶ms)?; + let cwd = params + .cwd + .to_abs_path() + .map_err(|error| ReadEnvironmentConfigError::InvalidParams(error.to_string()))?; + let codex_home = find_codex_home().map_err(|error| { + ReadEnvironmentConfigError::Internal(format!("failed to find Codex home: {error}")) + })?; + let layers = load_local_config_layers(file_system, codex_home.as_path(), &cwd) + .await + .map_err(|error| { + ReadEnvironmentConfigError::Internal(format!( + "failed to load executor-local config: {error}" + )) + })? + .project(¶ms.config_paths, ¶ms.requirements_paths); + + Ok(EnvironmentConfigReadResponse { + user_home_dir: dirs::home_dir() + .and_then(|home_dir| PathUri::from_host_native_path(home_dir).ok()), + codex_home_dir: PathUri::from_abs_path(&codex_home), + hostname: host_name(), + config: serialize_layer_stack(layers.config, |source| { + format_config_layer_source(source, CONFIG_TOML_FILE) + })?, + requirements: serialize_layer_stack(layers.requirements, ToString::to_string)?, + }) +} + +fn validate_paths(params: &EnvironmentConfigReadParams) -> Result<(), ReadEnvironmentConfigError> { + if params.config_paths.is_empty() && params.requirements_paths.is_empty() { + return Err(ReadEnvironmentConfigError::InvalidParams( + "at least one config or requirements path is required".to_string(), + )); + } + if params + .config_paths + .iter() + .chain(¶ms.requirements_paths) + .any(Vec::is_empty) + { + return Err(ReadEnvironmentConfigError::InvalidParams( + "TOML paths must contain at least one key segment".to_string(), + )); + } + Ok(()) +} + +fn serialize_layer_stack( + stack: LocalTomlLayerStack, + source_name: impl Fn(&S) -> String, +) -> Result { + let layers = stack + .layers + .into_iter() + .map(|layer| { + let toml = toml::to_string(&layer.toml).map_err(|error| { + ReadEnvironmentConfigError::Internal(format!( + "failed to serialize executor-local config: {error}" + )) + })?; + Ok(EnvironmentConfigLayer { + source: source_name(&layer.source), + base_dir: PathUri::from_abs_path(&layer.base_dir), + toml, + }) + }) + .collect::, ReadEnvironmentConfigError>>()?; + Ok(EnvironmentConfigLayerStack { + layers, + cloud_insertion_index: stack.cloud_insertion_index, + }) +} diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 9d846a305f..81f14f78e5 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -7,6 +7,7 @@ mod client_transport; mod connection; mod environment; mod environment_bootstrap; +mod environment_config; mod environment_provider; mod environment_registry; mod environment_toml; @@ -123,6 +124,10 @@ pub use protocol::CapabilityTextFile; pub use protocol::DiscoveredPluginFiles; pub use protocol::DiscoveredSkillFiles; pub use protocol::EnvironmentCapabilities; +pub use protocol::EnvironmentConfigLayer; +pub use protocol::EnvironmentConfigLayerStack; +pub use protocol::EnvironmentConfigReadParams; +pub use protocol::EnvironmentConfigReadResponse; pub use protocol::EnvironmentInfo; pub use protocol::EnvironmentStatus; pub use protocol::EnvironmentStatusKind; diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index 7b34a8a50e..4ca999f340 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -16,8 +16,12 @@ use crate::ExecServerRuntimePaths; use crate::client::http_client::PendingRouteAwareHttpBodyStream; use crate::client::http_client::RouteAwareHttpClient; use crate::client::http_client::RouteAwareHttpRequestRunner; +use crate::environment_config::ReadEnvironmentConfigError; +use crate::environment_config::read_environment_config; use crate::protocol::CapabilityRootsDiscoverParams; use crate::protocol::CapabilityRootsDiscoverResponse; +use crate::protocol::EnvironmentConfigReadParams; +use crate::protocol::EnvironmentConfigReadResponse; use crate::protocol::EnvironmentInfo; use crate::protocol::EnvironmentStatus; use crate::protocol::EnvironmentStatusKind; @@ -175,6 +179,19 @@ impl ExecServerHandler { Ok(EnvironmentInfo::local()) } + pub(crate) async fn environment_config_read( + &self, + params: EnvironmentConfigReadParams, + ) -> Result { + self.require_initialized_for("environment config")?; + read_environment_config(crate::LOCAL_FS.as_ref(), params) + .await + .map_err(|error| match error { + ReadEnvironmentConfigError::InvalidParams(message) => invalid_params(message), + ReadEnvironmentConfigError::Internal(message) => internal_error(message), + }) + } + pub(crate) fn environment_status(&self) -> Result { self.require_initialized_for("environment status")?; Ok(EnvironmentStatus { diff --git a/codex-rs/exec-server/src/server/registry.rs b/codex-rs/exec-server/src/server/registry.rs index e47f9a37c2..b1a1173085 100644 --- a/codex-rs/exec-server/src/server/registry.rs +++ b/codex-rs/exec-server/src/server/registry.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use crate::protocol::CAPABILITY_ROOTS_DISCOVER_METHOD; use crate::protocol::CapabilityRootsDiscoverParams; +use crate::protocol::ENVIRONMENT_CONFIG_READ_METHOD; use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::ENVIRONMENT_STATUS_METHOD; use crate::protocol::EXEC_METHOD; @@ -9,6 +10,7 @@ use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_SIGNAL_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; use crate::protocol::EXEC_WRITE_METHOD; +use crate::protocol::EnvironmentConfigReadParams; use crate::protocol::ExecParams; use crate::protocol::FS_CANONICALIZE_METHOD; use crate::protocol::FS_CLOSE_METHOD; @@ -74,6 +76,12 @@ pub(crate) fn build_router() -> RpcRouter { ENVIRONMENT_INFO_METHOD, |handler: Arc, _params: ()| async move { handler.environment_info() }, ); + router.request( + ENVIRONMENT_CONFIG_READ_METHOD, + |handler: Arc, params: EnvironmentConfigReadParams| async move { + handler.environment_config_read(params).await + }, + ); router.request( ENVIRONMENT_STATUS_METHOD, |handler: Arc, _params: ()| async move { handler.environment_status() }, diff --git a/codex-rs/exec-server/tests/common/exec_server.rs b/codex-rs/exec-server/tests/common/exec_server.rs index 5040e4926a..ea4f5b166c 100644 --- a/codex-rs/exec-server/tests/common/exec_server.rs +++ b/codex-rs/exec-server/tests/common/exec_server.rs @@ -32,7 +32,7 @@ const CONNECT_RETRY_INTERVAL: Duration = Duration::from_millis(25); const EVENT_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) struct ExecServerHarness { - _codex_home: TempDir, + codex_home: TempDir, _helper_paths: TestCodexHelperPaths, child: Child, websocket_url: String, @@ -104,7 +104,7 @@ where let websocket_url = read_listen_url_from_stdout(&mut child).await?; let (websocket, _) = connect_websocket_when_ready(&websocket_url).await?; Ok(ExecServerHarness { - _codex_home: codex_home, + codex_home, _helper_paths: helper_paths, child, websocket_url, @@ -114,6 +114,10 @@ where } impl ExecServerHarness { + pub(crate) fn codex_home(&self) -> &std::path::Path { + self.codex_home.path() + } + pub(crate) fn websocket_url(&self) -> &str { &self.websocket_url } diff --git a/codex-rs/exec-server/tests/environment_config.rs b/codex-rs/exec-server/tests/environment_config.rs new file mode 100644 index 0000000000..78d2d96c2c --- /dev/null +++ b/codex-rs/exec-server/tests/environment_config.rs @@ -0,0 +1,130 @@ +mod common; + +use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerSource; +use codex_config::format_config_layer_source; +use codex_config::loader::project_trust_key; +use codex_exec_server::Environment; +use codex_exec_server::EnvironmentConfigLayer; +use codex_exec_server::EnvironmentConfigLayerStack; +use codex_exec_server::EnvironmentConfigReadParams; +use codex_exec_server::EnvironmentConfigReadResponse; +use codex_exec_server::ExecServerError; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use common::exec_server::exec_server; +use pretty_assertions::assert_eq; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_environment_reads_projected_executor_config() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let codex_home = + AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(server.codex_home())?)?; + let config_file = codex_home.join(CONFIG_TOML_FILE); + let project = codex_home.join("project"); + let dot_codex = project.join(".codex"); + tokio::fs::create_dir_all(dot_codex.as_path()).await?; + tokio::fs::write(project.join(".project-root").as_path(), "").await?; + let project_key = toml::Value::String(project_trust_key(project.as_path())).to_string(); + tokio::fs::write( + &config_file, + format!( + "project_root_markers = [\".project-root\"]\n[projects.{project_key}]\ntrust_level = \"trusted\"" + ), + ) + .await?; + tokio::fs::write( + dot_codex.join(CONFIG_TOML_FILE).as_path(), + r#" +[future_environment] +relative_path = "./executor-relative" +unselected = "do not return" +"#, + ) + .await?; + + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; + let response = environment + .read_environment_config(EnvironmentConfigReadParams { + cwd: PathUri::from_abs_path(&project), + config_paths: vec![vec![ + "future_environment".to_string(), + "relative_path".to_string(), + ]], + requirements_paths: Vec::new(), + }) + .await?; + + let projected_toml = toml::toml! { + [future_environment] + relative_path = "./executor-relative" + }; + assert_eq!( + response, + EnvironmentConfigReadResponse { + user_home_dir: dirs::home_dir() + .and_then(|home_dir| PathUri::from_host_native_path(home_dir).ok()), + codex_home_dir: PathUri::from_abs_path(&codex_home), + hostname: codex_config::host_name(), + config: EnvironmentConfigLayerStack { + layers: vec![EnvironmentConfigLayer { + source: format_config_layer_source( + &ConfigLayerSource::Project { + dot_codex_folder: dot_codex.clone(), + }, + CONFIG_TOML_FILE, + ), + base_dir: PathUri::from_abs_path(&dot_codex), + toml: toml::to_string(&projected_toml)?, + }], + cloud_insertion_index: 0, + }, + requirements: EnvironmentConfigLayerStack { + layers: Vec::new(), + cloud_insertion_index: 0, + }, + } + ); + + server.shutdown().await?; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn environment_config_read_rejects_empty_selectors() -> anyhow::Result<()> { + let mut server = exec_server().await?; + let codex_home = + AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(server.codex_home())?)?; + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; + + for (config_paths, expected_message) in [ + ( + Vec::new(), + "at least one config or requirements path is required", + ), + ( + vec![Vec::new()], + "TOML paths must contain at least one key segment", + ), + ] { + let error = environment + .read_environment_config(EnvironmentConfigReadParams { + cwd: PathUri::from_abs_path(&codex_home), + config_paths, + requirements_paths: Vec::new(), + }) + .await + .expect_err("invalid selectors should fail"); + assert!( + matches!( + error, + ExecServerError::Server { code: -32602, ref message } + if message == expected_message + ), + "unexpected error: {error:?}" + ); + } + + server.shutdown().await?; + Ok(()) +}