From 787823cf957709b314276646024ec54e1761c089 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 02:39:01 +0000 Subject: [PATCH] Add `--no-daemon` to bypass the shared background server (#46088) ## What changed - Run with `--no-daemon` without starting or probing the shared server, even when it is already running. Preserve the flag through `resume` and `fork`, and honor it for session archive commands. - Reject combinations with `--remote`, `codex agents`, and `codex queue`, which require a server connection. Point users to `codex --no-daemon` when the agents overview cannot start its shared server. - Centralize daemon eligibility checks and exclude launches using `--profile` or `CODEX_EXEC_SERVER_URL` from implicit daemon reuse. ## Testing Add coverage for flag propagation, incompatible command combinations, and daemon eligibility. Add a PTY test verifying that `--no-daemon` starts the TUI without creating daemon state or connecting to an existing control socket. GitOrigin-RevId: acc6a7cb339df6433bb8273c796194cdceda37c1 --- codex-rs/cli/src/main.rs | 37 ++++++++- codex-rs/cli/tests/features.rs | 32 ++++++++ codex-rs/tui/src/cli.rs | 4 + codex-rs/tui/src/daemon_startup.rs | 53 +++++++++++++ codex-rs/tui/src/daemon_startup_tests.rs | 79 ++++++++++++++++++++ codex-rs/tui/src/lib.rs | 54 +------------ codex-rs/tui/src/session_archive_commands.rs | 11 ++- codex-rs/tui/src/session_queue_commands.rs | 5 ++ codex-rs/tui/src/startup_orchestration.rs | 23 +++--- codex-rs/tui/tests/suite/focus_palette.rs | 40 +++++++++- 10 files changed, 271 insertions(+), 67 deletions(-) create mode 100644 codex-rs/tui/src/daemon_startup.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 339ab94573..d474558eb8 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -337,6 +337,9 @@ struct DebugTraceReduceCommand { #[derive(Debug, Parser)] struct AgentsCommand { + /// The agents overview requires a shared server; this option is rejected. + #[arg(long, hide = true)] + no_daemon: bool, #[clap(flatten)] remote: InteractiveRemoteOptions, @@ -1215,6 +1218,7 @@ async fn cli_main( if let Some(options) = agents_options { interactive.cwd = options.cwd.clone().or(interactive.cwd.take()); interactive.no_alt_screen |= options.no_alt_screen; + interactive.no_daemon |= options.no_daemon; } let root_strict_config = interactive.strict_config; interactive @@ -2797,6 +2801,18 @@ async fn run_interactive_tui( remote_auth_token_env: Option, arg0_paths: Arg0DispatchPaths, ) -> std::io::Result { + if interactive.no_daemon { + if interactive.agents_overview { + return Ok(AppExitInfo::fatal( + "--no-daemon cannot be used with codex agents. The agents overview requires a shared server. Use codex --no-daemon to work without it.", + )); + } + if remote.is_some() { + return Ok(AppExitInfo::fatal( + "--no-daemon cannot be used with --remote.", + )); + } + } if let Some(prompt) = interactive.prompt.take() { // Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state. interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n")); @@ -2833,7 +2849,9 @@ async fn run_interactive_tui( .map_err(std::io::Error::other)?; codex_app_server_daemon::run(AppServerLifecycleCommand::Start) .await - .map_err(std::io::Error::other)?; + .map_err(|err| std::io::Error::other(format!( + "{err:#}\nThe agents overview requires a shared server. Use codex --no-daemon to work without it." + )))?; } let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env.clone()) { @@ -3056,6 +3074,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) approval_policy, web_search, no_alt_screen, + no_daemon, prompt, mut config_overrides, .. @@ -3076,6 +3095,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) interactive.web_search = true; } interactive.no_alt_screen |= no_alt_screen; + interactive.no_daemon |= no_daemon; if strict_config { interactive.strict_config = true; } @@ -4266,6 +4286,21 @@ mod tests { assert_eq!(interactive.resume_session_id, None); } + #[test] + fn resume_and_fork_preserve_no_daemon() { + for (command, finalize) in [ + ("resume", finalize_resume_from_args as fn(&[&str]) -> TuiCli), + ("fork", finalize_fork_from_args as fn(&[&str]) -> TuiCli), + ] { + for args in [ + ["codex", "--no-daemon", command, "--last"], + ["codex", command, "--last", "--no-daemon"], + ] { + assert!(finalize(&args).no_daemon); + } + } + } + #[test] fn resume_and_fork_preserve_no_alt_screen() { for (command, finalize) in [ diff --git a/codex-rs/cli/tests/features.rs b/codex-rs/cli/tests/features.rs index 83cf9ce325..a3ec9bc096 100644 --- a/codex-rs/cli/tests/features.rs +++ b/codex-rs/cli/tests/features.rs @@ -298,3 +298,35 @@ async fn features_list_honors_cloud_managed_feature_requirements() -> Result<()> Ok(()) } + +#[test] +fn no_daemon_rejects_agents_and_explicit_remote_targets() -> Result<()> { + for args in [ + "--no-daemon agents", + "agents --no-daemon", + "--no-daemon queue --thread example --message hello", + "--no-daemon --remote ws://localhost:9999 agents", + "--no-daemon --remote ws://localhost:9999", + "--no-daemon --remote ws://localhost:9999 archive example", + "--no-daemon --remote ws://localhost:9999 queue --thread example --message hello", + "--remote ws://localhost:9999 resume --no-daemon --last", + "--no-daemon fork --remote ws://localhost:9999 session-name", + ] { + let args = args.split_whitespace().collect::>(); + let home = TempDir::new()?; + let expected = if args.contains(&"agents") { + "--no-daemon cannot be used with codex agents." + } else if args.contains(&"queue") && !args.contains(&"--remote") { + "--no-daemon cannot be used with codex queue." + } else { + "--no-daemon cannot be used with --remote." + }; + codex_command(home.path())? + .args(args) + .assert() + .failure() + .stderr(contains(expected)); + assert!(!home.path().join("app-server-daemon").exists()); + } + Ok(()) +} diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index c6379e59aa..cfa2d86233 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -80,6 +80,10 @@ pub struct Cli { #[arg(long = "no-alt-screen", default_value_t = false)] pub no_alt_screen: bool, + /// Run without the shared background server, even if it is already running. + #[arg(long)] + pub no_daemon: bool, + #[clap(skip)] pub config_overrides: CliConfigOverrides, } diff --git a/codex-rs/tui/src/daemon_startup.rs b/codex-rs/tui/src/daemon_startup.rs new file mode 100644 index 0000000000..20a189cc0e --- /dev/null +++ b/codex-rs/tui/src/daemon_startup.rs @@ -0,0 +1,53 @@ +//! Local daemon discovery policy. Explicit embedded launches never probe the shared server. + +use super::*; + +pub(super) fn exclusion( + cli: &Cli, + cli_kv_overrides: &[(String, toml::Value)], + loader_overrides: &LoaderOverrides, + workload_identity_selected: bool, + exec_server_url: Option<&std::ffi::OsStr>, +) -> Option<&'static str> { + if cli.no_daemon { + Some("--no-daemon") + } else if cli.shared.worktree { + Some("--worktree") + } else if cli.oss { + Some("--oss") + } else if workload_identity_selected { + Some("workload identity") + } else if exec_server_url.is_some() { + Some("executor selection (CODEX_EXEC_SERVER_URL)") + } else if cli.agents_overview { + None + } else if cli.config_profile_v2.is_some() { + Some("--profile") + } else { + config_exclusion( + cli_kv_overrides, + loader_overrides, + cli.strict_config, + cli.bypass_hook_trust, + ) + } +} + +pub(super) fn config_exclusion( + cli_kv_overrides: &[(String, toml::Value)], + loader_overrides: &LoaderOverrides, + strict_config: bool, + bypass_hook_trust: bool, +) -> Option<&'static str> { + if !cli_kv_overrides.is_empty() { + Some("command-line configuration overrides (-c, --enable, --disable, or --search)") + } else if !loader_overrides_are_default(loader_overrides) { + Some("custom configuration loader") + } else if strict_config { + Some("--strict-config") + } else if bypass_hook_trust { + Some("--dangerously-bypass-hook-trust") + } else { + None + } +} diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs index 01bd8bc3cf..49725cbb92 100644 --- a/codex-rs/tui/src/daemon_startup_tests.rs +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -83,3 +83,82 @@ async fn daemon_startup_falls_back_only_for_implicit_endpoints() -> color_eyre:: } Ok(()) } + +#[test] +fn daemon_eligibility_preserves_launch_options_and_explains_exclusions() { + use clap::Parser; + for (args, expected) in [ + ("--no-daemon", Some("--no-daemon")), + ("--worktree", Some("--worktree")), + ("--oss", Some("--oss")), + ("--profile test", Some("--profile")), + ("--strict-config", Some("--strict-config")), + ( + "--dangerously-bypass-hook-trust", + Some("--dangerously-bypass-hook-trust"), + ), + ( + "-m test --cd /tmp -i image.png -a never -s workspace-write --add-dir /tmp --no-alt-screen hello", + None, + ), + ] { + let cli = Cli::parse_from(std::iter::once("codex").chain(args.split_whitespace())); + assert_eq!( + daemon_startup::exclusion( + &cli, + &[], + &LoaderOverrides::default(), + /*workload_identity_selected*/ false, + /*exec_server_url*/ None + ), + expected + ); + } + let mut cli = Cli::parse_from(["codex"]); + let overrides = vec![("web_search".into(), toml::Value::String("live".into()))]; + let loader = LoaderOverrides { + ignore_user_config: true, + ..Default::default() + }; + for (kv, loader, workload, executor, expected) in [ + ( + &overrides[..], + LoaderOverrides::default(), + false, + None, + "command-line configuration overrides (-c, --enable, --disable, or --search)", + ), + (&[][..], loader, false, None, "custom configuration loader"), + ( + &[][..], + LoaderOverrides::default(), + true, + None, + "workload identity", + ), + ( + &[][..], + LoaderOverrides::default(), + false, + Some(std::ffi::OsStr::new("executor")), + "executor selection (CODEX_EXEC_SERVER_URL)", + ), + ] { + assert_eq!( + daemon_startup::exclusion(&cli, kv, &loader, workload, executor), + Some(expected) + ); + } + cli.agents_overview = true; + cli.strict_config = true; + assert_eq!( + daemon_startup::exclusion( + &cli, + &overrides, + &LoaderOverrides::default(), + /*workload_identity_selected*/ false, + /*exec_server_url*/ None + ), + None + ); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b0cc9b5494..7c00ce4c50 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -987,19 +987,6 @@ fn loader_overrides_are_default(loader_overrides: &LoaderOverrides) -> bool { loader_overrides_are_default } -fn can_reuse_implicit_local_daemon( - cli_kv_overrides: &[(String, toml::Value)], - loader_overrides: &LoaderOverrides, - strict_config: bool, - has_non_replayable_launch_overrides: bool, -) -> bool { - // A reused daemon cannot adopt this invocation's full launch config state. - cli_kv_overrides.is_empty() - && loader_overrides_are_default(loader_overrides) - && !strict_config - && !has_non_replayable_launch_overrides -} - /// Restore terminal modes before a fatal startup exit bypasses destructor cleanup. fn restore_terminal_before_fatal_exit() { if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) { @@ -2175,6 +2162,8 @@ fn should_show_bedrock_setup_wizard( .is_login_method_allowed(ForcedLoginMethod::Api) } +mod daemon_startup; + #[cfg(test)] #[path = "daemon_startup_tests.rs"] mod daemon_startup_tests; @@ -3020,45 +3009,6 @@ requires_openai_auth = {requires_openai_auth} Ok(()) } - #[test] - fn can_reuse_implicit_local_daemon_requires_default_launch_config() -> color_eyre::Result<()> { - let mut loader_overrides = LoaderOverrides::default(); - let cli_kv_overrides = vec![("web_search".to_string(), toml::Value::String("live".into()))]; - - assert!(can_reuse_implicit_local_daemon( - &[], - &LoaderOverrides::default(), - /*strict_config*/ false, - /*has_non_replayable_launch_overrides*/ false, - )); - assert!(!can_reuse_implicit_local_daemon( - &cli_kv_overrides, - &LoaderOverrides::default(), - /*strict_config*/ false, - /*has_non_replayable_launch_overrides*/ false, - )); - loader_overrides.ignore_user_config = true; - assert!(!can_reuse_implicit_local_daemon( - &[], - &loader_overrides, - /*strict_config*/ false, - /*has_non_replayable_launch_overrides*/ false, - )); - assert!(!can_reuse_implicit_local_daemon( - &[], - &LoaderOverrides::default(), - /*strict_config*/ true, - /*has_non_replayable_launch_overrides*/ false, - )); - assert!(!can_reuse_implicit_local_daemon( - &[], - &LoaderOverrides::default(), - /*strict_config*/ false, - /*has_non_replayable_launch_overrides*/ true, - )); - Ok(()) - } - #[test] fn should_load_configured_environments_for_local_daemon() -> color_eyre::Result<()> { let target = AppServerTarget::LocalDaemon { diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 69f3f4a7d5..917a5e42f7 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -228,6 +228,9 @@ pub(super) async fn start_app_server_for_session_command( arg0_paths, explicit_remote_endpoint, } = options; + if cli.no_daemon && explicit_remote_endpoint.is_some() { + return Err(eyre!("--no-daemon cannot be used with --remote.")); + } let loader_overrides = LoaderOverrides::default(); let strict_config = cli.strict_config; let raw_overrides = cli.config_overrides.raw_overrides.clone(); @@ -245,13 +248,15 @@ pub(super) async fn start_app_server_for_session_command( } let workload_identity_selected = codex_login::is_workload_identity_selected(); - let reuse_implicit_local_daemon = !workload_identity_selected - && super::can_reuse_implicit_local_daemon( + let reuse_implicit_local_daemon = !cli.no_daemon + && !workload_identity_selected + && super::daemon_startup::config_exclusion( &cli_kv_overrides, &launch_loader_overrides, strict_config, cli.bypass_hook_trust, - ); + ) + .is_none(); let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon { super::maybe_probe_default_daemon_socket(codex_home.as_path()).await } else { diff --git a/codex-rs/tui/src/session_queue_commands.rs b/codex-rs/tui/src/session_queue_commands.rs index 485c8077a5..9752a42817 100644 --- a/codex-rs/tui/src/session_queue_commands.rs +++ b/codex-rs/tui/src/session_queue_commands.rs @@ -29,6 +29,11 @@ pub async fn run_session_queue_command( message: String, options: SessionArchiveCommandOptions, ) -> Result { + if options.cli.no_daemon && options.explicit_remote_endpoint.is_none() { + return Err(eyre!( + "--no-daemon cannot be used with codex queue. Queuing must discover the shared server to avoid writing through a separate server." + )); + } let codex_home = find_codex_home().wrap_err("failed to find Codex home")?; let explicit_remote = options.explicit_remote_endpoint.is_some(); let mut app_server = diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 28ecb47046..82e9eba18d 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -13,6 +13,11 @@ pub(super) async fn run_main_inner( loader_overrides: LoaderOverrides, explicit_remote_endpoint: Option, ) -> std::io::Result { + if cli.no_daemon && explicit_remote_endpoint.is_some() { + return Err(std::io::Error::other( + "--no-daemon cannot be used with --remote.", + )); + } let strict_config = cli.strict_config; if cli.shared.worktree { if explicit_remote_endpoint.is_some() { @@ -150,16 +155,14 @@ pub(super) async fn run_main_inner( .await; } - let reuse_implicit_local_daemon = !cli.shared.worktree - && !cli.oss - && !workload_identity_selected - && (cli.agents_overview - || can_reuse_implicit_local_daemon( - &cli_kv_overrides, - &launch_loader_overrides, - strict_config, - cli.bypass_hook_trust, - )); + let daemon_exclusion = daemon_startup::exclusion( + &cli, + &cli_kv_overrides, + &launch_loader_overrides, + workload_identity_selected, + std::env::var_os(codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR).as_deref(), + ); + let reuse_implicit_local_daemon = daemon_exclusion.is_none(); let search_only_config_override = !workload_identity_selected && cli.web_search && startup_preflight::has_only_search_config_override(&cli_kv_overrides) diff --git a/codex-rs/tui/tests/suite/focus_palette.rs b/codex-rs/tui/tests/suite/focus_palette.rs index a29e3b5099..c65b039325 100644 --- a/codex-rs/tui/tests/suite/focus_palette.rs +++ b/codex-rs/tui/tests/suite/focus_palette.rs @@ -14,7 +14,6 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; use anyhow::ensure; -#[cfg(target_os = "macos")] use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -396,3 +395,42 @@ pub(super) fn write_test_config(codex_home: &Path, repo_root: &Path) -> Result<( ) .context("write focus-test API-key authentication") } + +#[test] +fn no_daemon_skips_startup_and_discovery() -> Result<()> { + for running in [false, true] { + let workspace = tempfile::tempdir()?; + let home = tempfile::tempdir()?; + write_test_config(home.path(), workspace.path())?; + let socket_path = codex_app_server_client::app_server_control_socket_path(home.path())?; + std::fs::create_dir_all(socket_path.as_path().parent().unwrap())?; + let listener = if running { + let listener = std::os::unix::net::UnixListener::bind(socket_path.as_path())?; + listener.set_nonblocking(true)?; + Some(listener) + } else { + None + }; + let mut terminal = PtyCodex::start(workspace.path(), home, &["--no-daemon"])?; + terminal.wait_for_startup()?; + terminal.write_input(b"/status")?; + terminal.wait_for_screen("show current session configuration")?; + terminal.read_output(Duration::from_millis(/*millis*/ 200))?; + terminal.write_input(b"\r")?; + terminal.wait_for_screen("Model:")?; + ensure!( + !terminal + ._codex_home + .path() + .join("app-server-daemon") + .exists() + ); + if let Some(listener) = listener { + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } + } + Ok(()) +}