Files
codex/codex-rs/exec-server/src/environment.rs
T
Adam Perry @ OpenAI 841b5490b2 Preserve filesystem sandbox policy context when the cwd disappears (#46112)
## Why

Removing the selected working directory can prevent filesystem sandbox helpers from launching, even when the requested absolute paths remain accessible. Permission rules must stay anchored to the selected directory while those operations continue.

## What changed

- Require a policy `cwd` in `FileSystemSandboxContext` and launch filesystem helpers from the filesystem root while preserving the policy directory and workspace roots.
- Carry explicit `policyContext` in filesystem RPCs, preserving legacy wire fields and resolving omitted directories from older clients at executor ingress.
- Keep permission paths as executor file URIs and validate host compatibility where they are enforced.
- Bind Windows relative denial globs to the policy directory before changing the helper's launch directory, preserving home-relative patterns.

## Testing

Add regression coverage for `apply_patch` after working-directory removal, legacy RPC directory fallbacks, cross-platform permission URI transport, and Windows relative read denials. The patch regression verifies that an allowed file is updated while an explicitly denied file remains unreadable and unchanged.

GitOrigin-RevId: b0f4db722b27cb72ec129fc297c85732afac11f7
2026-09-17 04:37:54 +00:00

1909 lines
73 KiB
Rust

mod connect_options;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
use arc_swap::ArcSwapOption;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::shell_environment::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR;
use crate::CapabilityRootsDiscoverParams;
use crate::CapabilityRootsDiscoverResponse;
use crate::EnvironmentConfigReadParams;
use crate::EnvironmentConfigReadResponse;
use crate::ExecServerError;
use crate::ExecServerRuntimePaths;
use crate::ExecutorFileSystem;
use crate::HttpClient;
use crate::NoiseChannelIdentity;
use crate::NoiseRendezvousConnectProvider;
use crate::client::LazyRemoteExecServerClient;
use crate::client::http_client::RouteAwareHttpClient;
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;
use crate::environment_provider::EnvironmentProviderSnapshot;
use crate::environment_provider::normalize_exec_server_url;
use crate::environment_toml::environment_provider_from_codex_home;
use crate::local_file_system::LocalFileSystem;
use crate::local_process::LocalProcess;
use crate::process::ExecBackend;
use crate::protocol::EnvironmentInfo;
use crate::remote::NoiseRendezvousEnvironmentConfig;
use crate::remote_file_system::RemoteFileSystem;
use crate::remote_process::RemoteProcess;
use tokio::sync::watch;
use tokio_util::task::AbortOnDropHandle;
use tracing::Instrument;
use tracing::instrument::WithSubscriber;
#[path = "environment/accepted.rs"]
mod accepted;
pub use connect_options::RemoteEnvironmentOptions;
pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
pub const CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR: &str =
"CODEX_EXEC_SERVER_NOISE_REGISTRY_URL";
pub const CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR: &str =
"CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID";
pub const CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR: &str =
"CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID";
/// The current connection state for one concrete environment.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EnvironmentConnectionState {
/// An initialized exec-server connection is available.
Connected,
/// No initialized exec-server connection is currently available.
Disconnected,
}
/// Owns the execution/filesystem environments available to the Codex runtime.
///
/// `EnvironmentManager` is a shared registry for concrete environments. Its
/// default constructor preserves the legacy `CODEX_EXEC_SERVER_URL` behavior
/// while configured construction accepts a provider-supplied snapshot.
///
/// Setting `CODEX_EXEC_SERVER_URL=none` disables environment access by leaving
/// the default environment unset and omitting the local environment. Callers
/// use `default_environment().is_some()` as the signal for model-facing
/// shell/filesystem tool availability.
///
/// Ordinary remote environments begin connecting when added to the manager.
/// Provisioned remote environments connect only after they are selected for use;
/// their deferred transport waits for provisioning to complete first. Filesystem
/// and execution backends share the resulting startup and reconnect as needed.
#[derive(Debug)]
pub struct EnvironmentManager {
default_environment: Option<String>,
pub(super) environments: RwLock<HashMap<String, Arc<Environment>>>,
local_environment: Option<Arc<Environment>>,
local_runtime_paths: Option<ExecServerRuntimePaths>,
http_client_factory: HttpClientFactory,
}
/// Information supplied by the environment owner when an environment is ready.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct EnvironmentReadyInfo {
/// Ordered capability roots selected for this environment.
pub selected_capability_roots: Vec<SelectedCapabilityRoot>,
}
/// Maximum capability roots accepted from environment ready information.
pub const MAX_SELECTED_CAPABILITY_ROOTS: usize = 256;
pub const LOCAL_ENVIRONMENT_ID: &str = "local";
pub const REMOTE_ENVIRONMENT_ID: &str = "remote";
/// Non-mutating connection status observed by an environment owner.
///
/// Computing this status never starts, waits for, or reconnects an exec-server
/// transport. Already-ready remote environments may receive a fail-fast probe
/// over their existing connection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvironmentObservedStatus {
/// A local environment, or a remote environment whose existing connection answered a probe.
Ready,
/// The configured environment has no ready connection and no observed connection failure.
///
/// This includes lazy transports that have never been started and initial startup that has
/// not finished. Computing status does not start the environment or wait for startup.
Pending,
/// A connection attempt, prior connection, or fail-fast status probe observed a failure.
///
/// This does not promise that the failure is terminal: later normal environment use may
/// recover the connection. Computing status itself does not trigger recovery.
Disconnected {
/// Human-readable reason recorded by the failed connection attempt or probe.
error: String,
},
}
impl EnvironmentManager {
/// Builds a test-only manager without configured sandbox helper paths.
pub fn default_for_tests() -> Self {
Self {
default_environment: Some(LOCAL_ENVIRONMENT_ID.to_string()),
environments: RwLock::new(HashMap::from([(
LOCAL_ENVIRONMENT_ID.to_string(),
Arc::new(Environment::default_for_tests()),
)])),
local_environment: Some(Arc::new(Environment::default_for_tests())),
local_runtime_paths: None,
// Test-only construction has no application config from which to resolve proxy policy.
http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
}
}
/// Builds a manager with no configured execution environments.
pub fn without_environments(http_client_factory: HttpClientFactory) -> Self {
Self {
default_environment: None,
environments: RwLock::new(HashMap::new()),
local_environment: None,
local_runtime_paths: None,
http_client_factory,
}
}
/// Builds a test-only manager from a raw exec-server URL value.
pub async fn create_for_tests(
exec_server_url: Option<String>,
local_runtime_paths: Option<ExecServerRuntimePaths>,
) -> Self {
let provider = DefaultEnvironmentProvider::new(exec_server_url);
match Self::from_snapshot(
provider.snapshot_inner(),
local_runtime_paths,
// Test-only construction has no application config from which to resolve proxy policy.
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
) {
Ok(manager) => manager,
Err(err) => panic!("default provider should create valid environments: {err}"),
}
}
/// Discovers configured environments without starting remote connections.
///
/// If `CODEX_HOME/environments.toml` is present, it defines the configured
/// environments. Otherwise this preserves the legacy
/// `CODEX_EXEC_SERVER_URL` behavior.
pub async fn prepare_from_codex_home(
codex_home: impl AsRef<std::path::Path>,
) -> Result<PreparedEnvironmentManager, ExecServerError> {
let source = if let Some(config) = noise_environment_config_from_env()? {
PreparedEnvironmentSource::Noise(config)
} else {
let provider = environment_provider_from_codex_home(codex_home.as_ref())?;
PreparedEnvironmentSource::Snapshot(provider.snapshot().await?)
};
Ok(PreparedEnvironmentManager { source })
}
/// Builds a manager from `CODEX_HOME` with an explicit outbound HTTP policy.
pub async fn from_codex_home(
codex_home: impl AsRef<std::path::Path>,
local_runtime_paths: Option<ExecServerRuntimePaths>,
http_client_factory: HttpClientFactory,
) -> Result<Self, ExecServerError> {
Self::prepare_from_codex_home(codex_home)
.await?
.build(local_runtime_paths, http_client_factory)
}
/// Discovers environment-variable environments without starting connections.
pub async fn prepare_from_env() -> Result<PreparedEnvironmentManager, ExecServerError> {
let source = if let Some(config) = noise_environment_config_from_env()? {
PreparedEnvironmentSource::Noise(config)
} else {
let provider = DefaultEnvironmentProvider::from_env();
PreparedEnvironmentSource::Snapshot(provider.snapshot().await?)
};
Ok(PreparedEnvironmentManager { source })
}
/// Builds a manager from environment variables with an explicit outbound HTTP policy.
pub async fn from_env(
local_runtime_paths: Option<ExecServerRuntimePaths>,
http_client_factory: HttpClientFactory,
) -> Result<Self, ExecServerError> {
Self::prepare_from_env()
.await?
.build(local_runtime_paths, http_client_factory)
}
pub(crate) fn from_noise_environment_config(
config: NoiseRendezvousEnvironmentConfig,
local_runtime_paths: Option<ExecServerRuntimePaths>,
http_client_factory: HttpClientFactory,
) -> Result<Self, ExecServerError> {
let connect_provider = config.into_connect_provider(http_client_factory.clone())?;
let manager = Self {
default_environment: Some(REMOTE_ENVIRONMENT_ID.to_string()),
environments: RwLock::new(HashMap::new()),
local_environment: None,
local_runtime_paths,
http_client_factory,
};
let identity = noise_channel_identity()?;
let environment = Arc::new(Environment::remote_with_transport(
ExecServerTransportParams::NoiseRendezvous {
provider: connect_provider,
identity,
},
manager.local_runtime_paths.clone(),
manager.http_client_factory.clone(),
));
manager.insert_environment(REMOTE_ENVIRONMENT_ID.to_string(), environment)?;
Ok(manager)
}
/// Builds a test-only manager that keeps the provider default while also
/// allowing tests to select the local environment explicitly.
pub async fn create_for_tests_with_local(
exec_server_url: Option<String>,
local_runtime_paths: ExecServerRuntimePaths,
) -> Self {
let mut snapshot = DefaultEnvironmentProvider::new(exec_server_url).snapshot_inner();
snapshot.include_local = true;
match Self::from_snapshot(
snapshot,
Some(local_runtime_paths),
// Test-only construction has no application config from which to resolve proxy policy.
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
) {
Ok(manager) => manager,
Err(err) => panic!("test provider with local should create valid environments: {err}"),
}
}
pub(crate) fn from_snapshot(
snapshot: EnvironmentProviderSnapshot,
local_runtime_paths: Option<ExecServerRuntimePaths>,
http_client_factory: HttpClientFactory,
) -> Result<Self, ExecServerError> {
let EnvironmentProviderSnapshot {
environments,
default,
include_local,
} = snapshot;
let mut environment_map =
HashMap::with_capacity(environments.len() + usize::from(include_local));
let local_environment = if include_local {
let local_runtime_paths = local_runtime_paths.clone().ok_or_else(|| {
ExecServerError::Protocol(
"local environment requires configured runtime paths".to_string(),
)
})?;
let local_environment = Arc::new(Environment::local(
local_runtime_paths,
http_client_factory.clone(),
));
environment_map.insert(
LOCAL_ENVIRONMENT_ID.to_string(),
Arc::clone(&local_environment),
);
Some(local_environment)
} else {
None
};
for (id, transport) in environments {
if id.is_empty() {
return Err(ExecServerError::Protocol(
"environment id cannot be empty".to_string(),
));
}
if id == LOCAL_ENVIRONMENT_ID {
return Err(ExecServerError::Protocol(format!(
"environment id `{LOCAL_ENVIRONMENT_ID}` is reserved for EnvironmentManager"
)));
}
let environment = Environment::remote_with_transport(
transport,
/*local_runtime_paths*/ None,
http_client_factory.clone(),
);
if environment_map
.insert(id.clone(), Arc::new(environment))
.is_some()
{
return Err(ExecServerError::Protocol(format!(
"environment id `{id}` is duplicated"
)));
}
}
let default_environment = match default {
EnvironmentDefault::Disabled => None,
EnvironmentDefault::EnvironmentId(environment_id) => {
if !environment_map.contains_key(&environment_id) {
return Err(ExecServerError::Protocol(format!(
"default environment `{environment_id}` is not configured"
)));
}
Some(environment_id)
}
};
// The snapshot is valid; start connecting its remote environments in the background.
for environment in environment_map.values() {
environment.start_connecting();
}
Ok(Self {
default_environment,
environments: RwLock::new(environment_map),
local_environment,
local_runtime_paths,
http_client_factory,
})
}
/// Returns the default environment instance.
pub fn default_environment(&self) -> Option<Arc<Environment>> {
self.default_environment
.as_deref()
.and_then(|environment_id| self.get_environment(environment_id))
}
/// Returns the id of the default environment.
pub fn default_environment_id(&self) -> Option<&str> {
self.default_environment.as_deref()
}
/// Returns the ordered environment ids used for new thread startup.
pub fn default_environment_ids(&self) -> Vec<String> {
let Some(default_environment_id) = self.default_environment.as_ref() else {
return Vec::new();
};
let environments = self
.environments
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut environment_ids = Vec::with_capacity(environments.len());
environment_ids.push(default_environment_id.clone());
environment_ids.extend(
environments
.keys()
.filter(|environment_id| *environment_id != default_environment_id)
.cloned(),
);
environment_ids
}
/// Returns the local environment instance when one is configured.
pub fn try_local_environment(&self) -> Option<Arc<Environment>> {
self.local_environment.as_ref().map(Arc::clone)
}
/// Returns a named environment instance.
pub fn get_environment(&self, environment_id: &str) -> Option<Arc<Environment>> {
self.environments
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(environment_id)
.cloned()
}
/// Records a Ready or Failed provisioning result for an environment.
///
/// Ordinary environments are ignored. A provisioned environment keeps the same `Arc` from
/// Pending through Ready or Failed, and is created if the report arrives first.
///
/// Ready updates capability roots and can recover a failed provisioning attempt. Failed keeps
/// the first error until a Ready report arrives; a late failure cannot replace Ready. Invalid
/// Ready information fails an existing Pending environment but does not create a missing one.
///
/// This only updates provisioning. The connection starts when the environment is selected.
pub fn report_environment_provisioning_status(
&self,
environment_id: String,
readiness: Result<EnvironmentReadyInfo, String>,
provider_if_missing: Arc<dyn NoiseRendezvousConnectProvider>,
) -> Result<Option<Arc<Environment>>, ExecServerError> {
validate_environment_id(&environment_id)?;
let mut environments = self
.environments
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(environment) = environments.get(&environment_id).cloned() {
if environment.provisioning_status_tx.is_none() {
return Ok(None);
}
match readiness {
Ok(ready_info) => {
environment.apply_ready_report(&environment_id, ready_info)?;
}
Err(error) => {
environment.apply_error_report(&environment_id, error)?;
}
}
return Ok(Some(environment));
}
let environment = match readiness {
Ok(ready_info) => {
validate_environment_ready_info(&environment_id, &ready_info)?;
let environment = Arc::new(
self.provisioning_noise_environment(provider_if_missing, Some(Ok(())))?,
);
environment.ready_info.store(Some(Arc::new(ready_info)));
environment
}
Err(error) => Arc::new(
self.provisioning_noise_environment(provider_if_missing, Some(Err(error)))?,
),
};
environments.insert(environment_id, Arc::clone(&environment));
Ok(Some(environment))
}
/// Returns the outbound HTTP policy carried by this manager.
pub fn http_client_factory(&self) -> &HttpClientFactory {
&self.http_client_factory
}
/// Returns the current status of one named environment when it is configured.
pub async fn get_environment_status(
&self,
environment_id: &str,
) -> Option<EnvironmentObservedStatus> {
let environment = self.get_environment(environment_id)?;
Some(environment.status().await)
}
/// Adds or replaces a named remote environment without changing the
/// manager's default environment selection. Uses the default WebSocket
/// connection timeout when none is provided.
pub fn upsert_environment(
&self,
environment_id: String,
exec_server_url: String,
connect_timeout: Option<std::time::Duration>,
) -> Result<(), ExecServerError> {
self.upsert_environment_with_options(
environment_id,
RemoteEnvironmentOptions {
exec_server_url,
connect_timeout,
http_headers: HashMap::new(),
},
)
}
/// Adds or replaces a direct environment with trusted host-owned connection options.
///
/// Invalid headers and WebSocket-controlled handshake headers are rejected
/// before the environment is registered. Valid headers are retained for
/// automatic reconnects without changing existing URL-only environment APIs.
pub fn upsert_environment_with_options(
&self,
environment_id: String,
options: RemoteEnvironmentOptions,
) -> Result<(), ExecServerError> {
validate_environment_id(&environment_id)?;
let transport = options.into_transport_params()?;
let environment = Arc::new(Environment::remote_with_transport(
transport,
self.local_runtime_paths.clone(),
self.http_client_factory.clone(),
));
self.insert_environment(environment_id, environment)
}
/// Returns the stable environment for an ID, creating it as pending when absent.
pub fn materialize_pending_noise_environment(
&self,
environment_id: String,
provider: Arc<dyn NoiseRendezvousConnectProvider>,
) -> Result<Arc<Environment>, ExecServerError> {
validate_environment_id(&environment_id)?;
let mut environments = self
.environments
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(environment) = environments.get(&environment_id) {
if environment.provisioning_status_tx.is_none() {
return Err(ExecServerError::ProvisioningModeConflict { environment_id });
}
return Ok(Arc::clone(environment));
}
let environment =
Arc::new(self.provisioning_noise_environment(provider, /*initial_result*/ None)?);
environments.insert(environment_id, Arc::clone(&environment));
Ok(environment)
}
fn provisioning_noise_environment(
&self,
provider: Arc<dyn NoiseRendezvousConnectProvider>,
initial_result: Option<Result<(), String>>,
) -> Result<Environment, ExecServerError> {
let identity = noise_channel_identity()?;
let (provisioning_status_tx, provisioning_status_rx) = watch::channel(initial_result);
let mut environment = Environment::remote_with_transport(
ExecServerTransportParams::Deferred(Box::new(crate::client_api::Deferred {
readiness: provisioning_status_rx,
transport: ExecServerTransportParams::NoiseRendezvous { provider, identity },
})),
self.local_runtime_paths.clone(),
self.http_client_factory.clone(),
);
environment.provisioning_status_tx = Some(provisioning_status_tx);
Ok(environment)
}
fn insert_environment(
&self,
environment_id: String,
environment: Arc<Environment>,
) -> Result<(), ExecServerError> {
let replaced = {
let mut environments = self
.environments
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
environments.insert(environment_id, Arc::clone(&environment))
};
drop(replaced);
environment.start_connecting();
Ok(())
}
}
fn validate_environment_ready_info(
environment_id: &str,
ready_info: &EnvironmentReadyInfo,
) -> Result<(), ExecServerError> {
if ready_info.selected_capability_roots.len() > MAX_SELECTED_CAPABILITY_ROOTS {
return Err(ExecServerError::Protocol(format!(
"environment ready info contains more than {MAX_SELECTED_CAPABILITY_ROOTS} selected capability roots"
)));
}
let mut root_ids = HashSet::with_capacity(ready_info.selected_capability_roots.len());
for root in &ready_info.selected_capability_roots {
let CapabilityRootLocation::Environment {
environment_id: root_environment_id,
..
} = &root.location;
if root.id.trim().is_empty()
|| root_environment_id != environment_id
|| !root_ids.insert(root.id.as_str())
{
return Err(ExecServerError::Protocol(format!(
"selected capability roots must have unique non-empty IDs and belong to environment `{environment_id}`"
)));
}
}
Ok(())
}
fn noise_channel_identity() -> Result<NoiseChannelIdentity, ExecServerError> {
NoiseChannelIdentity::generate().map_err(|error| {
ExecServerError::Protocol(format!(
"failed to generate Noise harness identity: {error}"
))
})
}
fn validate_environment_id(environment_id: &str) -> Result<(), ExecServerError> {
if environment_id.is_empty() {
return Err(ExecServerError::Protocol(
"environment id cannot be empty".to_string(),
));
}
if environment_id == LOCAL_ENVIRONMENT_ID {
return Err(ExecServerError::Protocol(format!(
"environment id `{LOCAL_ENVIRONMENT_ID}` is reserved for EnvironmentManager"
)));
}
Ok(())
}
fn noise_environment_config_from_env()
-> Result<Option<NoiseRendezvousEnvironmentConfig>, ExecServerError> {
noise_environment_config_from_values(
optional_environment_value(CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR),
optional_environment_value(CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR),
optional_environment_value(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR),
optional_environment_value(CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR),
)
}
fn noise_environment_config_from_values(
registry_url: Option<String>,
environment_id: Option<String>,
auth_token: Option<String>,
chatgpt_account_id: Option<String>,
) -> Result<Option<NoiseRendezvousEnvironmentConfig>, ExecServerError> {
let (registry_url, environment_id, auth_token) =
match (registry_url, environment_id, auth_token) {
(None, None, None) => return Ok(None),
(Some(registry_url), Some(environment_id), Some(auth_token)) => {
(registry_url, environment_id, auth_token)
}
_ => {
return Err(ExecServerError::EnvironmentRegistryConfig(format!(
"Noise environment requires {CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR}, \
{CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR}, and \
{CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR}"
)));
}
};
NoiseRendezvousEnvironmentConfig::new(
registry_url,
environment_id,
auth_token,
chatgpt_account_id,
)
.map(Some)
}
fn optional_environment_value(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
/// Concrete execution/filesystem environment selected for a session.
///
/// This bundles the selected backend metadata together with the local runtime
/// paths used by filesystem helpers.
#[derive(Clone)]
pub struct Environment {
remote_client: Option<LazyRemoteExecServerClient>,
ready_info: Arc<ArcSwapOption<EnvironmentReadyInfo>>,
// No sender means an ordinary environment. A provisioned environment retains a sender whose
// value is None while Pending, Some(Ok(())) when Ready, or Some(Err(error)) when Failed.
provisioning_status_tx: Option<watch::Sender<Option<Result<(), String>>>>,
// Dropping the environment stops unfinished background startup work.
startup_task: Arc<Mutex<Option<AbortOnDropHandle<()>>>>,
exec_backend: Arc<dyn ExecBackend>,
filesystem: Arc<dyn ExecutorFileSystem>,
http_client: Arc<dyn HttpClient>,
local_runtime_paths: Option<ExecServerRuntimePaths>,
}
impl Environment {
/// Builds a test-only local environment without configured sandbox helper paths.
pub fn default_for_tests() -> Self {
Self {
remote_client: None,
ready_info: Arc::new(ArcSwapOption::empty()),
provisioning_status_tx: None,
startup_task: Arc::new(Mutex::new(None)),
exec_backend: Arc::new(LocalProcess::default()),
filesystem: Arc::new(LocalFileSystem::unsandboxed()),
// Test-only construction has no application config from which to resolve proxy policy.
http_client: Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new(
OutboundProxyPolicy::ReqwestDefault,
))),
local_runtime_paths: None,
}
}
}
impl std::fmt::Debug for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Environment").finish_non_exhaustive()
}
}
impl Environment {
/// Builds an environment using the caller's effective outbound HTTP policy.
pub fn create(
exec_server_url: Option<String>,
local_runtime_paths: ExecServerRuntimePaths,
http_client_factory: HttpClientFactory,
) -> Result<Self, ExecServerError> {
Self::create_inner(
exec_server_url,
Some(local_runtime_paths),
http_client_factory,
)
}
/// Builds a test-only environment without configured sandbox helper paths.
pub fn create_for_tests(exec_server_url: Option<String>) -> Result<Self, ExecServerError> {
Self::create_inner(
exec_server_url,
/*local_runtime_paths*/ None,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
}
/// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value and
/// local runtime paths used when creating local filesystem helpers.
fn create_inner(
exec_server_url: Option<String>,
local_runtime_paths: Option<ExecServerRuntimePaths>,
http_client_factory: HttpClientFactory,
) -> Result<Self, ExecServerError> {
let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url);
if disabled {
return Err(ExecServerError::Protocol(
"disabled mode does not create an Environment".to_string(),
));
}
Ok(match exec_server_url {
Some(exec_server_url) => Self::remote_with_transport(
ExecServerTransportParams::websocket_url(
exec_server_url,
DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT,
),
local_runtime_paths,
http_client_factory,
),
None => match local_runtime_paths {
Some(local_runtime_paths) => Self::local(local_runtime_paths, http_client_factory),
None => Self::default_for_tests(),
},
})
}
pub(crate) fn local(
local_runtime_paths: ExecServerRuntimePaths,
http_client_factory: HttpClientFactory,
) -> Self {
Self {
remote_client: None,
ready_info: Arc::new(ArcSwapOption::empty()),
provisioning_status_tx: None,
startup_task: Arc::new(Mutex::new(None)),
exec_backend: Arc::new(LocalProcess::with_local_runtime_paths(
local_runtime_paths.clone(),
)),
filesystem: Arc::new(LocalFileSystem::with_runtime_paths(
local_runtime_paths.clone(),
)),
http_client: Arc::new(RouteAwareHttpClient::new(http_client_factory)),
local_runtime_paths: Some(local_runtime_paths),
}
}
pub(crate) fn remote_with_transport(
remote_transport: ExecServerTransportParams,
local_runtime_paths: Option<ExecServerRuntimePaths>,
http_client_factory: HttpClientFactory,
) -> Self {
let client = LazyRemoteExecServerClient::new(remote_transport, http_client_factory);
Self::remote_with_client(client, local_runtime_paths)
}
pub(crate) fn remote_with_client(
client: LazyRemoteExecServerClient,
local_runtime_paths: Option<ExecServerRuntimePaths>,
) -> Self {
let exec_backend: Arc<dyn ExecBackend> = Arc::new(RemoteProcess::new(client.clone()));
let filesystem: Arc<dyn ExecutorFileSystem> =
Arc::new(RemoteFileSystem::new(client.clone()));
Self {
remote_client: Some(client.clone()),
ready_info: Arc::new(ArcSwapOption::empty()),
provisioning_status_tx: None,
startup_task: Arc::new(Mutex::new(None)),
exec_backend,
filesystem,
http_client: Arc::new(client),
local_runtime_paths,
}
}
pub fn is_remote(&self) -> bool {
self.remote_client.is_some()
}
fn apply_error_report(
&self,
environment_id: &str,
error: String,
) -> Result<(), ExecServerError> {
let Some(provisioning_status_tx) = &self.provisioning_status_tx else {
return Ok(());
};
let mut transition_error = None;
provisioning_status_tx.send_if_modified(|current| match current.as_ref() {
None => {
*current = Some(Err(error.clone()));
true
}
Some(Ok(())) => {
transition_error = Some(ExecServerError::Protocol(format!(
"environment `{environment_id}` is already ready, but a later provisioning report failed: {error}"
)));
false
}
Some(Err(_)) => false,
});
transition_error.map_or(Ok(()), Err)
}
fn apply_ready_report(
&self,
environment_id: &str,
ready_info: EnvironmentReadyInfo,
) -> Result<(), ExecServerError> {
let Some(provisioning_status_tx) = &self.provisioning_status_tx else {
return Ok(());
};
let mut transition_error = None;
provisioning_status_tx.send_if_modified(|current| {
if let Err(error) = validate_environment_ready_info(environment_id, &ready_info) {
let pending = current.is_none();
if pending {
*current = Some(Err(error.to_string()));
}
transition_error = Some(error);
return pending;
}
self.ready_info.store(Some(Arc::new(ready_info.clone())));
let was_ready = matches!(current, Some(Ok(())));
*current = Some(Ok(()));
!was_ready
});
transition_error.map_or(Ok(()), Err)
}
/// Returns a snapshot of the last accepted Ready report.
///
/// `None` means no Ready report has been accepted, including for ordinary environments.
/// A report with no capability roots is distinct from `None`. The snapshot does not change
/// when later reports arrive and does not indicate whether the connection is healthy.
pub fn last_ready_info(&self) -> Option<Arc<EnvironmentReadyInfo>> {
self.ready_info.load_full()
}
/// Returns the capability roots most recently reported for this environment.
pub fn selected_capability_roots(&self) -> Vec<SelectedCapabilityRoot> {
self.ready_info
.load()
.as_ref()
.map_or_else(Vec::new, |ready_info| {
ready_info.selected_capability_roots.clone()
})
}
/// Subscribes to the current connection state for this remote environment.
pub fn subscribe_connection_state(
&self,
) -> Option<watch::Receiver<EnvironmentConnectionState>> {
self.remote_client
.as_ref()
.map(LazyRemoteExecServerClient::subscribe_connection_state)
}
pub fn local_runtime_paths(&self) -> Option<&ExecServerRuntimePaths> {
self.local_runtime_paths.as_ref()
}
/// Returns environment information from the selected execution/filesystem environment.
/// Remote metadata is cached for the current client's lifetime.
#[tracing::instrument(
name = "exec_server.environment.info",
skip_all,
fields(remote = self.is_remote())
)]
pub async fn info(&self) -> Result<EnvironmentInfo, ExecServerError> {
match &self.remote_client {
Some(client) => client.environment_info().await,
None => Ok(EnvironmentInfo::local()),
}
}
/// Refresh the connection to the executor currently registered for this environment.
///
/// # Caller contract
///
/// Call after a planned replacement has registered and become available under the
/// same environment ID. This method does not provision or destroy executors, or
/// wait for the registry to identify a particular replacement. It requires a remote
/// Noise registry-backed environment; other environment types return an error.
///
/// # Session behavior
///
/// A fresh registry lookup determines whether the current session can be reused.
/// A changed executor key, or a failed or missing session, causes a fresh connection
/// without resuming the old session. Retirement cancels old recovery, fails its
/// outstanding work and process handles, and never replays commands. The environment
/// object and filesystem handle remain usable through the new connection.
/// A matching executor key preserves a session that has not failed, including one
/// that is recovering; the live readiness check rejects a recovering connection.
///
/// # Completion and errors
///
/// Success means the selected connection answered a live status RPC, not merely that
/// metadata was cached. Refresh bypasses the old session's recovery deadline, but
/// registry lookup, connection, and status RPC timeouts still apply. If the initial
/// registry lookup fails, refresh leaves the old session untouched; errors after
/// retirement do not restore it. Ordinary disconnect recovery is unchanged unless
/// refresh retires the session.
#[tracing::instrument(
name = "exec_server.environment.refresh_connection",
skip_all,
fields(remote = self.is_remote())
)]
pub async fn refresh_connection(&self) -> Result<(), ExecServerError> {
let client = self.remote_client.as_ref().ok_or_else(|| {
ExecServerError::Protocol(
"connection refresh requires a remote environment".to_string(),
)
})?;
client.refresh_connection().await
}
/// Fetches uncached metadata, connecting or waiting for recovery as needed.
// TODO: Remove after app-server migrates off of force_environment_info.
#[tracing::instrument(
name = "exec_server.environment.force_info",
skip_all,
fields(remote = self.is_remote())
)]
pub async fn force_info(&self) -> Result<EnvironmentInfo, ExecServerError> {
match &self.remote_client {
Some(client) => client.get().await?.force_environment_info().await,
None => Ok(EnvironmentInfo::local()),
}
}
/// 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,
params: CapabilityRootsDiscoverParams,
) -> Result<CapabilityRootsDiscoverResponse, ExecServerError> {
match &self.remote_client {
Some(client) => {
let mut connection_state = client.subscribe_connection_state();
let client = client.get().await?;
let discover = || async {
if params.roots.iter().any(|root| {
root.sandbox
.as_ref()
.is_some_and(crate::FileSystemSandboxContext::should_run_in_sandbox)
}) && !client
.environment_info()
.await?
.capabilities
.capability_discovery_sandbox
{
return Err(ExecServerError::Protocol(
"exec-server does not support sandboxed capability discovery"
.to_string(),
));
}
client.discover_capability_roots(params.clone()).await
};
match discover().await {
Err(error) if crate::client::is_retryable_recovery_error(&error) => {
tracing::warn!(%error, "replaying capability discovery after executor recovery");
let recovered =
tokio::time::timeout(std::time::Duration::from_secs(8), async {
while self.readiness_result().is_none_or(|result| result.is_err()) {
if connection_state.changed().await.is_err() {
return false;
}
}
true
})
.await
.unwrap_or(false);
if recovered {
discover().await
} else {
Err(error)
}
}
response => response,
}
}
None => crate::discover_capability_roots(self.filesystem.as_ref(), params)
.await
.map_err(|error| ExecServerError::Protocol(error.to_string())),
}
}
/// Starts connecting a remote environment without waiting for it.
/// Requires an active Tokio runtime when background startup is supported.
pub fn start_connecting(&self) {
let Some(client) = &self.remote_client else {
return;
};
let mut startup_task = self
.startup_task
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if startup_task.is_none() {
*startup_task = client.start_connecting();
}
}
/// Starts the initial connection after an environment is actually selected for use.
pub(crate) fn start_connecting_for_use(environment: &Arc<Self>) {
let Some(client) = &environment.remote_client else {
return;
};
let mut startup_task = environment
.startup_task
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if startup_task.is_none() {
let client = client.clone();
*startup_task = Some(AbortOnDropHandle::new(tokio::spawn(
async move {
if let Err(error) = client.wait_until_ready().await {
tracing::debug!(%error, "exec-server environment startup failed");
}
}
.in_current_span()
.with_current_subscriber(),
)));
}
}
/// Returns whether startup has completed, including a first connection made by refresh.
pub fn startup_finished(&self) -> bool {
self.remote_client
.as_ref()
.is_none_or(LazyRemoteExecServerClient::startup_finished)
}
/// Waits for initial startup, retrying a previous transient failure when possible.
#[tracing::instrument(
name = "exec_server.environment.wait_until_ready",
skip_all,
fields(remote = self.is_remote())
)]
pub async fn wait_until_ready(&self) -> Result<(), ExecServerError> {
match &self.remote_client {
Some(client) => client.wait_until_ready().await,
None => Ok(()),
}
}
/// Returns whether the environment can serve a request without waiting or reconnecting.
pub(crate) fn readiness_result(&self) -> Option<Result<(), ExecServerError>> {
match &self.remote_client {
Some(client) => client.readiness_result(),
None => Some(Ok(())),
}
}
/// Returns the environment's status without starting or recovering it.
///
/// Local environments are always ready. Remote environments with an
/// already-ready cached connection receive a fail-fast `environment/status`
/// probe; other remote states are returned from cached connection state
/// without waiting for startup or recovery.
pub async fn status(&self) -> EnvironmentObservedStatus {
match &self.remote_client {
Some(client) => client.status().await,
None => EnvironmentObservedStatus::Ready,
}
}
pub fn get_exec_backend(&self) -> Arc<dyn ExecBackend> {
Arc::clone(&self.exec_backend)
}
pub fn get_http_client(&self) -> Arc<dyn HttpClient> {
Arc::clone(&self.http_client)
}
pub fn get_filesystem(&self) -> Arc<dyn ExecutorFileSystem> {
Arc::clone(&self.filesystem)
}
/// Returns a filesystem view that fails instead of starting or waiting for a connection.
pub fn get_filesystem_without_reconnect(&self) -> Arc<dyn ExecutorFileSystem> {
match &self.remote_client {
Some(client) => Arc::new(RemoteFileSystem::new(client.fail_fast())),
None => Arc::clone(&self.filesystem),
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use super::Environment;
use super::EnvironmentManager;
use super::EnvironmentObservedStatus;
use super::LOCAL_ENVIRONMENT_ID;
use super::REMOTE_ENVIRONMENT_ID;
use super::noise_environment_config_from_values;
use crate::ExecServerRuntimePaths;
use crate::ProcessId;
use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT;
use crate::client_api::ExecServerTransportParams;
use crate::client_api::StdioExecServerCommand;
use crate::environment_provider::EnvironmentDefault;
use crate::environment_provider::EnvironmentProviderSnapshot;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_utils_path_uri::PathUri;
use pretty_assertions::assert_eq;
use tokio::net::TcpListener;
use tokio::time::timeout;
fn legacy_http_client_factory() -> HttpClientFactory {
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault)
}
fn prepared_websocket_environment() -> ExecServerTransportParams {
ExecServerTransportParams::websocket_url(
"ws://127.0.0.1:8765".to_string(),
DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT,
)
}
fn test_runtime_paths() -> ExecServerRuntimePaths {
ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths")
}
fn assert_local_environment_unavailable(manager: &EnvironmentManager) {
assert!(manager.try_local_environment().is_none());
}
#[test]
fn local_environment_info_includes_current_directory() {
let info = super::EnvironmentInfo::local();
assert_eq!(
info.cwd,
Some(
PathUri::from_host_native_path(std::env::current_dir().expect("current directory"))
.expect("cwd URI")
)
);
assert_eq!(
info.temp_dir,
PathUri::from_host_native_path(std::env::temp_dir()).ok()
);
}
#[tokio::test]
async fn noise_environment_config_selects_remote_as_default() {
let config = noise_environment_config_from_values(
Some("http://registry.example/api".to_string()),
Some("environment-requested".to_string()),
Some("registry-token".to_string()),
Some("workspace-123".to_string()),
)
.expect("parse noise environment configuration")
.expect("noise environment configuration");
let manager = EnvironmentManager::from_noise_environment_config(
config,
/*local_runtime_paths*/ None,
HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy),
)
.expect("build environment manager");
assert_eq!(
manager.http_client_factory().outbound_proxy_policy(),
OutboundProxyPolicy::RespectSystemProxy
);
assert_eq!(
manager.default_environment_id(),
Some(REMOTE_ENVIRONMENT_ID)
);
assert!(
manager
.default_environment()
.expect("remote environment")
.is_remote()
);
assert_local_environment_unavailable(&manager);
}
#[tokio::test]
async fn create_local_environment_does_not_connect() {
let environment = Environment::create(
/*exec_server_url*/ None,
test_runtime_paths(),
legacy_http_client_factory(),
)
.expect("create environment");
assert!(!environment.is_remote());
assert!(environment.info().await.is_ok());
}
#[tokio::test]
async fn environment_manager_normalizes_empty_url() {
let manager =
EnvironmentManager::create_for_tests(Some(String::new()), Some(test_runtime_paths()))
.await;
let environment = manager.default_environment().expect("default environment");
assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID));
assert!(Arc::ptr_eq(
&environment,
&manager
.get_environment(LOCAL_ENVIRONMENT_ID)
.expect("local environment")
));
assert!(Arc::ptr_eq(
&environment,
&manager.try_local_environment().expect("local environment")
));
assert!(manager.try_local_environment().is_some());
assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none());
assert!(!environment.is_remote());
}
#[tokio::test]
async fn disabled_environment_manager_has_no_default_or_local_environment() {
let manager = EnvironmentManager::without_environments(HttpClientFactory::new(
OutboundProxyPolicy::RespectSystemProxy,
));
assert!(manager.default_environment().is_none());
assert_eq!(manager.default_environment_id(), None);
assert_local_environment_unavailable(&manager);
assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none());
assert_eq!(
manager.http_client_factory().outbound_proxy_policy(),
OutboundProxyPolicy::RespectSystemProxy
);
}
#[tokio::test]
async fn environment_manager_creates_remote_environment_for_url() {
let manager = EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
Some(test_runtime_paths()),
)
.await;
let environment = manager.default_environment().expect("default environment");
assert_eq!(
manager.default_environment_id(),
Some(REMOTE_ENVIRONMENT_ID)
);
assert!(environment.is_remote());
assert!(Arc::ptr_eq(
&environment,
&manager
.get_environment(REMOTE_ENVIRONMENT_ID)
.expect("remote environment")
));
assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
assert_local_environment_unavailable(&manager);
}
#[tokio::test]
async fn environment_manager_default_environment_caches_environment() {
let manager = EnvironmentManager::default_for_tests();
let first = manager.default_environment().expect("default environment");
let second = manager.default_environment().expect("default environment");
assert!(Arc::ptr_eq(&first, &second));
assert!(Arc::ptr_eq(
&first.get_filesystem(),
&second.get_filesystem()
));
}
#[tokio::test]
async fn environment_manager_builds_from_snapshot() {
let snapshot = EnvironmentProviderSnapshot {
environments: vec![(
REMOTE_ENVIRONMENT_ID.to_string(),
prepared_websocket_environment(),
)],
default: EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()),
include_local: false,
};
let manager = EnvironmentManager::from_snapshot(
snapshot,
Some(test_runtime_paths()),
legacy_http_client_factory(),
)
.expect("environment manager");
assert_eq!(
manager.default_environment_id(),
Some(REMOTE_ENVIRONMENT_ID)
);
assert!(
manager
.get_environment(REMOTE_ENVIRONMENT_ID)
.expect("remote environment")
.is_remote()
);
assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
assert_local_environment_unavailable(&manager);
}
#[tokio::test]
async fn environment_manager_rejects_empty_environment_id() {
let snapshot = EnvironmentProviderSnapshot {
environments: vec![("".to_string(), prepared_websocket_environment())],
default: EnvironmentDefault::Disabled,
include_local: false,
};
let err = EnvironmentManager::from_snapshot(
snapshot,
Some(test_runtime_paths()),
legacy_http_client_factory(),
)
.expect_err("empty id should fail");
assert_eq!(
err.to_string(),
"exec-server protocol error: environment id cannot be empty"
);
}
#[tokio::test]
async fn environment_manager_rejects_provider_supplied_local_environment() {
let snapshot = EnvironmentProviderSnapshot {
environments: vec![(
LOCAL_ENVIRONMENT_ID.to_string(),
prepared_websocket_environment(),
)],
default: EnvironmentDefault::Disabled,
include_local: false,
};
let err = EnvironmentManager::from_snapshot(
snapshot,
Some(test_runtime_paths()),
legacy_http_client_factory(),
)
.expect_err("local id should fail");
assert_eq!(
err.to_string(),
"exec-server protocol error: environment id `local` is reserved for EnvironmentManager"
);
}
#[tokio::test]
async fn environment_manager_uses_explicit_provider_default() {
let snapshot = EnvironmentProviderSnapshot {
environments: vec![("devbox".to_string(), prepared_websocket_environment())],
default: EnvironmentDefault::EnvironmentId("devbox".to_string()),
include_local: true,
};
let manager = EnvironmentManager::from_snapshot(
snapshot,
Some(test_runtime_paths()),
legacy_http_client_factory(),
)
.expect("manager");
assert_eq!(manager.default_environment_id(), Some("devbox"));
assert_eq!(
manager.default_environment_ids(),
vec!["devbox".to_string(), LOCAL_ENVIRONMENT_ID.to_string()]
);
assert!(manager.default_environment().expect("default").is_remote());
}
#[tokio::test]
async fn environment_manager_disables_provider_default() {
let snapshot = EnvironmentProviderSnapshot {
environments: vec![("devbox".to_string(), prepared_websocket_environment())],
default: EnvironmentDefault::Disabled,
include_local: true,
};
let manager = EnvironmentManager::from_snapshot(
snapshot,
Some(test_runtime_paths()),
legacy_http_client_factory(),
)
.expect("manager");
assert_eq!(manager.default_environment_id(), None);
assert!(manager.default_environment().is_none());
assert!(Arc::ptr_eq(
&manager
.get_environment(LOCAL_ENVIRONMENT_ID)
.expect("local environment"),
&manager.try_local_environment().expect("local environment")
));
}
#[tokio::test]
async fn environment_manager_rejects_unknown_provider_default() {
let snapshot = EnvironmentProviderSnapshot {
environments: vec![("devbox".to_string(), prepared_websocket_environment())],
default: EnvironmentDefault::EnvironmentId("missing".to_string()),
include_local: true,
};
let err = EnvironmentManager::from_snapshot(
snapshot,
Some(test_runtime_paths()),
legacy_http_client_factory(),
)
.expect_err("unknown default should fail");
assert_eq!(
err.to_string(),
"exec-server protocol error: default environment `missing` is not configured"
);
}
#[tokio::test]
async fn environment_manager_includes_local_for_default_provider_without_url() {
let manager = EnvironmentManager::create_for_tests(
/*exec_server_url*/ None,
Some(test_runtime_paths()),
)
.await;
let environment = manager.default_environment().expect("default environment");
assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID));
assert!(Arc::ptr_eq(
&environment,
&manager
.get_environment(LOCAL_ENVIRONMENT_ID)
.expect("local environment")
));
assert!(Arc::ptr_eq(
&environment,
&manager.try_local_environment().expect("local environment")
));
assert!(!environment.is_remote());
}
#[tokio::test]
async fn environment_manager_carries_local_runtime_paths() {
let runtime_paths = test_runtime_paths();
let manager = EnvironmentManager::create_for_tests(
/*exec_server_url*/ None,
Some(runtime_paths.clone()),
)
.await;
let environment = manager.try_local_environment().expect("local environment");
assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths));
let manager = EnvironmentManager::create_for_tests(
/*exec_server_url*/ None,
Some(
environment
.local_runtime_paths()
.expect("local runtime paths")
.clone(),
),
)
.await;
let environment = manager.try_local_environment().expect("local environment");
assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths));
}
#[tokio::test]
async fn environment_manager_omits_default_provider_local_lookup_when_default_disabled() {
let manager = EnvironmentManager::create_for_tests(
Some("none".to_string()),
Some(test_runtime_paths()),
)
.await;
assert!(manager.default_environment().is_none());
assert_eq!(manager.default_environment_id(), None);
assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none());
assert_local_environment_unavailable(&manager);
}
#[tokio::test]
async fn environment_manager_snapshot_without_local_environment_disables_local_default() {
let mut snapshot = EnvironmentProviderSnapshot {
environments: Vec::new(),
default: EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()),
include_local: true,
};
snapshot.include_local = false;
snapshot.default = EnvironmentDefault::Disabled;
let manager = EnvironmentManager::from_snapshot(
snapshot,
/*local_runtime_paths*/ None,
legacy_http_client_factory(),
)
.expect("environment manager");
assert!(manager.default_environment().is_none());
assert_eq!(manager.default_environment_id(), None);
assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
assert_local_environment_unavailable(&manager);
}
#[tokio::test]
async fn get_environment_returns_none_for_unknown_id() {
let manager = EnvironmentManager::default_for_tests();
assert!(manager.get_environment("does-not-exist").is_none());
}
#[tokio::test]
async fn environment_manager_upserts_named_remote_environment() {
let manager = EnvironmentManager::without_environments(legacy_http_client_factory());
manager
.upsert_environment(
"executor-a".to_string(),
"ws://127.0.0.1:8765".to_string(),
/*connect_timeout*/ None,
)
.expect("remote environment");
let first = manager
.get_environment("executor-a")
.expect("first remote environment");
assert!(first.is_remote());
assert_eq!(manager.default_environment_id(), None);
manager
.upsert_environment(
"executor-a".to_string(),
"ws://127.0.0.1:9876".to_string(),
/*connect_timeout*/ None,
)
.expect("updated remote environment");
let second = manager
.get_environment("executor-a")
.expect("second remote environment");
assert!(second.is_remote());
assert!(!Arc::ptr_eq(&first, &second));
}
#[tokio::test]
async fn environment_manager_starts_remote_environment_when_upserted() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind websocket listener");
let manager = EnvironmentManager::without_environments(legacy_http_client_factory());
manager
.upsert_environment(
"executor-a".to_string(),
format!("ws://{}", listener.local_addr().expect("listener address")),
/*connect_timeout*/ None,
)
.expect("remote environment");
timeout(Duration::from_secs(5), listener.accept())
.await
.expect("environment should start connecting when registered")
.expect("accept connection");
}
#[tokio::test]
async fn environment_status_keeps_stdio_environment_pending() {
let environment = Environment::remote_with_transport(
ExecServerTransportParams::StdioCommand {
command: StdioExecServerCommand {
program: "codex-missing-exec-server-for-test".to_string(),
args: Vec::new(),
env: HashMap::new(),
cwd: None,
},
initialize_timeout: Duration::from_secs(1),
},
/*local_runtime_paths*/ None,
legacy_http_client_factory(),
);
assert_eq!(
environment.status().await,
EnvironmentObservedStatus::Pending
);
assert!(!environment.startup_finished());
}
#[tokio::test]
async fn environment_manager_leaves_stdio_environment_lazy() {
let transport = ExecServerTransportParams::StdioCommand {
command: StdioExecServerCommand {
program: "codex-missing-exec-server-for-test".to_string(),
args: Vec::new(),
env: HashMap::new(),
cwd: None,
},
initialize_timeout: Duration::from_secs(1),
};
let manager = EnvironmentManager::from_snapshot(
EnvironmentProviderSnapshot {
environments: vec![("stdio".to_string(), transport)],
default: EnvironmentDefault::Disabled,
include_local: false,
},
/*local_runtime_paths*/ None,
legacy_http_client_factory(),
)
.expect("environment manager");
let environment = manager.get_environment("stdio").expect("stdio environment");
assert!(!environment.startup_finished());
assert!(environment.wait_until_ready().await.is_err());
assert!(environment.startup_finished());
}
#[tokio::test]
async fn selected_capability_inspection_keeps_stdio_environment_lazy() {
use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
let transport = ExecServerTransportParams::StdioCommand {
command: StdioExecServerCommand {
program: "codex-missing-exec-server-for-test".to_string(),
args: Vec::new(),
env: HashMap::new(),
cwd: None,
},
initialize_timeout: Duration::from_secs(1),
};
let manager = EnvironmentManager::from_snapshot(
EnvironmentProviderSnapshot {
environments: vec![("stdio".to_string(), transport)],
default: EnvironmentDefault::Disabled,
include_local: false,
},
/*local_runtime_paths*/ None,
legacy_http_client_factory(),
)
.expect("environment manager");
let environment = manager.get_environment("stdio").expect("stdio environment");
let selected_root = SelectedCapabilityRoot {
id: "demo@1".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "stdio".to_string(),
path: PathUri::parse("file:///plugins/demo").expect("plugin path URI"),
},
};
let status =
manager.inspect_selected_capability_roots(std::slice::from_ref(&selected_root));
assert!(status.ready_roots.is_empty());
assert_eq!(status.warnings, Vec::<String>::new());
assert!(!environment.startup_finished());
let missing_root = SelectedCapabilityRoot {
id: "missing@1".to_string(),
location: CapabilityRootLocation::Environment {
environment_id: "missing".to_string(),
path: PathUri::parse("file:///plugins/missing").expect("missing plugin path URI"),
},
};
let status = manager.inspect_selected_capability_roots(&[missing_root]);
assert!(status.ready_roots.is_empty());
assert_eq!(
status.warnings,
vec![
"selected capability root `missing@1` references unavailable environment `missing`"
.to_string()
]
);
assert!(environment.wait_until_ready().await.is_err());
let status = manager.inspect_selected_capability_roots(&[selected_root]);
assert!(status.ready_roots.is_empty());
assert_eq!(status.warnings.len(), 1);
assert!(status.warnings[0].contains("environment `stdio` is unavailable"));
}
#[tokio::test]
async fn replacing_environment_stops_its_startup_task() {
let first_listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind first websocket listener");
let second_listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind second websocket listener");
let manager = EnvironmentManager::without_environments(legacy_http_client_factory());
manager
.upsert_environment(
"executor-a".to_string(),
format!(
"ws://{}",
first_listener.local_addr().expect("first listener address")
),
/*connect_timeout*/ None,
)
.expect("first remote environment");
let environment = manager
.get_environment("executor-a")
.expect("first remote environment");
let startup_abort = environment
.startup_task
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.expect("startup task")
.abort_handle();
assert!(!startup_abort.is_finished());
drop(environment);
manager
.upsert_environment(
"executor-a".to_string(),
format!(
"ws://{}",
second_listener
.local_addr()
.expect("second listener address")
),
/*connect_timeout*/ None,
)
.expect("replacement remote environment");
timeout(Duration::from_secs(1), async {
while !startup_abort.is_finished() {
tokio::task::yield_now().await;
}
})
.await
.expect("replacing the environment should cancel its startup task");
}
#[tokio::test]
async fn environment_manager_rejects_empty_remote_environment_url() {
let manager = EnvironmentManager::without_environments(legacy_http_client_factory());
let err = manager
.upsert_environment(
"executor-a".to_string(),
String::new(),
/*connect_timeout*/ None,
)
.expect_err("empty URL should fail");
assert_eq!(
err.to_string(),
"exec-server protocol error: remote environment requires an exec-server url"
);
}
#[tokio::test]
async fn default_environment_has_ready_local_executor() {
let environment = Environment::default_for_tests();
let response = environment
.get_exec_backend()
.start(crate::ExecParams {
metadata: Default::default(),
process_id: ProcessId::from("default-env-proc"),
argv: vec!["true".to_string()],
cwd: PathUri::from_host_native_path(
std::env::current_dir().expect("read current dir"),
)
.expect("cwd URI"),
shell_snapshot: None,
env_policy: None,
env: Default::default(),
tty: false,
pipe_stdin: false,
arg0: None,
sandbox: None,
enforce_managed_network: false,
managed_network: None,
network_proxy: None,
})
.await
.expect("start process");
assert_eq!(response.process.process_id().as_str(), "default-env-proc");
}
#[tokio::test]
async fn local_environment_passes_runtime_paths_to_exec_backend() {
let environment = Environment::local(test_runtime_paths(), legacy_http_client_factory());
#[cfg(unix)]
let uri = "file://server/share/checkout";
#[cfg(windows)]
let uri = "file:///usr/local/checkout";
let sandbox_cwd = PathUri::parse(uri).expect("non-native sandbox cwd URI");
let source = sandbox_cwd
.to_abs_path()
.expect_err("sandbox cwd should not be native to this host");
let sandbox = crate::FileSystemSandboxContext::from_permission_profile(
codex_protocol::models::PermissionProfile::workspace_write(),
sandbox_cwd.clone(),
);
let result = environment
.get_exec_backend()
.start(crate::ExecParams {
metadata: Default::default(),
process_id: ProcessId::from("local-sandbox-proc"),
argv: vec!["true".to_string()],
cwd: PathUri::from_host_native_path(
std::env::current_dir().expect("read current dir"),
)
.expect("cwd URI"),
shell_snapshot: None,
env_policy: None,
env: Default::default(),
tty: false,
pipe_stdin: false,
arg0: None,
sandbox: Some(sandbox),
enforce_managed_network: false,
managed_network: None,
network_proxy: None,
})
.await;
let Err(err) = result else {
panic!("sandbox cwd should be rejected after resolving runtime paths");
};
assert_eq!(
err.to_string(),
format!(
"exec-server rejected request (-32602): sandbox cwd URI `{sandbox_cwd}` is not valid on this exec-server host: {source}"
)
);
}
#[tokio::test]
async fn test_environment_rejects_sandboxed_filesystem_without_runtime_paths() {
let environment = Environment::default_for_tests();
let path = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(
std::env::current_exe().expect("current exe").as_path(),
)
.expect("absolute current exe");
let path = codex_utils_path_uri::PathUri::from_abs_path(&path);
let sandbox = crate::FileSystemSandboxContext::from_permission_profile(
codex_protocol::models::PermissionProfile::from_runtime_permissions(
&codex_protocol::permissions::FileSystemSandboxPolicy::restricted(Vec::new()),
codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
),
PathUri::from_host_native_path(std::env::current_dir().expect("read current dir"))
.expect("cwd URI"),
);
let err = environment
.get_filesystem()
.read_file(&path, Default::default(), Some(&sandbox))
.await
.expect_err("sandboxed read should require runtime paths");
assert_eq!(
err.to_string(),
"sandboxed filesystem operations require configured runtime paths"
);
}
}