diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 3a7bb36630..ce7d58e04d 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -72,7 +72,9 @@ const STATE_DIR_NAME: &str = "app-server-daemon"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum LifecycleCommand { - Start, + Start { + feature_overrides: BTreeMap, + }, Restart { feature_overrides: BTreeMap, }, @@ -243,7 +245,7 @@ pub async fn run(command: LifecycleCommand) -> Result { #[cfg(windows)] if matches!( command, - LifecycleCommand::Start | LifecycleCommand::Restart { .. } + LifecycleCommand::Start { .. } | LifecycleCommand::Restart { .. } ) { backend::windows::ensure_not_elevated()?; } @@ -423,8 +425,8 @@ impl Daemon { let _operation_lock = self.acquire_operation_lock().await?; let selected = self.current_installation()?; match command { - LifecycleCommand::Start => selected - .start(&BTreeMap::new()) + LifecycleCommand::Start { feature_overrides } => selected + .start(&feature_overrides) .await .map_err(|err| err.error), LifecycleCommand::Restart { feature_overrides } => { @@ -1503,7 +1505,9 @@ mod tests { ) .expect("legacy saved threads"); daemon - .run(super::LifecycleCommand::Start) + .run(super::LifecycleCommand::Start { + feature_overrides: Default::default(), + }) .await .expect_err("missing backend binary"); assert!(!daemon.recovery_file().expect("recovery path").exists()); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index a1462ee2a3..285c1222ec 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1329,9 +1329,6 @@ async fn dispatch_cli( } } Some(AppServerSubcommand::Daemon(daemon_cli)) => match daemon_cli.subcommand { - AppServerDaemonSubcommand::Start => { - print_app_server_daemon_output(AppServerLifecycleCommand::Start).await?; - } AppServerDaemonSubcommand::Bootstrap(bootstrap_cli) => { let output = codex_app_server_daemon::bootstrap(AppServerBootstrapOptions { @@ -1340,7 +1337,10 @@ async fn dispatch_cli( .await?; println!("{}", serde_json::to_string(&output)?); } - AppServerDaemonSubcommand::Restart => { + command @ (AppServerDaemonSubcommand::Start + | AppServerDaemonSubcommand::Restart) => { + let start = matches!(command, AppServerDaemonSubcommand::Start); + let action = if start { "start" } else { "restart" }; let overrides = root_config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?; @@ -1369,18 +1369,20 @@ async fn dispatch_cli( let feature = codex_features::feature_for_key(name).ok_or_else(|| { anyhow::anyhow!( - "daemon restart requires a boolean feature toggle: {name}" + "daemon {action} requires a boolean feature toggle: {name}" ) })?; let enabled = value.as_bool().ok_or_else(|| { - anyhow::anyhow!("daemon restart requires a boolean enablement for features.{name}") + anyhow::anyhow!("daemon {action} requires a boolean enablement for features.{name}") })?; feature_overrides.insert(feature.key().to_string(), enabled); } - print_app_server_daemon_output(AppServerLifecycleCommand::Restart { - feature_overrides, - }) - .await?; + let command = if start { + AppServerLifecycleCommand::Start { feature_overrides } + } else { + AppServerLifecycleCommand::Restart { feature_overrides } + }; + print_app_server_daemon_output(command).await?; } AppServerDaemonSubcommand::Update { from_cli: true, diff --git a/codex-rs/cli/tests/app_server_daemon.rs b/codex-rs/cli/tests/app_server_daemon.rs index 72b10fa51b..4a1b4c3499 100644 --- a/codex-rs/cli/tests/app_server_daemon.rs +++ b/codex-rs/cli/tests/app_server_daemon.rs @@ -122,6 +122,42 @@ fn bootstrap_reports_updater_bind_failure() -> Result<()> { Ok(()) } +#[test] +fn start_applies_feature_overrides_only_when_launching() -> Result<()> { + let daemon = TestDaemon::new()?; + let settings_path = daemon.home.path().join("app-server-daemon/settings.json"); + std::fs::write( + &settings_path, + br#"{"updater":{"autoUpdateEnabled":false}}"#, + )?; + let mut previous_pid = None; + for (prefer_mxc, expected_status) in [(true, "started"), (false, "alreadyRunning")] { + let output = daemon + .command() + .args(["app-server", "daemon", "start", "-c"]) + .arg(format!("features.prefer_mxc={prefer_mxc}")) + .output()?; + ensure!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let output: Value = serde_json::from_slice(&output.stdout)?; + assert_eq!(output["status"], expected_status); + let settings: Value = serde_json::from_slice(&std::fs::read(&settings_path)?)?; + assert_eq!( + settings["featureOverrides"], + serde_json::json!({"prefer_mxc": true}) + ); + let pid = daemon.pid("app-server.pid")?; + if let Some(previous_pid) = previous_pid { + assert_eq!(pid, previous_pid); + } + previous_pid = Some(pid); + } + Ok(()) +} + #[test] fn explicit_restart_applies_feature_overrides_and_preserves_saved_settings() -> Result<()> { let daemon = TestDaemon::new()?; @@ -187,23 +223,24 @@ fn explicit_restart_applies_feature_overrides_and_preserves_saved_settings() -> } // Non-toggle config keys must fail before stopping the daemon or saving settings. let saved_settings = std::fs::read(&settings_path)?; - let output = daemon - .command() - .args([ - "app-server", - "daemon", - "restart", - "--enable", - "tool_registry", - ]) - .output()?; - assert!(!output.status.success()); + let mut errors = String::new(); + for action in ["start", "restart"] { + let output = daemon + .command() + .args(["app-server", "daemon", action, "--enable", "tool_registry"]) + .output()?; + assert!(!output.status.success()); + errors.push_str(&String::from_utf8(output.stderr)?); + assert_eq!(std::fs::read(&settings_path)?, saved_settings); + assert_eq!(Some(u64::from(daemon.pid("app-server.pid")?)), previous_pid); + } insta::assert_snapshot!( - String::from_utf8(output.stderr)?, - @"Error: daemon restart requires a boolean feature toggle: tool_registry" + errors, + @" + Error: daemon start requires a boolean feature toggle: tool_registry + Error: daemon restart requires a boolean feature toggle: tool_registry + " ); - assert_eq!(std::fs::read(&settings_path)?, saved_settings); - assert_eq!(Some(u64::from(daemon.pid("app-server.pid")?)), previous_pid); assert_eq!(daemon.lifecycle("version")?["status"], "running"); Ok(()) }