Add executor-local config reads to the exec server (#37408)

## What changed

- Add the `environmentConfig/read` RPC for selecting literal TOML paths from executor-local config and requirements layers.
- Return layer precedence, cloud insertion points, source and base-directory metadata, and executor home and hostname context without normalizing path-bearing values.
- Expose the operation through both remote clients and local `Environment` instances, with invalid selectors reported as invalid parameters.

## Testing

- Cover projected remote config reads and rejection of empty selectors.

GitOrigin-RevId: fee15bf833de6bcb2058d405ed5b1d5d928d7218
This commit is contained in:
sayan-oai
2026-08-07 08:32:30 +00:00
committed by copyberry
parent 957f8eedee
commit 95c7265e84
12 changed files with 348 additions and 2 deletions
+3
View File
@@ -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",
@@ -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<Vec<String>>,
pub requirements_paths: Vec<Vec<String>>,
}
/// 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<PathUri>,
/// 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<String>,
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<EnvironmentConfigLayer>,
/// 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,
}
+2
View File
@@ -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::*;
+3
View File
@@ -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"
+10
View File
@@ -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<EnvironmentConfigReadResponse, ExecServerError> {
self.call(ENVIRONMENT_CONFIG_READ_METHOD, &params).await
}
pub async fn environment_status(&self) -> Result<EnvironmentStatus, ExecServerError> {
// Health checks only reuse an existing RPC connection and never initiate recovery.
let rpc_client = self.rpc_client_without_recovery()?;
+16
View File
@@ -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<EnvironmentConfigReadResponse, ExecServerError> {
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,
@@ -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<EnvironmentConfigReadResponse, ReadEnvironmentConfigError> {
validate_paths(&params)?;
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(&params.config_paths, &params.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(&params.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<S>(
stack: LocalTomlLayerStack<S>,
source_name: impl Fn(&S) -> String,
) -> Result<EnvironmentConfigLayerStack, ReadEnvironmentConfigError> {
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::<Result<Vec<_>, ReadEnvironmentConfigError>>()?;
Ok(EnvironmentConfigLayerStack {
layers,
cloud_insertion_index: stack.cloud_insertion_index,
})
}
+5
View File
@@ -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;
@@ -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<EnvironmentConfigReadResponse, JSONRPCErrorError> {
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<EnvironmentStatus, JSONRPCErrorError> {
self.require_initialized_for("environment status")?;
Ok(EnvironmentStatus {
@@ -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<ExecServerHandler> {
ENVIRONMENT_INFO_METHOD,
|handler: Arc<ExecServerHandler>, _params: ()| async move { handler.environment_info() },
);
router.request(
ENVIRONMENT_CONFIG_READ_METHOD,
|handler: Arc<ExecServerHandler>, params: EnvironmentConfigReadParams| async move {
handler.environment_config_read(params).await
},
);
router.request(
ENVIRONMENT_STATUS_METHOD,
|handler: Arc<ExecServerHandler>, _params: ()| async move { handler.environment_status() },
@@ -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
}
@@ -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(())
}