diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index fa6b6cd811f7..c92cd335e172 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -527,17 +527,27 @@ pub async fn run_main_with_transport_options( let message = config_warning_from_error("Invalid configuration; using defaults.", &err); config_warnings.push(message); - ( - config_manager.load_default_config().await.map_err(|e| { - std::io::Error::new( - ErrorKind::InvalidData, - format!("error loading default config after config error: {e}"), - ) - })?, - false, - ) + let mut config = config_manager.load_default_config().await.map_err(|e| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("error loading default config after config error: {e}"), + ) + })?; + config + .features + .disable(codex_features::Feature::Sqlite) + .map_err(std::io::Error::other)?; + (config, false) } }; + if !config.features.enabled(codex_features::Feature::Sqlite) { + config_warnings.push(ConfigWarningNotification { + summary: "SQLite is disabled. Codex is running in degraded mode; SQLite-dependent features and operations are unavailable.".to_string(), + details: None, + path: None, + range: None, + }); + } let otel = codex_core::otel_init::build_provider( &config, @@ -1208,6 +1218,13 @@ struct StateDbInitResult { async fn init_sqlite_state_db_with_fresh_start_on_corruption( config: &Config, ) -> anyhow::Result { + if !config.features.enabled(codex_features::Feature::Sqlite) { + return Ok(StateDbInitResult { + state_db: None, + recovery_notice: None, + }); + } + let mut attempted_backups = HashSet::new(); let mut recovered_databases = Vec::new(); loop { @@ -1368,9 +1385,12 @@ mod tests { use super::loader_overrides_with_test_user_config_file; #[cfg(debug_assertions)] use codex_config::LoaderOverrides; + use codex_core::config::ConfigBuilder; + use codex_features::Feature; #[cfg(debug_assertions)] use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use tempfile::TempDir; #[test] fn log_format_from_env_value_matches_json_values_case_insensitively() { @@ -1390,6 +1410,26 @@ mod tests { assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default); } + #[tokio::test] + async fn sqlite_state_db_can_be_explicitly_disabled() -> anyhow::Result<()> { + let temp_dir = TempDir::new()?; + let mut config = ConfigBuilder::default() + .codex_home(temp_dir.path().to_path_buf()) + .build() + .await?; + config.features.disable(Feature::Sqlite)?; + let occupied_sqlite_home = temp_dir.path().join("sqlite-home"); + std::fs::write(&occupied_sqlite_home, "occupied")?; + config.sqlite_home = occupied_sqlite_home.clone(); + + let result = super::init_sqlite_state_db_with_fresh_start_on_corruption(&config).await?; + + assert!(result.state_db.is_none()); + assert!(result.recovery_notice.is_none()); + assert_eq!(std::fs::read_to_string(occupied_sqlite_home)?, "occupied"); + Ok(()) + } + #[cfg(debug_assertions)] #[test] fn debug_test_user_config_file_overrides_loader_path() { diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 5bae994ca5e2..ac0b5b7ce2f9 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -355,10 +355,9 @@ impl ExternalAgentConfigRequestProcessor { pub(crate) async fn read_import_histories( &self, ) -> Result { - let state_db = self - .state_db - .as_ref() - .ok_or_else(|| internal_error("state database is unavailable"))?; + let Some(state_db) = self.state_db.as_ref() else { + return Ok(ExternalAgentConfigImportHistoriesReadResponse { data: Vec::new() }); + }; let histories = state_db .external_agent_config_import_history_records() .await diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index af8525873f52..e545370d39d4 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1586,6 +1586,12 @@ impl ThreadRequestProcessor { } async fn memory_reset_response_inner(&self) -> Result { + if !self.config.features.enabled(Feature::Sqlite) { + return Err(invalid_request( + "memory reset requires SQLite, which is disabled", + )); + } + let state_db = self .state_db .clone() @@ -1615,6 +1621,12 @@ impl ThreadRequestProcessor { &self, params: ThreadMetadataUpdateParams, ) -> Result { + if !self.config.features.enabled(Feature::Sqlite) { + return Err(invalid_request( + "thread metadata updates require SQLite, which is disabled", + )); + } + let ThreadMetadataUpdateParams { thread_id, git_info, @@ -1960,6 +1972,11 @@ impl ThreadRequestProcessor { )), (None, None) => None, }; + if relation_filter.is_some() && !self.config.features.enabled(Feature::Sqlite) { + return Err(invalid_request( + "thread relationship filters require SQLite, which is disabled", + )); + } let requested_page_size = limit .map(|value| value as usize) diff --git a/codex-rs/app-server/tests/suite/strict_config.rs b/codex-rs/app-server/tests/suite/strict_config.rs index 93784c975272..993d6ed44aaa 100644 --- a/codex-rs/app-server/tests/suite/strict_config.rs +++ b/codex-rs/app-server/tests/suite/strict_config.rs @@ -31,3 +31,38 @@ foo = "bar" Ok(()) } + +#[test] +fn non_strict_config_fallback_disables_sqlite() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +model = 123 + +[features] +sqlite = false +"#, + )?; + + let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex-app-server")?) + .env("CODEX_HOME", codex_home.path()) + .env(codex_state::SQLITE_HOME_ENV, codex_home.path()) + .env( + "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", + codex_home.path().join("managed_config.toml"), + ) + .args(["--listen", "off"]) + .output()?; + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr)?; + assert!( + stderr.contains("no transport configured"), + "expected startup to reach transport validation, got: {stderr}" + ); + for db in codex_state::runtime_db_paths(codex_home.path()) { + assert!(!db.path.exists(), "{} should not exist", db.label); + } + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 945ab16d7ebb..ad772cdd2575 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -40,6 +40,7 @@ use tokio::time::timeout; // Bazel CI can spend tens of seconds starting app-server subprocesses or // processing config RPCs under load. const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); +const SQLITE_DISABLED_WARNING_SUMMARY: &str = "SQLite is disabled. Codex is running in degraded mode; SQLite-dependent features and operations are unavailable."; fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> { Ok(std::fs::write( @@ -48,6 +49,39 @@ fn write_config(codex_home: &TempDir, contents: &str) -> Result<()> { )?) } +#[tokio::test] +async fn sqlite_disabled_emits_degraded_mode_warning() -> Result<()> { + let codex_home = TempDir::new()?; + write_config(&codex_home, "[features]\nsqlite = false\n")?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("CODEX_SQLITE_HOME", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let warning = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_matching_notification("SQLite disabled config warning", |message| { + message.method == "configWarning" + && message + .params + .as_ref() + .and_then(|params| params.get("summary")) + .and_then(serde_json::Value::as_str) + == Some(SQLITE_DISABLED_WARNING_SUMMARY) + }), + ) + .await??; + + assert_eq!( + warning + .params + .as_ref() + .and_then(|params| params.get("summary")) + .and_then(serde_json::Value::as_str), + Some(SQLITE_DISABLED_WARNING_SUMMARY) + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_requirements_read_includes_allow_remote_control() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 3d4b09579eef..849836f2415d 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -51,6 +51,37 @@ fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String response.import_id } +#[tokio::test] +async fn external_agent_config_import_histories_are_empty_when_sqlite_is_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\nsqlite = false\n", + )?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("CODEX_SQLITE_HOME", None)]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import/readHistories", + /*params*/ None, + ) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportHistoriesReadResponse = to_response(response)?; + + assert!(response.data.is_empty()); + for db in codex_state::runtime_db_paths(codex_home.path()) { + assert!(!db.path.exists(), "{} should not exist", db.label); + } + Ok(()) +} + #[tokio::test] async fn external_agent_config_import_sends_completion_notification_for_sync_only_import() -> Result<()> { diff --git a/codex-rs/app-server/tests/suite/v2/memory_reset.rs b/codex-rs/app-server/tests/suite/v2/memory_reset.rs index 07e16a3ba6bc..e48fe8b9a574 100644 --- a/codex-rs/app-server/tests/suite/v2/memory_reset.rs +++ b/codex-rs/app-server/tests/suite/v2/memory_reset.rs @@ -22,7 +22,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs #[tokio::test] async fn memory_reset_clears_memory_files_and_rows_preserves_threads() -> Result<()> { let codex_home = TempDir::new()?; - create_config_toml(codex_home.path())?; + create_config_toml(codex_home.path(), /*sqlite_enabled*/ true)?; let state_db = init_state_db(codex_home.path()).await?; let memory_root = codex_home.path().join("memories"); @@ -68,6 +68,37 @@ async fn memory_reset_clears_memory_files_and_rows_preserves_threads() -> Result Ok(()) } +#[tokio::test] +async fn memory_reset_reports_sqlite_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), /*sqlite_enabled*/ false)?; + let memory_root = codex_home.path().join("memories"); + tokio::fs::create_dir_all(&memory_root).await?; + tokio::fs::write(memory_root.join("MEMORY.md"), "stale memory\n").await?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request("memory/reset", /*params*/ None) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "memory reset requires SQLite, which is disabled" + ); + assert!(memory_root.join("MEMORY.md").is_file()); + for db in codex_state::runtime_db_paths(codex_home.path()) { + assert!(!db.path.exists(), "{} should not exist", db.label); + } + Ok(()) +} + async fn seed_stage1_output(state_db: &Arc, codex_home: &Path) -> Result { let now = Utc::now(); let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; @@ -126,11 +157,12 @@ async fn init_state_db(codex_home: &Path) -> Result> { Ok(state_db) } -fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { +fn create_config_toml(codex_home: &Path, sqlite_enabled: bool) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( config_toml, - r#" + format!( + r#" model = "mock-model" approval_policy = "never" sandbox_mode = "read-only" @@ -138,7 +170,7 @@ model_provider = "mock_provider" suppress_unstable_features_warning = true [features] -sqlite = true +sqlite = {sqlite_enabled} [model_providers.mock_provider] name = "Mock provider for test" @@ -147,5 +179,6 @@ wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 "#, + ), ) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 8ade0bd382a1..d1384120cae4 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1208,6 +1208,44 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_list_relation_filters_report_sqlite_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\nsqlite = false\n", + )?; + let mut mcp = init_mcp(codex_home.path()).await?; + let request_id = mcp + .send_thread_list_request(codex_app_server_protocol::ThreadListParams { + cursor: None, + sort_key: None, + sort_direction: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + use_state_db_only: false, + search_term: None, + parent_thread_id: Some(ThreadId::new().to_string()), + ancestor_thread_id: None, + limit: Some(10), + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "thread relationship filters require SQLite, which is disabled" + ); + Ok(()) +} + #[tokio::test] async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index e20a5fab4470..9ea112d9eaba 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -35,6 +35,42 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +#[tokio::test] +async fn thread_metadata_update_reports_sqlite_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\nsqlite = false\n", + )?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: ThreadId::new().to_string(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/test".to_string())), + origin_url: None, + }), + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "thread metadata updates require SQLite, which is disabled" + ); + for db in codex_state::runtime_db_paths(codex_home.path()) { + assert!(!db.path.exists(), "{} should not exist", db.label); + } + Ok(()) +} + #[tokio::test] async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index ef587cd1d4eb..03a335eaa0e0 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2149,13 +2149,19 @@ fn non_empty_trimmed(value: String) -> Option { async fn state_check(config: &Config) -> DoctorCheck { let mut details = Vec::new(); + let sqlite_enabled = config.features.enabled(codex_features::Feature::Sqlite); path_readiness(&mut details, "CODEX_HOME", &config.codex_home); path_readiness(&mut details, "log dir", &config.log_dir); path_readiness(&mut details, "sqlite home", &config.sqlite_home); let mut integrity_failures = Vec::new(); for db in codex_state::runtime_db_paths(&config.sqlite_home) { path_readiness(&mut details, db.label, &db.path); - sqlite_integrity_detail(&mut details, &mut integrity_failures, db.label, &db.path).await; + if sqlite_enabled { + sqlite_integrity_detail(&mut details, &mut integrity_failures, db.label, &db.path) + .await; + } else { + details.push(format!("{} integrity: skipped (SQLite disabled)", db.label)); + } } rollout_stats_details(&mut details, &config.codex_home); standalone_release_cache_details(&mut details); @@ -2165,7 +2171,9 @@ async fn state_check(config: &Config) -> DoctorCheck { } else { CheckStatus::Fail }; - let summary = if status == CheckStatus::Ok { + let summary = if !sqlite_enabled { + "state paths are inspectable; SQLite is disabled" + } else if status == CheckStatus::Ok { "state paths and databases are inspectable" } else { "state database integrity check failed" @@ -3082,6 +3090,40 @@ mod tests { use super::*; + #[tokio::test] + async fn state_check_skips_inactive_corrupt_sqlite_database() { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + config + .features + .disable(codex_features::Feature::Sqlite) + .expect("disable sqlite"); + std::fs::write( + codex_state::state_db_path(config.sqlite_home.as_path()), + "not a sqlite database", + ) + .expect("write corrupt database"); + + let check = state_check(&config).await; + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!( + check.summary, + "state paths are inspectable; SQLite is disabled" + ); + assert_eq!(check.remediation, None); + assert!( + check + .details + .iter() + .any(|detail| detail == "state DB integrity: skipped (SQLite disabled)") + ); + } + #[derive(Default)] struct RecordingProgress { events: Mutex>, diff --git a/codex-rs/cli/src/doctor/thread_inventory.rs b/codex-rs/cli/src/doctor/thread_inventory.rs index c50e6debdc08..ba7c59cc66e3 100644 --- a/codex-rs/cli/src/doctor/thread_inventory.rs +++ b/codex-rs/cli/src/doctor/thread_inventory.rs @@ -82,6 +82,16 @@ impl RolloutScan { } pub(super) async fn thread_inventory_check(config: &Config) -> DoctorCheck { + if !config.features.enabled(codex_features::Feature::Sqlite) { + return DoctorCheck::new( + CHECK_ID, + CHECK_CATEGORY, + CheckStatus::Ok, + "state database is disabled", + ) + .details(vec!["rollout/state DB parity check skipped".to_string()]); + } + thread_inventory_check_for_roots( config.codex_home.as_path(), config.sqlite_home.as_path(), @@ -677,6 +687,8 @@ where #[cfg(test)] mod tests { use super::*; + use codex_core::config::ConfigBuilder; + use codex_features::Feature; use codex_protocol::ThreadId; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; @@ -685,6 +697,26 @@ mod tests { use sqlx::sqlite::SqlitePoolOptions; use tempfile::TempDir; + #[tokio::test] + async fn thread_inventory_check_skips_intentionally_disabled_database() { + let codex_home = TempDir::new().expect("codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + config + .features + .disable(Feature::Sqlite) + .expect("disable sqlite"); + + let check = thread_inventory_check(&config).await; + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.summary, "state database is disabled"); + assert!(check.issues.is_empty()); + } + #[tokio::test] async fn thread_inventory_check_ok_when_rollouts_match_db() { let fixture = Fixture::new().await; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index c766baa04881..3e1d23844aee 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2022,6 +2022,13 @@ async fn run_debug_clear_memories_command( .cli_overrides(cli_kv_overrides) .build() .await?; + clear_memories_for_config(&config).await +} + +async fn clear_memories_for_config(config: &codex_core::config::Config) -> anyhow::Result<()> { + if !config.features.enabled(codex_features::Feature::Sqlite) { + anyhow::bail!("`codex debug clear-memories` requires SQLite, which is disabled"); + } let memories_path = memories_db_path(config.sqlite_home.as_path()); let cleared_memories_db = @@ -2497,6 +2504,37 @@ mod tests { use codex_protocol::ThreadId; use codex_tui::TokenUsage; use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[tokio::test] + async fn clear_memories_rejects_disabled_sqlite_without_mutating_state() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .loader_overrides(LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + config.sqlite_home = codex_home.path().to_path_buf(); + config.features.disable(codex_features::Feature::Sqlite)?; + let memory_root = codex_home.path().join("memories"); + let memory_file = memory_root.join("memory_summary.md"); + std::fs::create_dir_all(&memory_root)?; + std::fs::write(&memory_file, "keep me")?; + + let error = clear_memories_for_config(&config) + .await + .expect_err("disabled SQLite should reject the command"); + + assert_eq!( + error.to_string(), + "`codex debug clear-memories` requires SQLite, which is disabled" + ); + assert_eq!(std::fs::read_to_string(memory_file)?, "keep me"); + for db in codex_state::runtime_db_paths(config.sqlite_home.as_path()) { + assert!(!db.path.exists(), "{} should not exist", db.label); + } + Ok(()) + } #[test] fn exec_server_remote_auth_accepts_api_key_auth() { diff --git a/codex-rs/core/src/state_db_bridge.rs b/codex-rs/core/src/state_db_bridge.rs index 78d3cb11f906..ed6521841574 100644 --- a/codex-rs/core/src/state_db_bridge.rs +++ b/codex-rs/core/src/state_db_bridge.rs @@ -4,5 +4,32 @@ pub use codex_rollout::state_db::StateDbHandle; use crate::config::Config; pub async fn init_state_db(config: &Config) -> Option { + if !config.features.enabled(codex_features::Feature::Sqlite) { + return None; + } rollout_state_db::init(config).await } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::ConfigBuilder; + use codex_features::Feature; + use tempfile::TempDir; + + #[tokio::test] + async fn sqlite_disabled_skips_state_db_initialization() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + config.features.disable(Feature::Sqlite)?; + + assert!(init_state_db(&config).await.is_none()); + for db in codex_state::runtime_db_paths(config.sqlite_home.as_path()) { + assert!(!db.path.exists(), "{} should not exist", db.label); + } + Ok(()) + } +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 166dc912fdaa..7ea9f2f83816 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -547,6 +547,11 @@ impl Features { } pub fn normalize_dependencies(&mut self) { + if !self.enabled(Feature::Sqlite) { + // Keep every feature that cannot operate without SQLite in this block. + self.disable(Feature::Goals); + self.disable(Feature::SpawnCsv); + } if self.enabled(Feature::SpawnCsv) && !self.enabled(Feature::Collab) { self.enable(Feature::Collab); } @@ -917,7 +922,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Sqlite, key: "sqlite", - stage: Stage::Removed, + stage: Stage::Stable, default_enabled: true, }, FeatureSpec { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 7291c4e1b1af..2989a8b0da86 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -47,6 +47,20 @@ fn use_legacy_landlock_is_deprecated_and_disabled_by_default() { assert_eq!(Feature::UseLegacyLandlock.default_enabled(), false); } +#[test] +fn sqlite_is_stable_enabled_by_default_and_configurable() { + assert_eq!(Feature::Sqlite.stage(), Stage::Stable); + assert_eq!(Feature::Sqlite.default_enabled(), true); + + let mut features = Features::with_defaults(); + features.enable(Feature::SpawnCsv); + features.apply_map(&BTreeMap::from([("sqlite".to_string(), false)])); + features.normalize_dependencies(); + assert_eq!(features.enabled(Feature::Sqlite), false); + assert_eq!(features.enabled(Feature::Goals), false); + assert_eq!(features.enabled(Feature::SpawnCsv), false); +} + #[test] fn use_linux_sandbox_bwrap_is_removed_and_disabled_by_default() { assert_eq!(Feature::UseLinuxSandboxBwrap.stage(), Stage::Removed); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index dc8262f7db30..2c300f932a01 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -282,6 +282,10 @@ async fn init_state_db_for_app_server_target( config: &Config, app_server_target: &AppServerTarget, ) -> std::io::Result> { + if !config.features.enabled(codex_features::Feature::Sqlite) { + return Ok(None); + } + match app_server_target { AppServerTarget::Embedded => state_db::try_init(config).await.map(Some).map_err(|err| { let database_path = codex_state::runtime_db_path_for_corruption_error(&err) @@ -814,6 +818,17 @@ fn app_server_target_for_launch( } } +fn app_server_target_for_effective_sqlite_setting( + target: AppServerTarget, + sqlite_enabled: bool, +) -> AppServerTarget { + // An implicitly reused daemon cannot adopt this invocation's effective feature set. + match target { + AppServerTarget::LocalDaemon { .. } if !sqlite_enabled => AppServerTarget::Embedded, + target => target, + } +} + fn loader_overrides_are_default(loader_overrides: &LoaderOverrides) -> bool { let loader_overrides_are_default = loader_overrides.user_config_path.is_none() && loader_overrides.user_config_profile.is_none() @@ -1067,6 +1082,10 @@ pub async fn run_main( strict_config, ) .await; + let app_server_target = app_server_target_for_effective_sqlite_setting( + app_server_target, + config.features.enabled(codex_features::Feature::Sqlite), + ); remove_legacy_tui_log_file(config.codex_home.as_path()); @@ -2319,6 +2338,44 @@ mod tests { Ok(()) } + #[test] + fn sqlite_disabled_does_not_reuse_implicit_local_daemon() -> color_eyre::Result<()> { + let local_endpoint = RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("local.sock")?, + }; + let local_target = AppServerTarget::LocalDaemon { + endpoint: local_endpoint, + }; + assert_eq!( + app_server_target_for_effective_sqlite_setting( + local_target.clone(), + /*sqlite_enabled*/ false, + ), + AppServerTarget::Embedded + ); + assert_eq!( + app_server_target_for_effective_sqlite_setting( + local_target.clone(), + /*sqlite_enabled*/ true, + ), + local_target + ); + + let remote_target = AppServerTarget::Remote { + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::relative_to_current_dir("remote.sock")?, + }, + }; + assert_eq!( + app_server_target_for_effective_sqlite_setting( + remote_target.clone(), + /*sqlite_enabled*/ false, + ), + remote_target + ); + Ok(()) + } + #[test] fn can_reuse_implicit_local_daemon_requires_default_launch_config() -> color_eyre::Result<()> { let mut loader_overrides = LoaderOverrides::default(); @@ -2916,6 +2973,23 @@ mod tests { Ok(()) } + #[tokio::test] + async fn embedded_state_db_can_be_explicitly_disabled() -> color_eyre::Result<()> { + let temp_dir = TempDir::new()?; + let mut config = build_config(&temp_dir).await?; + config.features.disable(codex_features::Feature::Sqlite)?; + let occupied_sqlite_home = temp_dir.path().join("sqlite-home"); + std::fs::write(&occupied_sqlite_home, "occupied")?; + config.sqlite_home = occupied_sqlite_home.clone(); + + let state_db = + init_state_db_for_app_server_target(&config, &AppServerTarget::Embedded).await?; + + assert!(state_db.is_none()); + assert_eq!(std::fs::read_to_string(occupied_sqlite_home)?, "occupied"); + Ok(()) + } + #[tokio::test] async fn embedded_state_db_corruption_preserves_failed_database_for_cli_recovery() -> color_eyre::Result<()> { diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index d3e728f5f8c1..390a90cbd3aa 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -384,6 +384,10 @@ async fn start_app_server_for_archive_command( .build() .await .wrap_err("failed to load configuration")?; + let app_server_target = super::app_server_target_for_effective_sqlite_setting( + app_server_target, + config.features.enabled(codex_features::Feature::Sqlite), + ); let state_db = super::init_state_db_for_app_server_target(&config, &app_server_target) .await .wrap_err("failed to initialize state database")?;