use std::path::Path; use std::time::Duration; use serde::Deserialize; use serde::Serialize; use crate::ExecServerError; pub const EXEC_SERVER_TOML_FILE: &str = "exec-server.toml"; #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct ExecServerConfig { pub listen: Option, pub remote: Option, pub runtime: ExecServerRuntimeConfig, } impl ExecServerConfig { pub fn from_codex_home(codex_home: &Path) -> Result { let path = codex_home.join(EXEC_SERVER_TOML_FILE); if !path.try_exists().map_err(|err| { ExecServerError::Protocol(format!( "failed to inspect exec-server config `{}`: {err}", path.display() )) })? { return Ok(Self::default()); } let contents = std::fs::read_to_string(&path).map_err(|err| { ExecServerError::Protocol(format!( "failed to read exec-server config `{}`: {err}", path.display() )) })?; let config: ExecServerToml = toml::from_str(&contents).map_err(|err| { ExecServerError::Protocol(format!( "failed to parse exec-server config `{}`: {err}", path.display() )) })?; config.try_into() } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RemoteExecServerConfig { pub url: Option, pub environment_id: Option, pub name: Option, pub use_agent_identity_auth: bool, pub reconnect_initial_backoff: Duration, pub reconnect_max_backoff: Duration, } #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct ExecServerRuntimeConfig { pub sessions: SessionRegistryConfig, pub processes: LocalProcessConfig, pub filesystem: LocalFileSystemConfig, } impl ExecServerRuntimeConfig { pub(crate) fn validate(&self) -> Result<(), ExecServerError> { self.sessions.validate() } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct SessionRegistryConfig { pub detached_session_ttl: Duration, } impl Default for SessionRegistryConfig { fn default() -> Self { Self { detached_session_ttl: default_detached_session_ttl(), } } } impl SessionRegistryConfig { fn validate(&self) -> Result<(), ExecServerError> { validate_detached_session_ttl(self.detached_session_ttl) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct LocalProcessConfig { pub retained_output_bytes_per_process: usize, pub exited_process_retention: Duration, } impl Default for LocalProcessConfig { fn default() -> Self { Self { retained_output_bytes_per_process: 1024 * 1024, exited_process_retention: default_exited_process_retention(), } } } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalFileSystemConfig { pub max_read_file_bytes: u64, } impl Default for LocalFileSystemConfig { fn default() -> Self { Self { max_read_file_bytes: 512 * 1024 * 1024, } } } #[cfg(test)] fn default_detached_session_ttl() -> Duration { Duration::from_millis(200) } #[cfg(not(test))] fn default_detached_session_ttl() -> Duration { Duration::from_secs(10) } #[cfg(test)] fn default_exited_process_retention() -> Duration { Duration::from_millis(25) } #[cfg(not(test))] fn default_exited_process_retention() -> Duration { Duration::from_secs(30) } #[derive(Deserialize, Default)] #[serde(deny_unknown_fields)] struct ExecServerToml { listen: Option, remote: Option, sessions: Option, processes: Option, filesystem: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RemoteExecServerToml { url: Option, environment_id: Option, name: Option, use_agent_identity_auth: Option, reconnect_initial_backoff_sec: Option, reconnect_max_backoff_sec: Option, } #[derive(Deserialize, Default)] #[serde(deny_unknown_fields)] struct SessionsToml { detached_ttl_sec: Option, } #[derive(Deserialize, Default)] #[serde(deny_unknown_fields)] struct ProcessesToml { retained_output_bytes_per_process: Option, exited_process_retention_sec: Option, } #[derive(Deserialize, Default)] #[serde(deny_unknown_fields)] struct FileSystemToml { max_read_file_bytes: Option, } impl TryFrom for ExecServerConfig { type Error = ExecServerError; fn try_from(config: ExecServerToml) -> Result { let ExecServerToml { listen, remote, sessions, processes, filesystem, } = config; if listen.is_some() && remote.is_some() { return Err(ExecServerError::Protocol( "exec-server config cannot set both `listen` and `[remote]`".to_string(), )); } let mut runtime = ExecServerRuntimeConfig::default(); if let Some(sessions) = sessions && let Some(detached_ttl_sec) = sessions.detached_ttl_sec { let detached_session_ttl = Duration::from_secs(detached_ttl_sec); validate_detached_session_ttl(detached_session_ttl)?; runtime.sessions.detached_session_ttl = detached_session_ttl; } if let Some(processes) = processes { if let Some(retained_output_bytes_per_process) = processes.retained_output_bytes_per_process { runtime.processes.retained_output_bytes_per_process = retained_output_bytes_per_process; } if let Some(exited_process_retention_sec) = processes.exited_process_retention_sec { runtime.processes.exited_process_retention = Duration::from_secs(exited_process_retention_sec); } } if let Some(filesystem) = filesystem && let Some(max_read_file_bytes) = filesystem.max_read_file_bytes { runtime.filesystem.max_read_file_bytes = max_read_file_bytes; } runtime.validate()?; Ok(Self { listen, remote: remote.map(TryInto::try_into).transpose()?, runtime, }) } } impl TryFrom for RemoteExecServerConfig { type Error = ExecServerError; fn try_from(config: RemoteExecServerToml) -> Result { let RemoteExecServerToml { url, environment_id, name, use_agent_identity_auth, reconnect_initial_backoff_sec, reconnect_max_backoff_sec, } = config; let reconnect_initial_backoff = Duration::from_secs(reconnect_initial_backoff_sec.unwrap_or(1)); let reconnect_max_backoff = Duration::from_secs(reconnect_max_backoff_sec.unwrap_or(30)); validate_reconnect_backoff(reconnect_initial_backoff, reconnect_max_backoff)?; Ok(Self { url, environment_id, name, use_agent_identity_auth: use_agent_identity_auth.unwrap_or(false), reconnect_initial_backoff, reconnect_max_backoff, }) } } pub(crate) fn validate_reconnect_backoff( reconnect_initial_backoff: Duration, reconnect_max_backoff: Duration, ) -> Result<(), ExecServerError> { if reconnect_initial_backoff.is_zero() || reconnect_max_backoff.is_zero() { return Err(ExecServerError::Protocol( "remote reconnect backoff must be positive".to_string(), )); } if reconnect_initial_backoff > reconnect_max_backoff { return Err(ExecServerError::Protocol( "remote reconnect initial backoff must not exceed max backoff".to_string(), )); } Ok(()) } fn validate_detached_session_ttl(detached_session_ttl: Duration) -> Result<(), ExecServerError> { if std::time::Instant::now() .checked_add(detached_session_ttl) .is_none() { return Err(ExecServerError::Protocol( "session detached TTL is too large".to_string(), )); } Ok(()) } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use tempfile::tempdir; use super::*; #[test] fn absent_file_uses_current_defaults() { let codex_home = tempdir().expect("tempdir"); let config = ExecServerConfig::from_codex_home(codex_home.path()).expect("config"); assert_eq!(config, ExecServerConfig::default()); } #[test] fn file_loads_remote_and_runtime_settings() { let codex_home = tempdir().expect("tempdir"); std::fs::write( codex_home.path().join(EXEC_SERVER_TOML_FILE), r#" [remote] url = "https://chatgpt.com" environment_id = "devbox-123" name = "remote-name" use_agent_identity_auth = true reconnect_initial_backoff_sec = 2 reconnect_max_backoff_sec = 10 [sessions] detached_ttl_sec = 11 [processes] retained_output_bytes_per_process = 12 exited_process_retention_sec = 13 [filesystem] max_read_file_bytes = 14 "#, ) .expect("write config"); let config = ExecServerConfig::from_codex_home(codex_home.path()).expect("config"); assert_eq!( config, ExecServerConfig { listen: None, remote: Some(RemoteExecServerConfig { url: Some("https://chatgpt.com".to_string()), environment_id: Some("devbox-123".to_string()), name: Some("remote-name".to_string()), use_agent_identity_auth: true, reconnect_initial_backoff: Duration::from_secs(2), reconnect_max_backoff: Duration::from_secs(10), }), runtime: ExecServerRuntimeConfig { sessions: SessionRegistryConfig { detached_session_ttl: Duration::from_secs(11), }, processes: LocalProcessConfig { retained_output_bytes_per_process: 12, exited_process_retention: Duration::from_secs(13), }, filesystem: LocalFileSystemConfig { max_read_file_bytes: 14, }, }, } ); } #[test] fn file_loads_partial_remote_settings() { let codex_home = tempdir().expect("tempdir"); std::fs::write( codex_home.path().join(EXEC_SERVER_TOML_FILE), r#" [remote] environment_id = "devbox-123" name = "remote-name" "#, ) .expect("write config"); let config = ExecServerConfig::from_codex_home(codex_home.path()).expect("config"); assert_eq!( config.remote, Some(RemoteExecServerConfig { url: None, environment_id: Some("devbox-123".to_string()), name: Some("remote-name".to_string()), use_agent_identity_auth: false, reconnect_initial_backoff: Duration::from_secs(1), reconnect_max_backoff: Duration::from_secs(30), }) ); } #[test] fn file_rejects_listen_with_remote() { let codex_home = tempdir().expect("tempdir"); std::fs::write( codex_home.path().join(EXEC_SERVER_TOML_FILE), r#" listen = "ws://127.0.0.1:0" [remote] url = "https://chatgpt.com" environment_id = "devbox-123" "#, ) .expect("write config"); let err = ExecServerConfig::from_codex_home(codex_home.path()).expect_err("invalid config"); assert_eq!( err.to_string(), "exec-server protocol error: exec-server config cannot set both `listen` and `[remote]`" ); } #[test] fn file_rejects_unknown_fields() { let codex_home = tempdir().expect("tempdir"); std::fs::write( codex_home.path().join(EXEC_SERVER_TOML_FILE), "unknown = true\n", ) .expect("write config"); let err = ExecServerConfig::from_codex_home(codex_home.path()).expect_err("invalid config"); assert!(err.to_string().contains("unknown field `unknown`")); } #[test] fn file_rejects_invalid_remote_reconnect_backoff() { for (initial, max, expected) in [ (0, 30, "remote reconnect backoff must be positive"), ( 31, 30, "remote reconnect initial backoff must not exceed max backoff", ), ] { let config = ExecServerToml { remote: Some(RemoteExecServerToml { url: Some("https://chatgpt.com".to_string()), environment_id: Some("devbox-123".to_string()), name: None, use_agent_identity_auth: None, reconnect_initial_backoff_sec: Some(initial), reconnect_max_backoff_sec: Some(max), }), ..Default::default() }; let err = ExecServerConfig::try_from(config).expect_err("invalid config"); assert_eq!( err.to_string(), format!("exec-server protocol error: {expected}") ); } } #[test] fn file_rejects_invalid_detached_session_ttl() { let config = ExecServerToml { sessions: Some(SessionsToml { detached_ttl_sec: Some(u64::MAX), }), ..Default::default() }; let err = ExecServerConfig::try_from(config).expect_err("invalid config"); assert_eq!( err.to_string(), "exec-server protocol error: session detached TTL is too large" ); } #[test] fn runtime_rejects_invalid_detached_session_ttl() { let config = ExecServerRuntimeConfig { sessions: SessionRegistryConfig { detached_session_ttl: Duration::MAX, }, ..Default::default() }; let err = config.validate().expect_err("invalid config"); assert_eq!( err.to_string(), "exec-server protocol error: session detached TTL is too large" ); } }