Skip to main content

codex_exec_server/
environment.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::sync::Arc;
4use std::sync::Mutex;
5use std::sync::OnceLock;
6use std::sync::RwLock;
7
8use codex_protocol::capabilities::CapabilityRootLocation;
9use codex_protocol::capabilities::SelectedCapabilityRoot;
10use futures::FutureExt;
11
12use crate::CapabilityRootsDiscoverParams;
13use crate::CapabilityRootsDiscoverResponse;
14use crate::ExecServerError;
15use crate::ExecServerRuntimePaths;
16use crate::ExecutorFileSystem;
17use crate::HttpClient;
18use crate::NoiseChannelIdentity;
19use crate::NoiseRendezvousConnectProvider;
20use crate::client::LazyRemoteExecServerClient;
21use crate::client::http_client::ReqwestHttpClient;
22use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT;
23use crate::client_api::ExecServerTransportParams;
24use crate::environment_provider::DefaultEnvironmentProvider;
25use crate::environment_provider::EnvironmentDefault;
26use crate::environment_provider::EnvironmentProvider;
27use crate::environment_provider::EnvironmentProviderSnapshot;
28use crate::environment_provider::normalize_exec_server_url;
29use crate::environment_toml::environment_provider_from_codex_home;
30use crate::local_file_system::LocalFileSystem;
31use crate::local_process::LocalProcess;
32use crate::process::ExecBackend;
33use crate::protocol::EnvironmentInfo;
34use crate::remote::NoiseRendezvousEnvironmentConfig;
35use crate::remote_file_system::RemoteFileSystem;
36use crate::remote_process::RemoteProcess;
37use tokio::sync::oneshot;
38use tokio::sync::watch;
39use tokio_util::task::AbortOnDropHandle;
40
41pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
42pub const CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR: &str =
43    "CODEX_EXEC_SERVER_NOISE_REGISTRY_URL";
44pub const CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR: &str =
45    "CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID";
46pub const CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR: &str = "CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN";
47pub const CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR: &str =
48    "CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID";
49
50/// The current connection state for one concrete environment.
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum EnvironmentConnectionState {
53    /// An initialized exec-server connection is available.
54    Connected,
55    /// No initialized exec-server connection is currently available.
56    Disconnected,
57}
58
59/// Owns the execution/filesystem environments available to the Codex runtime.
60///
61/// `EnvironmentManager` is a shared registry for concrete environments. Its
62/// default constructor preserves the legacy `CODEX_EXEC_SERVER_URL` behavior
63/// while configured construction accepts a provider-supplied snapshot.
64///
65/// Setting `CODEX_EXEC_SERVER_URL=none` disables environment access by leaving
66/// the default environment unset and omitting the local environment. Callers
67/// use `default_environment().is_some()` as the signal for model-facing
68/// shell/filesystem tool availability.
69///
70/// Remote environments begin connecting when added to the manager. Their
71/// filesystem and execution backends share that startup result and reconnect
72/// after later disconnects as needed.
73#[derive(Debug)]
74pub struct EnvironmentManager {
75    default_environment: Option<String>,
76    pub(super) environments: RwLock<HashMap<String, Arc<Environment>>>,
77    local_environment: Option<Arc<Environment>>,
78    local_runtime_paths: Option<ExecServerRuntimePaths>,
79}
80
81/// Information supplied by the environment owner when a deferred environment is ready.
82#[derive(Clone, Debug, Default, Eq, PartialEq)]
83pub struct EnvironmentReadyInfo {
84    /// Ordered capability roots selected for this environment.
85    pub selected_capability_roots: Vec<SelectedCapabilityRoot>,
86}
87
88/// The one-shot capability to complete a deferred environment registration.
89#[must_use = "the deferred environment cannot connect until registration is completed"]
90pub struct DeferredEnvironmentRegistration {
91    completion: oneshot::Sender<Result<(), String>>,
92    environment_id: String,
93    ready_info: Arc<OnceLock<EnvironmentReadyInfo>>,
94}
95
96/// Maximum capability roots accepted from deferred environment ready information.
97pub const MAX_SELECTED_CAPABILITY_ROOTS: usize = 256;
98
99pub const LOCAL_ENVIRONMENT_ID: &str = "local";
100pub const REMOTE_ENVIRONMENT_ID: &str = "remote";
101
102/// Non-mutating connection status observed by an environment owner.
103///
104/// Computing this status never starts, waits for, or reconnects an exec-server
105/// transport. Already-ready remote environments may receive a fail-fast probe
106/// over their existing connection.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum EnvironmentObservedStatus {
109    /// A local environment, or a remote environment whose existing connection answered a probe.
110    Ready,
111    /// The configured environment has no ready connection and no observed connection failure.
112    ///
113    /// This includes lazy transports that have never been started and initial startup that has
114    /// not finished. Computing status does not start the environment or wait for startup.
115    Pending,
116    /// A connection attempt, prior connection, or fail-fast status probe observed a failure.
117    ///
118    /// This does not promise that the failure is terminal: later normal environment use may
119    /// recover the connection. Computing status itself does not trigger recovery.
120    Disconnected {
121        /// Human-readable reason recorded by the failed connection attempt or probe.
122        error: String,
123    },
124}
125
126impl EnvironmentManager {
127    /// Builds a test-only manager without configured sandbox helper paths.
128    pub fn default_for_tests() -> Self {
129        Self {
130            default_environment: Some(LOCAL_ENVIRONMENT_ID.to_string()),
131            environments: RwLock::new(HashMap::from([(
132                LOCAL_ENVIRONMENT_ID.to_string(),
133                Arc::new(Environment::default_for_tests()),
134            )])),
135            local_environment: Some(Arc::new(Environment::default_for_tests())),
136            local_runtime_paths: None,
137        }
138    }
139
140    /// Builds a manager with no configured execution environments.
141    pub fn without_environments() -> Self {
142        Self {
143            default_environment: None,
144            environments: RwLock::new(HashMap::new()),
145            local_environment: None,
146            local_runtime_paths: None,
147        }
148    }
149
150    /// Builds a test-only manager from a raw exec-server URL value.
151    pub async fn create_for_tests(
152        exec_server_url: Option<String>,
153        local_runtime_paths: Option<ExecServerRuntimePaths>,
154    ) -> Self {
155        Self::from_default_provider_url(exec_server_url, local_runtime_paths).await
156    }
157
158    /// Builds a manager from `CODEX_HOME` and local runtime paths used when
159    /// creating local filesystem helpers.
160    ///
161    /// If `CODEX_HOME/environments.toml` is present, it defines the configured
162    /// environments. Otherwise this preserves the legacy
163    /// `CODEX_EXEC_SERVER_URL` behavior.
164    pub async fn from_codex_home(
165        codex_home: impl AsRef<std::path::Path>,
166        local_runtime_paths: Option<ExecServerRuntimePaths>,
167    ) -> Result<Self, ExecServerError> {
168        if let Some(config) = noise_environment_config_from_env()? {
169            return Self::from_noise_environment_config(config, local_runtime_paths);
170        }
171        let provider = environment_provider_from_codex_home(codex_home.as_ref())?;
172        Self::from_snapshot(provider.snapshot().await?, local_runtime_paths)
173    }
174
175    /// Builds a manager from the legacy environment-variable provider without
176    /// reading user config files from `CODEX_HOME`.
177    pub async fn from_env(
178        local_runtime_paths: Option<ExecServerRuntimePaths>,
179    ) -> Result<Self, ExecServerError> {
180        if let Some(config) = noise_environment_config_from_env()? {
181            return Self::from_noise_environment_config(config, local_runtime_paths);
182        }
183        let provider = DefaultEnvironmentProvider::from_env();
184        Self::from_snapshot(provider.snapshot().await?, local_runtime_paths)
185    }
186
187    async fn from_default_provider_url(
188        exec_server_url: Option<String>,
189        local_runtime_paths: Option<ExecServerRuntimePaths>,
190    ) -> Self {
191        let provider = DefaultEnvironmentProvider::new(exec_server_url);
192        match Self::from_snapshot(provider.snapshot_inner(), local_runtime_paths) {
193            Ok(manager) => manager,
194            Err(err) => panic!("default provider should create valid environments: {err}"),
195        }
196    }
197
198    fn from_noise_environment_config(
199        config: NoiseRendezvousEnvironmentConfig,
200        local_runtime_paths: Option<ExecServerRuntimePaths>,
201    ) -> Result<Self, ExecServerError> {
202        let manager = Self {
203            default_environment: Some(REMOTE_ENVIRONMENT_ID.to_string()),
204            environments: RwLock::new(HashMap::new()),
205            local_environment: None,
206            local_runtime_paths,
207        };
208        manager.upsert_noise_environment(
209            REMOTE_ENVIRONMENT_ID.to_string(),
210            config.connect_provider(),
211        )?;
212        Ok(manager)
213    }
214
215    /// Builds a test-only manager that keeps the provider default while also
216    /// allowing tests to select the local environment explicitly.
217    pub async fn create_for_tests_with_local(
218        exec_server_url: Option<String>,
219        local_runtime_paths: ExecServerRuntimePaths,
220    ) -> Self {
221        let mut snapshot = DefaultEnvironmentProvider::new(exec_server_url).snapshot_inner();
222        snapshot.include_local = true;
223        match Self::from_snapshot(snapshot, Some(local_runtime_paths)) {
224            Ok(manager) => manager,
225            Err(err) => panic!("test provider with local should create valid environments: {err}"),
226        }
227    }
228
229    fn from_snapshot(
230        snapshot: EnvironmentProviderSnapshot,
231        local_runtime_paths: Option<ExecServerRuntimePaths>,
232    ) -> Result<Self, ExecServerError> {
233        let EnvironmentProviderSnapshot {
234            environments,
235            default,
236            include_local,
237        } = snapshot;
238        let mut environment_map =
239            HashMap::with_capacity(environments.len() + usize::from(include_local));
240        let local_environment = if include_local {
241            let local_runtime_paths = local_runtime_paths.clone().ok_or_else(|| {
242                ExecServerError::Protocol(
243                    "local environment requires configured runtime paths".to_string(),
244                )
245            })?;
246            let local_environment = Arc::new(Environment::local(local_runtime_paths));
247            environment_map.insert(
248                LOCAL_ENVIRONMENT_ID.to_string(),
249                Arc::clone(&local_environment),
250            );
251            Some(local_environment)
252        } else {
253            None
254        };
255        for (id, environment) in environments {
256            if id.is_empty() {
257                return Err(ExecServerError::Protocol(
258                    "environment id cannot be empty".to_string(),
259                ));
260            }
261            if id == LOCAL_ENVIRONMENT_ID {
262                return Err(ExecServerError::Protocol(format!(
263                    "environment id `{LOCAL_ENVIRONMENT_ID}` is reserved for EnvironmentManager"
264                )));
265            }
266            if environment_map
267                .insert(id.clone(), Arc::new(environment))
268                .is_some()
269            {
270                return Err(ExecServerError::Protocol(format!(
271                    "environment id `{id}` is duplicated"
272                )));
273            }
274        }
275        let default_environment = match default {
276            EnvironmentDefault::Disabled => None,
277            EnvironmentDefault::EnvironmentId(environment_id) => {
278                if !environment_map.contains_key(&environment_id) {
279                    return Err(ExecServerError::Protocol(format!(
280                        "default environment `{environment_id}` is not configured"
281                    )));
282                }
283                Some(environment_id)
284            }
285        };
286        // The snapshot is valid; start connecting its remote environments in the background.
287        for environment in environment_map.values() {
288            environment.start_connecting();
289        }
290        Ok(Self {
291            default_environment,
292            environments: RwLock::new(environment_map),
293            local_environment,
294            local_runtime_paths,
295        })
296    }
297
298    /// Returns the default environment instance.
299    pub fn default_environment(&self) -> Option<Arc<Environment>> {
300        self.default_environment
301            .as_deref()
302            .and_then(|environment_id| self.get_environment(environment_id))
303    }
304
305    /// Returns the id of the default environment.
306    pub fn default_environment_id(&self) -> Option<&str> {
307        self.default_environment.as_deref()
308    }
309
310    /// Returns the ordered environment ids used for new thread startup.
311    pub fn default_environment_ids(&self) -> Vec<String> {
312        let Some(default_environment_id) = self.default_environment.as_ref() else {
313            return Vec::new();
314        };
315        let environments = self
316            .environments
317            .read()
318            .unwrap_or_else(std::sync::PoisonError::into_inner);
319        let mut environment_ids = Vec::with_capacity(environments.len());
320        environment_ids.push(default_environment_id.clone());
321        environment_ids.extend(
322            environments
323                .keys()
324                .filter(|environment_id| *environment_id != default_environment_id)
325                .cloned(),
326        );
327        environment_ids
328    }
329
330    /// Returns the local environment instance when one is configured.
331    pub fn try_local_environment(&self) -> Option<Arc<Environment>> {
332        self.local_environment.as_ref().map(Arc::clone)
333    }
334
335    /// Returns a named environment instance.
336    pub fn get_environment(&self, environment_id: &str) -> Option<Arc<Environment>> {
337        self.environments
338            .read()
339            .unwrap_or_else(std::sync::PoisonError::into_inner)
340            .get(environment_id)
341            .cloned()
342    }
343
344    /// Returns the current status of one named environment when it is configured.
345    pub async fn get_environment_status(
346        &self,
347        environment_id: &str,
348    ) -> Option<EnvironmentObservedStatus> {
349        let environment = self.get_environment(environment_id)?;
350        Some(environment.status().await)
351    }
352
353    /// Adds or replaces a named remote environment without changing the
354    /// manager's default environment selection. Uses the default WebSocket
355    /// connection timeout when none is provided.
356    pub fn upsert_environment(
357        &self,
358        environment_id: String,
359        exec_server_url: String,
360        connect_timeout: Option<std::time::Duration>,
361    ) -> Result<(), ExecServerError> {
362        validate_environment_id(&environment_id)?;
363        let exec_server_url = validate_remote_exec_server_url(exec_server_url)?;
364        let environment = Arc::new(Environment::remote_with_transport(
365            ExecServerTransportParams::websocket_url(
366                exec_server_url,
367                connect_timeout.unwrap_or(DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT),
368            ),
369            self.local_runtime_paths.clone(),
370        ));
371        self.insert_environment(environment_id, environment);
372        Ok(())
373    }
374
375    /// Adds or replaces a Noise rendezvous environment that will become ready later.
376    pub fn register_deferred_noise_environment(
377        &self,
378        environment_id: String,
379        provider: Arc<dyn NoiseRendezvousConnectProvider>,
380    ) -> Result<DeferredEnvironmentRegistration, ExecServerError> {
381        validate_environment_id(&environment_id)?;
382        let identity = noise_channel_identity()?;
383        let (completion, readiness) = oneshot::channel();
384        let ready_info = Arc::new(OnceLock::new());
385        let mut environment = Environment::remote_with_transport(
386            ExecServerTransportParams::Deferred(Box::new(crate::client_api::Deferred {
387                readiness: readiness.shared(),
388                transport: ExecServerTransportParams::NoiseRendezvous { provider, identity },
389            })),
390            self.local_runtime_paths.clone(),
391        );
392        environment.ready_info = Some(Arc::clone(&ready_info));
393        let environment = Arc::new(environment);
394        self.insert_environment(environment_id.clone(), environment);
395        Ok(DeferredEnvironmentRegistration {
396            completion,
397            environment_id,
398            ready_info,
399        })
400    }
401
402    /// Adds or replaces a named remote environment that connects through an
403    /// authenticated, end-to-end encrypted rendezvous stream.
404    ///
405    /// The provider is retained so every reconnect obtains fresh authorization.
406    /// This transport never falls back to the URL-only remote environment path.
407    pub fn upsert_noise_environment(
408        &self,
409        environment_id: String,
410        provider: Arc<dyn NoiseRendezvousConnectProvider>,
411    ) -> Result<(), ExecServerError> {
412        validate_environment_id(&environment_id)?;
413        let identity = noise_channel_identity()?;
414        let environment = Arc::new(Environment::remote_with_transport(
415            ExecServerTransportParams::NoiseRendezvous { provider, identity },
416            self.local_runtime_paths.clone(),
417        ));
418        self.insert_environment(environment_id, environment);
419        Ok(())
420    }
421
422    fn insert_environment(&self, environment_id: String, environment: Arc<Environment>) {
423        self.environments
424            .write()
425            .unwrap_or_else(std::sync::PoisonError::into_inner)
426            .insert(environment_id, Arc::clone(&environment));
427        environment.start_connecting();
428    }
429}
430
431impl DeferredEnvironmentRegistration {
432    /// Completes provisioning with ready information or a terminal error message.
433    pub fn complete(
434        self,
435        result: Result<EnvironmentReadyInfo, String>,
436    ) -> Result<(), ExecServerError> {
437        let result = match result {
438            Ok(ready_info) => {
439                if ready_info.selected_capability_roots.len() > MAX_SELECTED_CAPABILITY_ROOTS {
440                    let error = ExecServerError::Protocol(format!(
441                        "environment ready info contains more than {MAX_SELECTED_CAPABILITY_ROOTS} selected capability roots"
442                    ));
443                    let _ = self.completion.send(Err(error.to_string()));
444                    return Err(error);
445                }
446                let mut root_ids =
447                    HashSet::with_capacity(ready_info.selected_capability_roots.len());
448                for root in &ready_info.selected_capability_roots {
449                    let CapabilityRootLocation::Environment { environment_id, .. } = &root.location;
450                    if root.id.trim().is_empty()
451                        || environment_id != &self.environment_id
452                        || !root_ids.insert(root.id.as_str())
453                    {
454                        let error = ExecServerError::Protocol(format!(
455                            "selected capability roots must have unique non-empty IDs and belong to environment `{}`",
456                            self.environment_id
457                        ));
458                        let _ = self.completion.send(Err(error.to_string()));
459                        return Err(error);
460                    }
461                }
462                if self.ready_info.set(ready_info).is_err() {
463                    let error = ExecServerError::Protocol(
464                        "deferred environment ready info was already set".to_string(),
465                    );
466                    let _ = self.completion.send(Err(error.to_string()));
467                    return Err(error);
468                }
469                Ok(())
470            }
471            Err(message) => Err(message),
472        };
473        self.completion.send(result).map_err(|_| {
474            ExecServerError::Disconnected("deferred environment registration is inactive".into())
475        })
476    }
477}
478
479fn noise_channel_identity() -> Result<NoiseChannelIdentity, ExecServerError> {
480    NoiseChannelIdentity::generate().map_err(|error| {
481        ExecServerError::Protocol(format!(
482            "failed to generate Noise harness identity: {error}"
483        ))
484    })
485}
486
487fn validate_environment_id(environment_id: &str) -> Result<(), ExecServerError> {
488    if environment_id.is_empty() {
489        return Err(ExecServerError::Protocol(
490            "environment id cannot be empty".to_string(),
491        ));
492    }
493    Ok(())
494}
495
496fn validate_remote_exec_server_url(exec_server_url: String) -> Result<String, ExecServerError> {
497    let (exec_server_url, disabled) = normalize_exec_server_url(Some(exec_server_url));
498    if disabled {
499        return Err(ExecServerError::Protocol(
500            "remote environment cannot use disabled exec-server url".to_string(),
501        ));
502    }
503    exec_server_url.ok_or_else(|| {
504        ExecServerError::Protocol("remote environment requires an exec-server url".to_string())
505    })
506}
507
508fn noise_environment_config_from_env()
509-> Result<Option<NoiseRendezvousEnvironmentConfig>, ExecServerError> {
510    noise_environment_config_from_values(
511        optional_environment_value(CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR),
512        optional_environment_value(CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR),
513        optional_environment_value(CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR),
514        optional_environment_value(CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR),
515    )
516}
517
518fn noise_environment_config_from_values(
519    registry_url: Option<String>,
520    environment_id: Option<String>,
521    auth_token: Option<String>,
522    chatgpt_account_id: Option<String>,
523) -> Result<Option<NoiseRendezvousEnvironmentConfig>, ExecServerError> {
524    let (registry_url, environment_id, auth_token) =
525        match (registry_url, environment_id, auth_token) {
526            (None, None, None) => return Ok(None),
527            (Some(registry_url), Some(environment_id), Some(auth_token)) => {
528                (registry_url, environment_id, auth_token)
529            }
530            _ => {
531                return Err(ExecServerError::EnvironmentRegistryConfig(format!(
532                    "Noise environment requires {CODEX_EXEC_SERVER_NOISE_REGISTRY_URL_ENV_VAR}, \
533{CODEX_EXEC_SERVER_NOISE_ENVIRONMENT_ID_ENV_VAR}, and \
534{CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR}"
535                )));
536            }
537        };
538
539    let config = NoiseRendezvousEnvironmentConfig::new(
540        registry_url,
541        environment_id,
542        auth_token,
543        chatgpt_account_id,
544    )?;
545    Ok(Some(config))
546}
547
548fn optional_environment_value(name: &str) -> Option<String> {
549    std::env::var(name)
550        .ok()
551        .map(|value| value.trim().to_string())
552        .filter(|value| !value.is_empty())
553}
554
555/// Concrete execution/filesystem environment selected for a session.
556///
557/// This bundles the selected backend metadata together with the local runtime
558/// paths used by filesystem helpers.
559#[derive(Clone)]
560pub struct Environment {
561    remote_client: Option<LazyRemoteExecServerClient>,
562    ready_info: Option<Arc<OnceLock<EnvironmentReadyInfo>>>,
563    // Dropping the environment stops unfinished background startup work.
564    startup_task: Arc<Mutex<Option<AbortOnDropHandle<()>>>>,
565    exec_backend: Arc<dyn ExecBackend>,
566    filesystem: Arc<dyn ExecutorFileSystem>,
567    http_client: Arc<dyn HttpClient>,
568    local_runtime_paths: Option<ExecServerRuntimePaths>,
569}
570
571impl Environment {
572    /// Builds a test-only local environment without configured sandbox helper paths.
573    pub fn default_for_tests() -> Self {
574        Self {
575            remote_client: None,
576            ready_info: None,
577            startup_task: Arc::new(Mutex::new(None)),
578            exec_backend: Arc::new(LocalProcess::default()),
579            filesystem: Arc::new(LocalFileSystem::unsandboxed()),
580            http_client: Arc::new(ReqwestHttpClient),
581            local_runtime_paths: None,
582        }
583    }
584}
585
586impl std::fmt::Debug for Environment {
587    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
588        f.debug_struct("Environment").finish_non_exhaustive()
589    }
590}
591
592impl Environment {
593    /// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value.
594    pub fn create(
595        exec_server_url: Option<String>,
596        local_runtime_paths: ExecServerRuntimePaths,
597    ) -> Result<Self, ExecServerError> {
598        Self::create_inner(exec_server_url, Some(local_runtime_paths))
599    }
600
601    /// Builds a test-only environment without configured sandbox helper paths.
602    pub fn create_for_tests(exec_server_url: Option<String>) -> Result<Self, ExecServerError> {
603        Self::create_inner(exec_server_url, /*local_runtime_paths*/ None)
604    }
605
606    /// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value and
607    /// local runtime paths used when creating local filesystem helpers.
608    fn create_inner(
609        exec_server_url: Option<String>,
610        local_runtime_paths: Option<ExecServerRuntimePaths>,
611    ) -> Result<Self, ExecServerError> {
612        let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url);
613        if disabled {
614            return Err(ExecServerError::Protocol(
615                "disabled mode does not create an Environment".to_string(),
616            ));
617        }
618
619        Ok(match exec_server_url {
620            Some(exec_server_url) => Self::remote_inner(exec_server_url, local_runtime_paths),
621            None => match local_runtime_paths {
622                Some(local_runtime_paths) => Self::local(local_runtime_paths),
623                None => Self::default_for_tests(),
624            },
625        })
626    }
627
628    pub(crate) fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self {
629        Self {
630            remote_client: None,
631            ready_info: None,
632            startup_task: Arc::new(Mutex::new(None)),
633            exec_backend: Arc::new(LocalProcess::with_local_runtime_paths(
634                local_runtime_paths.clone(),
635            )),
636            filesystem: Arc::new(LocalFileSystem::with_runtime_paths(
637                local_runtime_paths.clone(),
638            )),
639            http_client: Arc::new(ReqwestHttpClient),
640            local_runtime_paths: Some(local_runtime_paths),
641        }
642    }
643
644    pub(crate) fn remote_inner(
645        exec_server_url: String,
646        local_runtime_paths: Option<ExecServerRuntimePaths>,
647    ) -> Self {
648        Self::remote_with_transport(
649            ExecServerTransportParams::websocket_url(
650                exec_server_url,
651                DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT,
652            ),
653            local_runtime_paths,
654        )
655    }
656
657    pub(crate) fn remote_with_transport(
658        remote_transport: ExecServerTransportParams,
659        local_runtime_paths: Option<ExecServerRuntimePaths>,
660    ) -> Self {
661        let client = LazyRemoteExecServerClient::new(remote_transport);
662        let exec_backend: Arc<dyn ExecBackend> = Arc::new(RemoteProcess::new(client.clone()));
663        let filesystem: Arc<dyn ExecutorFileSystem> =
664            Arc::new(RemoteFileSystem::new(client.clone()));
665
666        Self {
667            remote_client: Some(client.clone()),
668            ready_info: None,
669            startup_task: Arc::new(Mutex::new(None)),
670            exec_backend,
671            filesystem,
672            http_client: Arc::new(client),
673            local_runtime_paths,
674        }
675    }
676
677    pub fn is_remote(&self) -> bool {
678        self.remote_client.is_some()
679    }
680
681    /// Returns capability roots supplied with the deferred environment's ready signal.
682    pub fn selected_capability_roots(&self) -> &[SelectedCapabilityRoot] {
683        self.ready_info
684            .as_ref()
685            .and_then(|ready_info| ready_info.get())
686            .map_or(&[], |ready_info| {
687                ready_info.selected_capability_roots.as_slice()
688            })
689    }
690
691    /// Subscribes to the current connection state for this remote environment.
692    pub fn subscribe_connection_state(
693        &self,
694    ) -> Option<watch::Receiver<EnvironmentConnectionState>> {
695        self.remote_client
696            .as_ref()
697            .map(LazyRemoteExecServerClient::subscribe_connection_state)
698    }
699
700    pub fn local_runtime_paths(&self) -> Option<&ExecServerRuntimePaths> {
701        self.local_runtime_paths.as_ref()
702    }
703
704    /// Returns environment information from the selected execution/filesystem environment.
705    pub async fn info(&self) -> Result<EnvironmentInfo, ExecServerError> {
706        match &self.remote_client {
707            Some(client) => client.environment_info().await,
708            None => Ok(EnvironmentInfo::local()),
709        }
710    }
711
712    /// Discovers plugin and skill manifests through the environment's high-level discovery API.
713    pub async fn discover_capability_roots(
714        &self,
715        params: CapabilityRootsDiscoverParams,
716    ) -> Result<CapabilityRootsDiscoverResponse, ExecServerError> {
717        match &self.remote_client {
718            Some(client) => client.get().await?.discover_capability_roots(params).await,
719            None => crate::discover_capability_roots(self.filesystem.as_ref(), params)
720                .await
721                .map_err(|error| ExecServerError::Protocol(error.to_string())),
722        }
723    }
724
725    /// Starts connecting a remote environment without waiting for it.
726    /// Requires an active Tokio runtime when background startup is supported.
727    pub fn start_connecting(&self) {
728        let Some(client) = &self.remote_client else {
729            return;
730        };
731        let mut startup_task = self
732            .startup_task
733            .lock()
734            .unwrap_or_else(std::sync::PoisonError::into_inner);
735        if startup_task.is_none() {
736            *startup_task = client.start_connecting();
737        }
738    }
739
740    /// Starts the initial connection after an environment is actually selected for use.
741    pub(crate) fn start_connecting_for_use(environment: &Arc<Self>) {
742        if environment.remote_client.is_none() {
743            return;
744        }
745        let mut startup_task = environment
746            .startup_task
747            .lock()
748            .unwrap_or_else(std::sync::PoisonError::into_inner);
749        if startup_task.is_none() {
750            let environment = Arc::clone(environment);
751            *startup_task = Some(AbortOnDropHandle::new(tokio::spawn(async move {
752                if let Err(error) = environment.wait_until_ready().await {
753                    tracing::debug!(%error, "exec-server environment startup failed");
754                }
755            })));
756        }
757    }
758
759    /// Returns whether initial startup has either succeeded or permanently failed.
760    pub fn startup_finished(&self) -> bool {
761        self.remote_client
762            .as_ref()
763            .is_none_or(LazyRemoteExecServerClient::startup_finished)
764    }
765
766    /// Waits for initial startup. A failed startup is never attempted again.
767    pub async fn wait_until_ready(&self) -> Result<(), ExecServerError> {
768        match &self.remote_client {
769            Some(client) => client.wait_until_ready().await,
770            None => Ok(()),
771        }
772    }
773
774    /// Returns whether the environment can serve a request without waiting or reconnecting.
775    pub(crate) fn readiness_result(&self) -> Option<Result<(), ExecServerError>> {
776        match &self.remote_client {
777            Some(client) => client.readiness_result(),
778            None => Some(Ok(())),
779        }
780    }
781
782    /// Returns the environment's status without starting or recovering it.
783    ///
784    /// Local environments are always ready. Remote environments with an
785    /// already-ready cached connection receive a fail-fast `environment/status`
786    /// probe; other remote states are returned from cached connection state
787    /// without waiting for startup or recovery.
788    pub async fn status(&self) -> EnvironmentObservedStatus {
789        match &self.remote_client {
790            Some(client) => client.status().await,
791            None => EnvironmentObservedStatus::Ready,
792        }
793    }
794
795    pub fn get_exec_backend(&self) -> Arc<dyn ExecBackend> {
796        Arc::clone(&self.exec_backend)
797    }
798
799    pub fn get_http_client(&self) -> Arc<dyn HttpClient> {
800        Arc::clone(&self.http_client)
801    }
802
803    pub fn get_filesystem(&self) -> Arc<dyn ExecutorFileSystem> {
804        Arc::clone(&self.filesystem)
805    }
806
807    /// Returns a filesystem view that fails instead of starting or waiting for a connection.
808    pub fn get_filesystem_without_reconnect(&self) -> Arc<dyn ExecutorFileSystem> {
809        match &self.remote_client {
810            Some(client) => Arc::new(RemoteFileSystem::new(client.fail_fast())),
811            None => Arc::clone(&self.filesystem),
812        }
813    }
814}
815
816#[cfg(test)]
817mod tests {
818    use std::collections::HashMap;
819    use std::sync::Arc;
820    use std::time::Duration;
821
822    use super::Environment;
823    use super::EnvironmentManager;
824    use super::EnvironmentObservedStatus;
825    use super::LOCAL_ENVIRONMENT_ID;
826    use super::REMOTE_ENVIRONMENT_ID;
827    use super::noise_environment_config_from_values;
828    use crate::ExecServerRuntimePaths;
829    use crate::ProcessId;
830    use crate::client_api::ExecServerTransportParams;
831    use crate::client_api::StdioExecServerCommand;
832    use crate::environment_provider::EnvironmentDefault;
833    use crate::environment_provider::EnvironmentProviderSnapshot;
834    use codex_utils_path_uri::PathUri;
835    use pretty_assertions::assert_eq;
836    use tokio::net::TcpListener;
837    use tokio::time::timeout;
838
839    fn test_runtime_paths() -> ExecServerRuntimePaths {
840        ExecServerRuntimePaths::new(
841            std::env::current_exe().expect("current exe"),
842            /*codex_linux_sandbox_exe*/ None,
843        )
844        .expect("runtime paths")
845    }
846
847    fn assert_local_environment_unavailable(manager: &EnvironmentManager) {
848        assert!(manager.try_local_environment().is_none());
849    }
850
851    #[test]
852    fn local_environment_info_includes_current_directory() {
853        let info = super::EnvironmentInfo::local();
854
855        assert_eq!(
856            info.cwd,
857            Some(
858                PathUri::from_host_native_path(std::env::current_dir().expect("current directory"))
859                    .expect("cwd URI")
860            )
861        );
862    }
863
864    #[tokio::test]
865    async fn noise_environment_config_selects_remote_as_default() {
866        let config = noise_environment_config_from_values(
867            Some("http://registry.example/api".to_string()),
868            Some("environment-requested".to_string()),
869            Some("registry-token".to_string()),
870            Some("workspace-123".to_string()),
871        )
872        .expect("parse noise environment configuration")
873        .expect("noise environment configuration");
874
875        let manager = EnvironmentManager::from_noise_environment_config(
876            config, /*local_runtime_paths*/ None,
877        )
878        .expect("build environment manager");
879
880        assert_eq!(
881            manager.default_environment_id(),
882            Some(REMOTE_ENVIRONMENT_ID)
883        );
884        assert!(
885            manager
886                .default_environment()
887                .expect("remote environment")
888                .is_remote()
889        );
890        assert_local_environment_unavailable(&manager);
891    }
892
893    #[tokio::test]
894    async fn create_local_environment_does_not_connect() {
895        let environment = Environment::create(/*exec_server_url*/ None, test_runtime_paths())
896            .expect("create environment");
897
898        assert!(!environment.is_remote());
899        assert!(environment.info().await.is_ok());
900    }
901
902    #[tokio::test]
903    async fn environment_manager_normalizes_empty_url() {
904        let manager =
905            EnvironmentManager::create_for_tests(Some(String::new()), Some(test_runtime_paths()))
906                .await;
907
908        let environment = manager.default_environment().expect("default environment");
909        assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID));
910        assert!(Arc::ptr_eq(
911            &environment,
912            &manager
913                .get_environment(LOCAL_ENVIRONMENT_ID)
914                .expect("local environment")
915        ));
916        assert!(Arc::ptr_eq(
917            &environment,
918            &manager.try_local_environment().expect("local environment")
919        ));
920        assert!(manager.try_local_environment().is_some());
921        assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none());
922        assert!(!environment.is_remote());
923    }
924
925    #[tokio::test]
926    async fn disabled_environment_manager_has_no_default_or_local_environment() {
927        let manager = EnvironmentManager::without_environments();
928
929        assert!(manager.default_environment().is_none());
930        assert_eq!(manager.default_environment_id(), None);
931        assert_local_environment_unavailable(&manager);
932        assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
933        assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none());
934    }
935
936    #[tokio::test]
937    async fn environment_manager_creates_remote_environment_for_url() {
938        let manager = EnvironmentManager::create_for_tests(
939            Some("ws://127.0.0.1:8765".to_string()),
940            Some(test_runtime_paths()),
941        )
942        .await;
943
944        let environment = manager.default_environment().expect("default environment");
945        assert_eq!(
946            manager.default_environment_id(),
947            Some(REMOTE_ENVIRONMENT_ID)
948        );
949        assert!(environment.is_remote());
950        assert!(Arc::ptr_eq(
951            &environment,
952            &manager
953                .get_environment(REMOTE_ENVIRONMENT_ID)
954                .expect("remote environment")
955        ));
956        assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
957        assert_local_environment_unavailable(&manager);
958    }
959
960    #[tokio::test]
961    async fn environment_manager_default_environment_caches_environment() {
962        let manager = EnvironmentManager::default_for_tests();
963
964        let first = manager.default_environment().expect("default environment");
965        let second = manager.default_environment().expect("default environment");
966
967        assert!(Arc::ptr_eq(&first, &second));
968        assert!(Arc::ptr_eq(
969            &first.get_filesystem(),
970            &second.get_filesystem()
971        ));
972    }
973
974    #[tokio::test]
975    async fn environment_manager_builds_from_snapshot() {
976        let snapshot = EnvironmentProviderSnapshot {
977            environments: vec![(
978                REMOTE_ENVIRONMENT_ID.to_string(),
979                Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
980                    .expect("remote environment"),
981            )],
982            default: EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()),
983            include_local: false,
984        };
985        let manager = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths()))
986            .expect("environment manager");
987
988        assert_eq!(
989            manager.default_environment_id(),
990            Some(REMOTE_ENVIRONMENT_ID)
991        );
992        assert!(
993            manager
994                .get_environment(REMOTE_ENVIRONMENT_ID)
995                .expect("remote environment")
996                .is_remote()
997        );
998        assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
999        assert_local_environment_unavailable(&manager);
1000    }
1001
1002    #[tokio::test]
1003    async fn environment_manager_rejects_empty_environment_id() {
1004        let snapshot = EnvironmentProviderSnapshot {
1005            environments: vec![("".to_string(), Environment::default_for_tests())],
1006            default: EnvironmentDefault::Disabled,
1007            include_local: false,
1008        };
1009        let err = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths()))
1010            .expect_err("empty id should fail");
1011
1012        assert_eq!(
1013            err.to_string(),
1014            "exec-server protocol error: environment id cannot be empty"
1015        );
1016    }
1017
1018    #[tokio::test]
1019    async fn environment_manager_rejects_provider_supplied_local_environment() {
1020        let snapshot = EnvironmentProviderSnapshot {
1021            environments: vec![(
1022                LOCAL_ENVIRONMENT_ID.to_string(),
1023                Environment::default_for_tests(),
1024            )],
1025            default: EnvironmentDefault::Disabled,
1026            include_local: false,
1027        };
1028        let err = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths()))
1029            .expect_err("local id should fail");
1030
1031        assert_eq!(
1032            err.to_string(),
1033            "exec-server protocol error: environment id `local` is reserved for EnvironmentManager"
1034        );
1035    }
1036
1037    #[tokio::test]
1038    async fn environment_manager_uses_explicit_provider_default() {
1039        let snapshot = EnvironmentProviderSnapshot {
1040            environments: vec![(
1041                "devbox".to_string(),
1042                Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
1043                    .expect("remote environment"),
1044            )],
1045            default: EnvironmentDefault::EnvironmentId("devbox".to_string()),
1046            include_local: true,
1047        };
1048        let manager = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths()))
1049            .expect("manager");
1050
1051        assert_eq!(manager.default_environment_id(), Some("devbox"));
1052        assert_eq!(
1053            manager.default_environment_ids(),
1054            vec!["devbox".to_string(), LOCAL_ENVIRONMENT_ID.to_string()]
1055        );
1056        assert!(manager.default_environment().expect("default").is_remote());
1057    }
1058
1059    #[tokio::test]
1060    async fn environment_manager_disables_provider_default() {
1061        let snapshot = EnvironmentProviderSnapshot {
1062            environments: vec![(
1063                "devbox".to_string(),
1064                Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
1065                    .expect("remote environment"),
1066            )],
1067            default: EnvironmentDefault::Disabled,
1068            include_local: true,
1069        };
1070        let manager = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths()))
1071            .expect("manager");
1072
1073        assert_eq!(manager.default_environment_id(), None);
1074        assert!(manager.default_environment().is_none());
1075        assert!(Arc::ptr_eq(
1076            &manager
1077                .get_environment(LOCAL_ENVIRONMENT_ID)
1078                .expect("local environment"),
1079            &manager.try_local_environment().expect("local environment")
1080        ));
1081    }
1082
1083    #[tokio::test]
1084    async fn environment_manager_rejects_unknown_provider_default() {
1085        let snapshot = EnvironmentProviderSnapshot {
1086            environments: vec![(
1087                "devbox".to_string(),
1088                Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
1089                    .expect("remote environment"),
1090            )],
1091            default: EnvironmentDefault::EnvironmentId("missing".to_string()),
1092            include_local: true,
1093        };
1094        let err = EnvironmentManager::from_snapshot(snapshot, Some(test_runtime_paths()))
1095            .expect_err("unknown default should fail");
1096
1097        assert_eq!(
1098            err.to_string(),
1099            "exec-server protocol error: default environment `missing` is not configured"
1100        );
1101    }
1102
1103    #[tokio::test]
1104    async fn environment_manager_includes_local_for_default_provider_without_url() {
1105        let manager = EnvironmentManager::create_for_tests(
1106            /*exec_server_url*/ None,
1107            Some(test_runtime_paths()),
1108        )
1109        .await;
1110
1111        let environment = manager.default_environment().expect("default environment");
1112        assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID));
1113        assert!(Arc::ptr_eq(
1114            &environment,
1115            &manager
1116                .get_environment(LOCAL_ENVIRONMENT_ID)
1117                .expect("local environment")
1118        ));
1119        assert!(Arc::ptr_eq(
1120            &environment,
1121            &manager.try_local_environment().expect("local environment")
1122        ));
1123        assert!(!environment.is_remote());
1124    }
1125
1126    #[tokio::test]
1127    async fn environment_manager_carries_local_runtime_paths() {
1128        let runtime_paths = test_runtime_paths();
1129        let manager = EnvironmentManager::create_for_tests(
1130            /*exec_server_url*/ None,
1131            Some(runtime_paths.clone()),
1132        )
1133        .await;
1134
1135        let environment = manager.try_local_environment().expect("local environment");
1136
1137        assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths));
1138        let manager = EnvironmentManager::create_for_tests(
1139            /*exec_server_url*/ None,
1140            Some(
1141                environment
1142                    .local_runtime_paths()
1143                    .expect("local runtime paths")
1144                    .clone(),
1145            ),
1146        )
1147        .await;
1148        let environment = manager.try_local_environment().expect("local environment");
1149        assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths));
1150    }
1151
1152    #[tokio::test]
1153    async fn environment_manager_omits_default_provider_local_lookup_when_default_disabled() {
1154        let manager = EnvironmentManager::create_for_tests(
1155            Some("none".to_string()),
1156            Some(test_runtime_paths()),
1157        )
1158        .await;
1159
1160        assert!(manager.default_environment().is_none());
1161        assert_eq!(manager.default_environment_id(), None);
1162        assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
1163        assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none());
1164        assert_local_environment_unavailable(&manager);
1165    }
1166
1167    #[tokio::test]
1168    async fn environment_manager_snapshot_without_local_environment_disables_local_default() {
1169        let mut snapshot = EnvironmentProviderSnapshot {
1170            environments: Vec::new(),
1171            default: EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()),
1172            include_local: true,
1173        };
1174        snapshot.include_local = false;
1175        snapshot.default = EnvironmentDefault::Disabled;
1176        let manager =
1177            EnvironmentManager::from_snapshot(snapshot, /*local_runtime_paths*/ None)
1178                .expect("environment manager");
1179
1180        assert!(manager.default_environment().is_none());
1181        assert_eq!(manager.default_environment_id(), None);
1182        assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
1183        assert_local_environment_unavailable(&manager);
1184    }
1185
1186    #[tokio::test]
1187    async fn get_environment_returns_none_for_unknown_id() {
1188        let manager = EnvironmentManager::default_for_tests();
1189
1190        assert!(manager.get_environment("does-not-exist").is_none());
1191    }
1192
1193    #[tokio::test]
1194    async fn environment_manager_upserts_named_remote_environment() {
1195        let manager = EnvironmentManager::without_environments();
1196
1197        manager
1198            .upsert_environment(
1199                "executor-a".to_string(),
1200                "ws://127.0.0.1:8765".to_string(),
1201                /*connect_timeout*/ None,
1202            )
1203            .expect("remote environment");
1204        let first = manager
1205            .get_environment("executor-a")
1206            .expect("first remote environment");
1207        assert!(first.is_remote());
1208        assert_eq!(manager.default_environment_id(), None);
1209
1210        manager
1211            .upsert_environment(
1212                "executor-a".to_string(),
1213                "ws://127.0.0.1:9876".to_string(),
1214                /*connect_timeout*/ None,
1215            )
1216            .expect("updated remote environment");
1217        let second = manager
1218            .get_environment("executor-a")
1219            .expect("second remote environment");
1220        assert!(second.is_remote());
1221        assert!(!Arc::ptr_eq(&first, &second));
1222    }
1223
1224    #[tokio::test]
1225    async fn environment_manager_starts_remote_environment_when_upserted() {
1226        let listener = TcpListener::bind("127.0.0.1:0")
1227            .await
1228            .expect("bind websocket listener");
1229        let manager = EnvironmentManager::without_environments();
1230
1231        manager
1232            .upsert_environment(
1233                "executor-a".to_string(),
1234                format!("ws://{}", listener.local_addr().expect("listener address")),
1235                /*connect_timeout*/ None,
1236            )
1237            .expect("remote environment");
1238
1239        timeout(Duration::from_secs(5), listener.accept())
1240            .await
1241            .expect("environment should start connecting when registered")
1242            .expect("accept connection");
1243    }
1244
1245    #[tokio::test]
1246    async fn environment_status_keeps_stdio_environment_pending() {
1247        let environment = Environment::remote_with_transport(
1248            ExecServerTransportParams::StdioCommand {
1249                command: StdioExecServerCommand {
1250                    program: "codex-missing-exec-server-for-test".to_string(),
1251                    args: Vec::new(),
1252                    env: HashMap::new(),
1253                    cwd: None,
1254                },
1255                initialize_timeout: Duration::from_secs(1),
1256            },
1257            /*local_runtime_paths*/ None,
1258        );
1259
1260        assert_eq!(
1261            environment.status().await,
1262            EnvironmentObservedStatus::Pending
1263        );
1264        assert!(!environment.startup_finished());
1265    }
1266
1267    #[tokio::test]
1268    async fn environment_manager_leaves_stdio_environment_lazy() {
1269        let environment = Environment::remote_with_transport(
1270            ExecServerTransportParams::StdioCommand {
1271                command: StdioExecServerCommand {
1272                    program: "codex-missing-exec-server-for-test".to_string(),
1273                    args: Vec::new(),
1274                    env: HashMap::new(),
1275                    cwd: None,
1276                },
1277                initialize_timeout: Duration::from_secs(1),
1278            },
1279            /*local_runtime_paths*/ None,
1280        );
1281        let manager = EnvironmentManager::from_snapshot(
1282            EnvironmentProviderSnapshot {
1283                environments: vec![("stdio".to_string(), environment)],
1284                default: EnvironmentDefault::Disabled,
1285                include_local: false,
1286            },
1287            /*local_runtime_paths*/ None,
1288        )
1289        .expect("environment manager");
1290        let environment = manager.get_environment("stdio").expect("stdio environment");
1291
1292        assert!(!environment.startup_finished());
1293        assert!(environment.wait_until_ready().await.is_err());
1294        assert!(environment.startup_finished());
1295    }
1296
1297    #[tokio::test]
1298    async fn selected_capability_inspection_keeps_stdio_environment_lazy() {
1299        use codex_protocol::capabilities::CapabilityRootLocation;
1300        use codex_protocol::capabilities::SelectedCapabilityRoot;
1301
1302        let environment = Environment::remote_with_transport(
1303            ExecServerTransportParams::StdioCommand {
1304                command: StdioExecServerCommand {
1305                    program: "codex-missing-exec-server-for-test".to_string(),
1306                    args: Vec::new(),
1307                    env: HashMap::new(),
1308                    cwd: None,
1309                },
1310                initialize_timeout: Duration::from_secs(1),
1311            },
1312            /*local_runtime_paths*/ None,
1313        );
1314        let manager = EnvironmentManager::from_snapshot(
1315            EnvironmentProviderSnapshot {
1316                environments: vec![("stdio".to_string(), environment)],
1317                default: EnvironmentDefault::Disabled,
1318                include_local: false,
1319            },
1320            /*local_runtime_paths*/ None,
1321        )
1322        .expect("environment manager");
1323        let environment = manager.get_environment("stdio").expect("stdio environment");
1324        let selected_root = SelectedCapabilityRoot {
1325            id: "demo@1".to_string(),
1326            location: CapabilityRootLocation::Environment {
1327                environment_id: "stdio".to_string(),
1328                path: PathUri::parse("file:///plugins/demo").expect("plugin path URI"),
1329            },
1330        };
1331
1332        let status =
1333            manager.inspect_selected_capability_roots(std::slice::from_ref(&selected_root));
1334        assert!(status.ready_roots.is_empty());
1335        assert_eq!(status.warnings, Vec::<String>::new());
1336        assert!(!environment.startup_finished());
1337
1338        let missing_root = SelectedCapabilityRoot {
1339            id: "missing@1".to_string(),
1340            location: CapabilityRootLocation::Environment {
1341                environment_id: "missing".to_string(),
1342                path: PathUri::parse("file:///plugins/missing").expect("missing plugin path URI"),
1343            },
1344        };
1345        let status = manager.inspect_selected_capability_roots(&[missing_root]);
1346        assert!(status.ready_roots.is_empty());
1347        assert_eq!(
1348            status.warnings,
1349            vec![
1350                "selected capability root `missing@1` references unavailable environment `missing`"
1351                    .to_string()
1352            ]
1353        );
1354
1355        assert!(environment.wait_until_ready().await.is_err());
1356
1357        let status = manager.inspect_selected_capability_roots(&[selected_root]);
1358        assert!(status.ready_roots.is_empty());
1359        assert_eq!(status.warnings.len(), 1);
1360        assert!(status.warnings[0].contains("environment `stdio` is unavailable"));
1361    }
1362
1363    #[tokio::test]
1364    async fn replacing_environment_stops_its_startup_task() {
1365        let first_listener = TcpListener::bind("127.0.0.1:0")
1366            .await
1367            .expect("bind first websocket listener");
1368        let second_listener = TcpListener::bind("127.0.0.1:0")
1369            .await
1370            .expect("bind second websocket listener");
1371        let manager = EnvironmentManager::without_environments();
1372        manager
1373            .upsert_environment(
1374                "executor-a".to_string(),
1375                format!(
1376                    "ws://{}",
1377                    first_listener.local_addr().expect("first listener address")
1378                ),
1379                /*connect_timeout*/ None,
1380            )
1381            .expect("first remote environment");
1382        let environment = manager
1383            .get_environment("executor-a")
1384            .expect("first remote environment");
1385        let startup_abort = environment
1386            .startup_task
1387            .lock()
1388            .unwrap_or_else(std::sync::PoisonError::into_inner)
1389            .as_ref()
1390            .expect("startup task")
1391            .abort_handle();
1392        assert!(!startup_abort.is_finished());
1393        drop(environment);
1394
1395        manager
1396            .upsert_environment(
1397                "executor-a".to_string(),
1398                format!(
1399                    "ws://{}",
1400                    second_listener
1401                        .local_addr()
1402                        .expect("second listener address")
1403                ),
1404                /*connect_timeout*/ None,
1405            )
1406            .expect("replacement remote environment");
1407
1408        timeout(Duration::from_secs(1), async {
1409            while !startup_abort.is_finished() {
1410                tokio::task::yield_now().await;
1411            }
1412        })
1413        .await
1414        .expect("replacing the environment should cancel its startup task");
1415    }
1416
1417    #[tokio::test]
1418    async fn environment_manager_rejects_empty_remote_environment_url() {
1419        let manager = EnvironmentManager::without_environments();
1420
1421        let err = manager
1422            .upsert_environment(
1423                "executor-a".to_string(),
1424                String::new(),
1425                /*connect_timeout*/ None,
1426            )
1427            .expect_err("empty URL should fail");
1428
1429        assert_eq!(
1430            err.to_string(),
1431            "exec-server protocol error: remote environment requires an exec-server url"
1432        );
1433    }
1434
1435    #[tokio::test]
1436    async fn default_environment_has_ready_local_executor() {
1437        let environment = Environment::default_for_tests();
1438
1439        let response = environment
1440            .get_exec_backend()
1441            .start(crate::ExecParams {
1442                process_id: ProcessId::from("default-env-proc"),
1443                argv: vec!["true".to_string()],
1444                cwd: PathUri::from_host_native_path(
1445                    std::env::current_dir().expect("read current dir"),
1446                )
1447                .expect("cwd URI"),
1448                env_policy: None,
1449                env: Default::default(),
1450                tty: false,
1451                pipe_stdin: false,
1452                arg0: None,
1453                sandbox: None,
1454                enforce_managed_network: false,
1455                managed_network: None,
1456                network_proxy: None,
1457            })
1458            .await
1459            .expect("start process");
1460
1461        assert_eq!(response.process.process_id().as_str(), "default-env-proc");
1462    }
1463
1464    #[tokio::test]
1465    async fn local_environment_passes_runtime_paths_to_exec_backend() {
1466        let environment = Environment::local(test_runtime_paths());
1467        #[cfg(unix)]
1468        let uri = "file://server/share/checkout";
1469        #[cfg(windows)]
1470        let uri = "file:///usr/local/checkout";
1471        let sandbox_cwd = PathUri::parse(uri).expect("non-native sandbox cwd URI");
1472        let source = sandbox_cwd
1473            .to_abs_path()
1474            .expect_err("sandbox cwd should not be native to this host");
1475        let sandbox = crate::FileSystemSandboxContext::from_permission_profile_with_cwd(
1476            codex_protocol::models::PermissionProfile::workspace_write(),
1477            sandbox_cwd.clone(),
1478        );
1479
1480        let result = environment
1481            .get_exec_backend()
1482            .start(crate::ExecParams {
1483                process_id: ProcessId::from("local-sandbox-proc"),
1484                argv: vec!["true".to_string()],
1485                cwd: PathUri::from_host_native_path(
1486                    std::env::current_dir().expect("read current dir"),
1487                )
1488                .expect("cwd URI"),
1489                env_policy: None,
1490                env: Default::default(),
1491                tty: false,
1492                pipe_stdin: false,
1493                arg0: None,
1494                sandbox: Some(sandbox),
1495                enforce_managed_network: false,
1496                managed_network: None,
1497                network_proxy: None,
1498            })
1499            .await;
1500        let Err(err) = result else {
1501            panic!("sandbox cwd should be rejected after resolving runtime paths");
1502        };
1503
1504        assert_eq!(
1505            err.to_string(),
1506            format!(
1507                "exec-server rejected request (-32602): sandbox cwd URI `{sandbox_cwd}` is not valid on this exec-server host: {source}"
1508            )
1509        );
1510    }
1511
1512    #[tokio::test]
1513    async fn test_environment_rejects_sandboxed_filesystem_without_runtime_paths() {
1514        let environment = Environment::default_for_tests();
1515        let path = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(
1516            std::env::current_exe().expect("current exe").as_path(),
1517        )
1518        .expect("absolute current exe");
1519        let path = codex_utils_path_uri::PathUri::from_abs_path(&path);
1520        let sandbox = crate::FileSystemSandboxContext::from_permission_profile(
1521            codex_protocol::models::PermissionProfile::from_runtime_permissions(
1522                &codex_protocol::permissions::FileSystemSandboxPolicy::restricted(Vec::new()),
1523                codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
1524            ),
1525        );
1526
1527        let err = environment
1528            .get_filesystem()
1529            .read_file(&path, Some(&sandbox))
1530            .await
1531            .expect_err("sandboxed read should require runtime paths");
1532
1533        assert_eq!(
1534            err.to_string(),
1535            "sandboxed filesystem operations require configured runtime paths"
1536        );
1537    }
1538}