use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::time::Duration; use async_trait::async_trait; use serde::Deserialize; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use crate::DefaultEnvironmentProvider; use crate::Environment; use crate::EnvironmentProvider; use crate::ExecServerError; use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT; use crate::client_api::ExecServerTransportParams; use crate::client_api::StdioExecServerCommand; use crate::environment::LOCAL_ENVIRONMENT_ID; use crate::environment_provider::EnvironmentDefault; use crate::environment_provider::EnvironmentProviderSnapshot; const ENVIRONMENTS_TOML_FILE: &str = "environments.toml"; const MAX_ENVIRONMENT_ID_LEN: usize = 64; #[derive(Deserialize, Debug, Default)] #[serde(deny_unknown_fields)] struct EnvironmentsToml { default: Option, #[serde(default)] environments: Vec, } #[derive(Deserialize, Debug, Default, PartialEq, Eq)] #[serde(deny_unknown_fields)] struct EnvironmentToml { id: String, url: Option, program: Option, args: Option>, env: Option>, cwd: Option, #[serde(default, with = "option_duration_secs")] connect_timeout_sec: Option, #[serde(default, with = "option_duration_secs")] initialize_timeout_sec: Option, } #[derive(Clone, Debug, PartialEq, Eq)] struct TomlEnvironmentProvider { default: EnvironmentDefault, environments: Vec<(String, ExecServerTransportParams)>, } impl TomlEnvironmentProvider { #[cfg(test)] fn new(config: EnvironmentsToml) -> Result { Self::new_with_config_dir(config, /*config_dir*/ None) } fn new_with_config_dir( config: EnvironmentsToml, config_dir: Option<&Path>, ) -> Result { let mut ids = HashSet::from([LOCAL_ENVIRONMENT_ID.to_string()]); let mut environments = Vec::with_capacity(config.environments.len()); for item in config.environments { let (id, transport) = parse_environment_toml(item, config_dir)?; if !ids.insert(id.clone()) { return Err(ExecServerError::Protocol(format!( "environment id `{id}` is duplicated" ))); } environments.push((id, transport)); } let default = normalize_default_environment_id(config.default.as_deref(), &ids)?; Ok(Self { default, environments, }) } } #[async_trait] impl EnvironmentProvider for TomlEnvironmentProvider { async fn snapshot(&self) -> Result { let mut environments = Vec::with_capacity(self.environments.len()); for (id, transport_params) in &self.environments { environments.push(( id.clone(), Environment::remote_with_transport( transport_params.clone(), /*local_runtime_paths*/ None, ), )); } Ok(EnvironmentProviderSnapshot { environments, default: self.default.clone(), include_local: true, }) } } fn parse_environment_toml( item: EnvironmentToml, config_dir: Option<&Path>, ) -> Result<(String, ExecServerTransportParams), ExecServerError> { let EnvironmentToml { id, url, program, args, env, cwd, connect_timeout_sec, initialize_timeout_sec, } = item; validate_environment_id(&id)?; if program.is_none() && (args.is_some() || env.is_some() || cwd.is_some()) { return Err(ExecServerError::Protocol(format!( "environment `{id}` args, env, and cwd require program" ))); } if url.is_none() && connect_timeout_sec.is_some() { return Err(ExecServerError::Protocol(format!( "environment `{id}` connect_timeout_sec requires url" ))); } let connect_timeout = connect_timeout_sec.unwrap_or(DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT); let initialize_timeout = initialize_timeout_sec.unwrap_or(DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT); let transport_params = match (url, program) { (Some(url), None) => { let url = validate_websocket_url(url)?; ExecServerTransportParams::WebSocketUrl { websocket_url: url, connect_timeout, initialize_timeout, } } (None, Some(program)) => { let program = program.trim().to_string(); if program.is_empty() { return Err(ExecServerError::Protocol(format!( "environment `{id}` program cannot be empty" ))); } let cwd = normalize_stdio_cwd(&id, cwd, config_dir)?; ExecServerTransportParams::StdioCommand { command: StdioExecServerCommand { program, args: args.unwrap_or_default(), env: env.unwrap_or_default(), cwd, }, initialize_timeout, } } (None, None) | (Some(_), Some(_)) => { return Err(ExecServerError::Protocol(format!( "environment `{id}` must set exactly one of url or program" ))); } }; Ok((id, transport_params)) } fn normalize_stdio_cwd( id: &str, cwd: Option, config_dir: Option<&Path>, ) -> Result, ExecServerError> { let Some(cwd) = cwd else { return Ok(None); }; if cwd.is_absolute() { return Ok(Some(cwd)); } let Some(config_dir) = config_dir else { return Err(ExecServerError::Protocol(format!( "environment `{id}` cwd must be absolute" ))); }; Ok(Some(config_dir.join(cwd))) } pub(crate) fn environment_provider_from_codex_home( codex_home: &Path, ) -> Result, ExecServerError> { let path = codex_home.join(ENVIRONMENTS_TOML_FILE); if !path.try_exists().map_err(|err| { ExecServerError::Protocol(format!( "failed to inspect environment config `{}`: {err}", path.display() )) })? { return Ok(Box::new(DefaultEnvironmentProvider::from_env())); } let environments = load_environments_toml(&path)?; Ok(Box::new(TomlEnvironmentProvider::new_with_config_dir( environments, Some(codex_home), )?)) } fn normalize_default_environment_id( default: Option<&str>, ids: &HashSet, ) -> Result { let Some(default) = default.map(str::trim) else { return Ok(EnvironmentDefault::EnvironmentId( LOCAL_ENVIRONMENT_ID.to_string(), )); }; if default.is_empty() { return Err(ExecServerError::Protocol( "default environment id cannot be empty".to_string(), )); } if !default.eq_ignore_ascii_case("none") && !ids.contains(default) { return Err(ExecServerError::Protocol(format!( "default environment `{default}` is not configured" ))); } if default.eq_ignore_ascii_case("none") { Ok(EnvironmentDefault::Disabled) } else { Ok(EnvironmentDefault::EnvironmentId(default.to_string())) } } fn validate_environment_id(id: &str) -> Result<(), ExecServerError> { let trimmed_id = id.trim(); if trimmed_id.is_empty() { return Err(ExecServerError::Protocol( "environment id cannot be empty".to_string(), )); } if trimmed_id != id { return Err(ExecServerError::Protocol(format!( "environment id `{id}` must not contain surrounding whitespace" ))); } if id == LOCAL_ENVIRONMENT_ID || id.eq_ignore_ascii_case("none") { return Err(ExecServerError::Protocol(format!( "environment id `{id}` is reserved" ))); } if id.len() > MAX_ENVIRONMENT_ID_LEN { return Err(ExecServerError::Protocol(format!( "environment id `{id}` cannot be longer than {MAX_ENVIRONMENT_ID_LEN} characters" ))); } if !id .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') { return Err(ExecServerError::Protocol(format!( "environment id `{id}` must contain only ASCII letters, numbers, '-' or '_'" ))); } Ok(()) } fn validate_websocket_url(url: String) -> Result { let url = url.trim(); if url.is_empty() { return Err(ExecServerError::Protocol( "environment url cannot be empty".to_string(), )); } if !url.starts_with("ws://") && !url.starts_with("wss://") { return Err(ExecServerError::Protocol(format!( "environment url `{url}` must use ws:// or wss://" ))); } url.into_client_request().map_err(|err| { ExecServerError::Protocol(format!("environment url `{url}` is invalid: {err}")) })?; Ok(url.to_string()) } fn load_environments_toml(path: &Path) -> Result { let contents = std::fs::read_to_string(path).map_err(|err| { ExecServerError::Protocol(format!( "failed to read environment config `{}`: {err}", path.display() )) })?; toml::from_str(&contents).map_err(|err| { ExecServerError::Protocol(format!( "failed to parse environment config `{}`: {err}", path.display() )) }) } mod option_duration_secs { use std::time::Duration; use serde::Deserialize; use serde::Deserializer; pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let secs = Option::::deserialize(deserializer)?; secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)) .transpose() } } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use tempfile::tempdir; use super::*; #[tokio::test] async fn toml_provider_includes_local_and_adds_configured_environments() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("ssh-dev".to_string()), environments: vec![ EnvironmentToml { id: "devbox".to_string(), url: Some(" ws://127.0.0.1:8765 ".to_string()), ..Default::default() }, EnvironmentToml { id: "ssh-dev".to_string(), program: Some(" ssh ".to_string()), args: Some(vec![ "dev".to_string(), "codex exec-server --listen stdio".to_string(), ]), env: Some(HashMap::from([( "CODEX_LOG".to_string(), "debug".to_string(), )])), ..Default::default() }, ], }) .expect("provider"); let snapshot = provider.snapshot().await.expect("environments"); let EnvironmentProviderSnapshot { environments, default, include_local, } = snapshot; let environment_ids: Vec<_> = environments .iter() .map(|(id, _environment)| id.as_str()) .collect(); assert_eq!(environment_ids, vec!["devbox", "ssh-dev"]); let environments: HashMap<_, _> = environments.into_iter().collect(); assert!(include_local); assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID)); assert_eq!( environments["devbox"].exec_server_url(), Some("ws://127.0.0.1:8765") ); assert!(environments["ssh-dev"].is_remote()); assert_eq!(environments["ssh-dev"].exec_server_url(), None); assert_eq!( default, EnvironmentDefault::EnvironmentId("ssh-dev".to_string()) ); } #[tokio::test] async fn toml_provider_default_omitted_selects_local() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml::default()).expect("provider"); let snapshot = provider.snapshot().await.expect("environments"); assert!(snapshot.include_local); assert_eq!( snapshot.default, EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()) ); } #[tokio::test] async fn toml_provider_default_none_disables_default() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("none".to_string()), environments: Vec::new(), }) .expect("provider"); let snapshot = provider.snapshot().await.expect("environments"); assert!(snapshot.include_local); assert_eq!(snapshot.default, EnvironmentDefault::Disabled); } #[test] fn toml_provider_rejects_invalid_environments() { let cases = [ ( EnvironmentToml { id: "local".to_string(), url: Some("ws://127.0.0.1:8765".to_string()), ..Default::default() }, "environment id `local` is reserved", ), ( EnvironmentToml { id: " devbox ".to_string(), url: Some("ws://127.0.0.1:8765".to_string()), ..Default::default() }, "environment id ` devbox ` must not contain surrounding whitespace", ), ( EnvironmentToml { id: "dev box".to_string(), url: Some("ws://127.0.0.1:8765".to_string()), ..Default::default() }, "environment id `dev box` must contain only ASCII letters, numbers, '-' or '_'", ), ( EnvironmentToml { id: "devbox".to_string(), url: Some("http://127.0.0.1:8765".to_string()), ..Default::default() }, "environment url `http://127.0.0.1:8765` must use ws:// or wss://", ), ( EnvironmentToml { id: "devbox".to_string(), url: Some("ws://127.0.0.1:8765".to_string()), program: Some("codex".to_string()), ..Default::default() }, "environment `devbox` must set exactly one of url or program", ), ( EnvironmentToml { id: "devbox".to_string(), program: Some(" ".to_string()), ..Default::default() }, "environment `devbox` program cannot be empty", ), ( EnvironmentToml { id: "devbox".to_string(), args: Some(Vec::new()), ..Default::default() }, "environment `devbox` args, env, and cwd require program", ), ( EnvironmentToml { id: "ssh-dev".to_string(), program: Some("ssh".to_string()), connect_timeout_sec: Some(Duration::from_secs(1)), ..Default::default() }, "environment `ssh-dev` connect_timeout_sec requires url", ), ]; for (item, expected) in cases { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, environments: vec![item], }) .expect_err("invalid item should fail"); assert_eq!( err.to_string(), format!("exec-server protocol error: {expected}") ); } } #[test] fn toml_provider_resolves_relative_stdio_cwd_from_config_dir() { let config_dir = tempdir().expect("tempdir"); let provider = TomlEnvironmentProvider::new_with_config_dir( EnvironmentsToml { default: None, environments: vec![EnvironmentToml { id: "ssh-dev".to_string(), program: Some("ssh".to_string()), cwd: Some(PathBuf::from("workspace")), ..Default::default() }], }, Some(config_dir.path()), ) .expect("provider"); assert_eq!( provider.environments[0].1, ExecServerTransportParams::StdioCommand { command: StdioExecServerCommand { program: "ssh".to_string(), args: Vec::new(), env: HashMap::new(), cwd: Some(config_dir.path().join("workspace")), }, initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, } ); } #[test] fn toml_provider_parses_configured_transport_timeouts() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, environments: vec![ EnvironmentToml { id: "devbox".to_string(), url: Some("ws://127.0.0.1:8765".to_string()), connect_timeout_sec: Some(Duration::from_secs(12)), initialize_timeout_sec: Some(Duration::from_secs(34)), ..Default::default() }, EnvironmentToml { id: "ssh-dev".to_string(), program: Some("ssh".to_string()), initialize_timeout_sec: Some(Duration::from_secs(56)), ..Default::default() }, ], }) .expect("provider"); assert_eq!( provider.environments[0].1, ExecServerTransportParams::WebSocketUrl { websocket_url: "ws://127.0.0.1:8765".to_string(), connect_timeout: Duration::from_secs(12), initialize_timeout: Duration::from_secs(34), } ); assert_eq!( provider.environments[1].1, ExecServerTransportParams::StdioCommand { command: StdioExecServerCommand { program: "ssh".to_string(), args: Vec::new(), env: HashMap::new(), cwd: None, }, initialize_timeout: Duration::from_secs(56), } ); } #[test] fn toml_provider_rejects_relative_stdio_cwd_without_config_dir() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, environments: vec![EnvironmentToml { id: "ssh-dev".to_string(), program: Some("ssh".to_string()), cwd: Some(PathBuf::from("workspace")), ..Default::default() }], }) .expect_err("relative cwd without config dir should fail"); assert_eq!( err.to_string(), "exec-server protocol error: environment `ssh-dev` cwd must be absolute" ); } #[test] fn toml_provider_rejects_duplicate_ids() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, environments: vec![ EnvironmentToml { id: "devbox".to_string(), url: Some("ws://127.0.0.1:8765".to_string()), ..Default::default() }, EnvironmentToml { id: "devbox".to_string(), program: Some("codex".to_string()), ..Default::default() }, ], }) .expect_err("duplicate id should fail"); assert_eq!( err.to_string(), "exec-server protocol error: environment id `devbox` is duplicated" ); } #[test] fn toml_provider_rejects_overlong_id() { let id = "a".repeat(MAX_ENVIRONMENT_ID_LEN + 1); let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, environments: vec![EnvironmentToml { id: id.clone(), url: Some("ws://127.0.0.1:8765".to_string()), ..Default::default() }], }) .expect_err("overlong id should fail"); assert_eq!( err.to_string(), format!( "exec-server protocol error: environment id `{id}` cannot be longer than {MAX_ENVIRONMENT_ID_LEN} characters" ) ); } #[test] fn toml_provider_rejects_unknown_default() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("missing".to_string()), environments: Vec::new(), }) .expect_err("unknown default should fail"); assert_eq!( err.to_string(), "exec-server protocol error: default environment `missing` is not configured" ); } #[test] fn load_environments_toml_reads_root_environment_list() { let codex_home = tempdir().expect("tempdir"); let path = codex_home.path().join(ENVIRONMENTS_TOML_FILE); std::fs::write( &path, r#" default = "ssh-dev" [[environments]] id = "devbox" url = "ws://127.0.0.1:4512" connect_timeout_sec = 12.0 initialize_timeout_sec = 34.0 [[environments]] id = "ssh-dev" program = "ssh" args = ["dev", "codex exec-server --listen stdio"] cwd = "/tmp" [environments.env] CODEX_LOG = "debug" "#, ) .expect("write environments.toml"); let environments = load_environments_toml(&path).expect("environments.toml"); assert_eq!(environments.default.as_deref(), Some("ssh-dev")); assert_eq!(environments.environments.len(), 2); assert_eq!( environments.environments[0], EnvironmentToml { id: "devbox".to_string(), url: Some("ws://127.0.0.1:4512".to_string()), connect_timeout_sec: Some(Duration::from_secs(12)), initialize_timeout_sec: Some(Duration::from_secs(34)), ..Default::default() } ); assert_eq!( environments.environments[1], EnvironmentToml { id: "ssh-dev".to_string(), program: Some("ssh".to_string()), args: Some(vec![ "dev".to_string(), "codex exec-server --listen stdio".to_string(), ]), env: Some(HashMap::from([( "CODEX_LOG".to_string(), "debug".to_string(), )])), cwd: Some(PathBuf::from("/tmp")), ..Default::default() } ); } #[test] fn load_environments_toml_rejects_unknown_fields() { let codex_home = tempdir().expect("tempdir"); let cases = [ ("unknown = true\n", "unknown field `unknown`"), ( r#" [[environments]] id = "devbox" url = "ws://127.0.0.1:4512" unknown = true "#, "unknown field `unknown`", ), ]; for (index, (contents, expected)) in cases.into_iter().enumerate() { let path = codex_home.path().join(format!("environments-{index}.toml")); std::fs::write(&path, contents).expect("write environments.toml"); let err = load_environments_toml(&path).expect_err("unknown field should fail"); assert!( err.to_string().contains(expected), "expected `{err}` to contain `{expected}`" ); } } #[test] fn toml_provider_rejects_malformed_websocket_url() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { default: None, environments: vec![EnvironmentToml { id: "devbox".to_string(), url: Some("ws://".to_string()), ..Default::default() }], }) .expect_err("malformed websocket url should fail"); assert!( err.to_string() .contains("environment url `ws://` is invalid"), "expected malformed URL error, got `{err}`" ); } #[tokio::test] async fn environment_provider_from_codex_home_uses_present_environments_file() { let codex_home = tempdir().expect("tempdir"); std::fs::write( codex_home.path().join(ENVIRONMENTS_TOML_FILE), r#" default = "none" "#, ) .expect("write environments.toml"); let provider = environment_provider_from_codex_home(codex_home.path()).expect("environment provider"); let snapshot = provider.snapshot().await.expect("environments"); let environment_ids: Vec<_> = snapshot .environments .into_iter() .map(|(id, _environment)| id) .collect(); assert!(snapshot.include_local); assert!(!environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); assert_eq!(snapshot.default, EnvironmentDefault::Disabled); } #[tokio::test] async fn environment_provider_from_codex_home_falls_back_when_file_is_missing() { let codex_home = tempdir().expect("tempdir"); let provider = environment_provider_from_codex_home(codex_home.path()).expect("environment provider"); let snapshot = provider.snapshot().await.expect("environments"); let environment_ids: Vec<_> = snapshot .environments .into_iter() .map(|(id, _environment)| id) .collect(); assert!(snapshot.include_local); assert!(!environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); assert_eq!( snapshot.default, EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()) ); } }