From 70e8fe1be3100fd2c1f1942a61f9ac70a0177e91 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 17 Sep 2026 04:38:18 +0000 Subject: [PATCH] Add opt-in automatic background server startup (#46117) ## What changed - Add `features.daemon_auto_start`, disabled by default and available through `/experimental`, to start the shared local server for eligible new, resumed, and forked sessions. Changes take effect on the next launch, and disabling the feature persists an explicit `false`. - Require successful daemon startup and connection when auto-start applies. On failure, show guidance to rerun the same command with `--no-daemon` instead of silently falling back to an embedded server. - Preserve embedded mode for excluded launches, including Bedrock sign-in, and carry exclusion warnings through resume and fork pickers. Honor `--no-daemon` without an exclusion warning. - Update `/import` guidance to recommend restarting with `codex --no-daemon`. ## Testing Add CLI and TUI coverage for automatic daemon attachment, startup and connection failures, `--no-daemon`, picker warning persistence, and Bedrock onboarding with and without a running daemon. Add snapshots and configuration-write assertions for the experimental toggle. GitOrigin-RevId: 2e8eb163bc7ddd268a86c7546c05a85356c1e9ee --- codex-rs/Cargo.lock | 2 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/tests/daemon_startup.rs | 217 ++++++++++++++++++ codex-rs/core/config.schema.json | 6 + codex-rs/features/src/lib.rs | 12 + codex-rs/tui/Cargo.toml | 1 + .../src/app/agents_overview_actions_tests.rs | 1 + codex-rs/tui/src/app/agents_overview_tests.rs | 2 + .../src/app/agents_overview_usage_tests.rs | 1 + codex-rs/tui/src/app/daemon_menu_tests.rs | 1 + codex-rs/tui/src/app/startup.rs | 7 + .../src/app/tests/agents_navigation_tests.rs | 5 +- .../src/app/tests/background_exit_tests.rs | 1 + .../app/tests/navigation_reconnect_tests.rs | 1 + .../app/tests/session_lifecycle_requests.rs | 5 + codex-rs/tui/src/app/tests/startup.rs | 1 + .../src/app/tests/startup_defaults_tests.rs | 1 + .../worktree_background_terminals_tests.rs | 1 + codex-rs/tui/src/app_server_connection.rs | 3 +- .../experimental_features_view_tests.rs | 21 ++ ...s_view__tests__daemon_auto_start_menu.snap | 14 ++ .../tui/src/chatwidget/rendering_tests.rs | 1 + .../src/chatwidget/tests/slash_commands.rs | 1 + codex-rs/tui/src/daemon_startup.rs | 5 +- codex-rs/tui/src/daemon_startup_tests.rs | 38 ++- codex-rs/tui/src/experimental_features.rs | 3 +- .../tui/src/experimental_features_tests.rs | 3 + .../external_agent_config_migration/flow.rs | 2 +- ...ernal_agent_config_migration_messages.snap | 2 +- codex-rs/tui/src/history_cell/tests.rs | 1 + codex-rs/tui/src/lib.rs | 34 ++- codex-rs/tui/src/session_start_tests.rs | 1 + ...artup_tests__daemon_exclusion_warning.snap | 6 + codex-rs/tui/src/startup_orchestration.rs | 60 ++++- codex-rs/tui/src/status/remote_connection.rs | 7 +- codex-rs/tui/tests/suite/focus_palette.rs | 55 +++++ ...us_palette__daemon_auto_start_failure.snap | 7 + 37 files changed, 513 insertions(+), 17 deletions(-) create mode 100644 codex-rs/cli/tests/daemon_startup.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__daemon_auto_start_menu.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__daemon_startup_tests__daemon_exclusion_warning.snap create mode 100644 codex-rs/tui/tests/suite/snapshots/all__suite__focus_palette__daemon_auto_start_failure.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 86413b6d60..40385acfd3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2669,6 +2669,7 @@ dependencies = [ "tracing-subscriber", "unicode-segmentation", "url", + "vt100", "which 8.0.0", "windows-sys 0.52.0", "wiremock", @@ -4767,6 +4768,7 @@ dependencies = [ "clap", "codex-ansi-escape", "codex-app-server-client", + "codex-app-server-daemon", "codex-app-server-protocol", "codex-arg0", "codex-backend-client", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 51181994e5..632147a701 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -129,6 +129,7 @@ pretty_assertions = { workspace = true } sqlx = { workspace = true } tar = { workspace = true } tokio-tungstenite = { workspace = true } +vt100 = { workspace = true } wiremock = { workspace = true } zstd = { workspace = true } diff --git a/codex-rs/cli/tests/daemon_startup.rs b/codex-rs/cli/tests/daemon_startup.rs new file mode 100644 index 0000000000..309a665c12 --- /dev/null +++ b/codex-rs/cli/tests/daemon_startup.rs @@ -0,0 +1,217 @@ +//! Exercises daemon startup and excluded resume/fork pickers with Cargo's CLI executable. + +use anyhow::Context as _; +use anyhow::Result; +use anyhow::ensure; +use std::collections::VecDeque; +use std::fs; +use std::process::Command; +use std::time::Duration; + +#[tokio::test] +#[cfg(unix)] +async fn auto_daemon_start_attaches_to_shared_server() -> Result<()> { + daemon_startup("start").await +} + +#[tokio::test] +async fn daemon_exclusion_survives_resume_picker() -> Result<()> { + daemon_startup("resume").await +} + +#[tokio::test] +async fn daemon_exclusion_survives_fork_picker() -> Result<()> { + daemon_startup("fork").await +} + +#[tokio::test] +async fn daemon_auto_start_preserves_bedrock_onboarding() -> Result<()> { + daemon_startup("bedrock").await +} + +#[tokio::test] +#[cfg(unix)] +async fn bedrock_onboarding_leaves_a_running_daemon_untouched() -> Result<()> { + daemon_startup("bedrock-running").await +} + +async fn daemon_startup(command: &str) -> Result<()> { + let codex = codex_utils_cargo_bin::cargo_bin("codex")?.canonicalize()?; + let workspace = tempfile::tempdir()?; + let workspace_path = workspace.path().canonicalize()?; + #[cfg(unix)] + let home = tempfile::Builder::new().tempdir_in("/tmp")?; + #[cfg(not(unix))] + let home = tempfile::tempdir()?; + fs::write( + home.path().join("config.toml"), + format!( + "model = \"gpt-5.6-terra\"\nfeatures.daemon_auto_start = true\n\ + features.bedrock_setup_wizard = true\ncli_auth_credentials_store = \"file\"\n\ + suppress_unstable_features_warning = true\nanalytics.enabled = false\n\ + windows.sandbox = \"unelevated\"\n\ + tui.disable_paste_burst = true\n\ + [projects.{}]\ntrust_level = \"trusted\"\n", + serde_json::to_string(&workspace_path)?, + ), + )?; + let bedrock_onboarding = matches!(command, "bedrock" | "bedrock-running"); + if !bedrock_onboarding { + fs::write( + home.path().join("auth.json"), + r#"{"OPENAI_API_KEY":"daemon-startup-test","tokens":null,"last_refresh":null}"#, + )?; + } + let mut env = std::env::vars().collect::>(); + for key in [ + "CODEX_EXEC_SERVER_URL", + "CODEX_ACCESS_TOKEN", + "CODEX_API_KEY", + "CODEX_CLOUD_TASKS_MODE", + "OPENAI_API_KEY", + "OPENAI_FEDERATION_RULE_ID", + "OPENAI_IDENTITY_TOKEN_FILE", + "OPENAI_WORKLOAD_IDENTITY_CONTEXT", + ] { + env.remove(key); + } + env.insert("CODEX_HOME".into(), home.path().display().to_string()); + env.insert( + "CODEX_SQLITE_HOME".into(), + home.path().display().to_string(), + ); + env.insert("TERM".into(), "xterm-256color".into()); + let mut args = vec!["--no-alt-screen".to_string()]; + let mut steps: VecDeque<(&str, &[u8])> = VecDeque::new(); + if matches!(command, "start" | "bedrock-running") { + // A selected package with a stopped daemon avoids installing a release. + let managed = home + .path() + .join("packages/app-server-daemon/current/bin/codex"); + fs::create_dir_all(home.path().join("packages/app-server-daemon/current/bin"))?; + fs::hard_link(&codex, &managed).or_else(|_| fs::copy(&codex, &managed).map(|_| ()))?; + fs::create_dir(home.path().join("app-server-daemon"))?; + fs::write( + home.path().join("app-server-daemon/settings.json"), + r#"{"shutdownGraceSeconds":0,"updater":{"autoUpdateEnabled":false}}"#, + )?; + } + let pid_file = home.path().join("app-server-daemon/daemon.pid"); + let result = async { + let existing_daemon = if command == "bedrock-running" { + let started = Command::new(&codex) + .env_clear() + .envs(&env) + .current_dir(&workspace_path) + .args(["app-server", "daemon", "start"]) + .output()?; + ensure!( + started.status.success(), + "{}", + String::from_utf8_lossy(&started.stderr) + ); + Some(fs::read(&pid_file)?) + } else { + None + }; + let expected = if command == "start" { + // The draft header is visible before the session's command composer is ready. + steps.push_back(("gpt-5.6-terra", b"/status\r")); + "app-server-control.sock" + } else if bedrock_onboarding { + "UseAmazonBedrock" + } else { + args.extend([command.into(), "--strict-config".into()]); + steps.push_back(("Nosessionsyet", b"\x1b")); + steps.push_back(("gpt-5.6-terra", b"\x14")); + "Runningwithoutthesharedbackgroundserver:--strict-config" + }; + let spawned = codex_utils_pty::spawn_pty_process( + &codex.to_string_lossy(), + &args, + &workspace_path, + &env, + /*arg0*/ &None, + codex_utils_pty::TerminalSize { + rows: 40, + cols: 120, + }, + &[], + ) + .await?; + let session = spawned.session; + let writer = session.writer_sender(); + let mut stdout = spawned.stdout_rx; + let mut output = String::new(); + let mut screen = vt100::Parser::new( + /*rows*/ 40, /*cols*/ 120, /*scrollback_len*/ 0, + ); + let mut queries: Vec<(&str, &[u8])> = vec![ + ("\x1b[6n", b"\x1b[1;1R"), + ("\x1b[?u", b"\x1b[?0u\x1b[?1;2c"), + ("\x1b[c", b"\x1b[?1;2c"), + ("\x1b]10;?", b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\"), + ("\x1b]11;?", b"\x1b]11;rgb:0000/0000/0000\x1b\\"), + ]; + let result = tokio::time::timeout(Duration::from_secs(/*secs*/ 45), async { + while let Some(bytes) = stdout.recv().await { + output.push_str(&String::from_utf8_lossy(&bytes)); + screen.process(&bytes); + for (query, reply) in &mut queries { + if !query.is_empty() && output.contains(*query) { + writer.send(reply.to_vec()).await?; + *query = ""; + } + } + let text: String = screen + .screen() + .contents() + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + if let Some((ready, input)) = steps.front() + && text.contains(ready) + { + writer.send(input.to_vec()).await?; + steps.pop_front(); + output.clear(); + } else if steps.is_empty() && text.contains(expected) { + if command == "start" { + ensure!(text.contains("unix://")); + ensure!(home.path().join("app-server-daemon/daemon.pid").exists()); + } else if let Some(existing_daemon) = &existing_daemon { + ensure!(fs::read(&pid_file)? == *existing_daemon); + } else if bedrock_onboarding { + ensure!(!pid_file.exists()); + } + return Ok::<_, anyhow::Error>(()); + } + } + anyhow::bail!( + "TUI exited before {expected}: {}", + screen.screen().contents() + ) + }) + .await; + Ok::<_, anyhow::Error>(( + session, + result.with_context(|| format!("{command} timed out: {}", screen.screen().contents())), + )) + } + .await; + // Always stop the detached daemon, including on timeout or failed assertions. + let stopped = Command::new(&codex) + .env_clear() + .envs(&env) + .args(["app-server", "daemon", "stop"]) + .output()?; + let (session, result) = result?; + session.terminate(); + result??; + ensure!( + stopped.status.success(), + "{}", + String::from_utf8_lossy(&stopped.stderr) + ); + Ok(()) +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e53a95e2d5..92d7ebf324 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -757,6 +757,9 @@ "cwd_relative_turn_diffs": { "type": "boolean" }, + "daemon_auto_start": { + "type": "boolean" + }, "default_mode_request_user_input": { "type": "boolean" }, @@ -6411,6 +6414,9 @@ "cwd_relative_turn_diffs": { "type": "boolean" }, + "daemon_auto_start": { + "type": "boolean" + }, "default_mode_request_user_input": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 557a9dfb17..a8cabd702c 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -110,6 +110,8 @@ pub enum Feature { SecretAuthStorage, // Experimental + /// Automatically start the shared local daemon for eligible interactive launches. + DaemonAutoStart, /// Send per-content-entry classifications in internal Responses metadata. ContentItemKinds, /// Record model-attempted tool calls in internal Responses metadata. @@ -920,6 +922,16 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::DaemonAutoStart, + key: "daemon_auto_start", + stage: Stage::Experimental { + name: "Automatically start the background server", + menu_description: "Use the shared local server for new, resumed, and forked sessions. Takes effect next launch.", + announcement: "Automatic background server startup can now be enabled from /experimental.", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::TranscriptV2, key: "transcript_v2", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2ed86100bb..70f7bcf562 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -30,6 +30,7 @@ chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-ansi-escape = { workspace = true } codex-app-server-client = { workspace = true } +codex-app-server-daemon = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-backend-client = { workspace = true } diff --git a/codex-rs/tui/src/app/agents_overview_actions_tests.rs b/codex-rs/tui/src/app/agents_overview_actions_tests.rs index 835a4a4367..44facee704 100644 --- a/codex-rs/tui/src/app/agents_overview_actions_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_actions_tests.rs @@ -510,6 +510,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb )), ); app.app_server_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: crate::RemoteAppServerEndpoint::UnixSocket { socket_path: test_path_buf("/tmp/unused.sock").abs(), }, diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index f1ebf9250a..837abc3327 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -495,6 +495,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res ) .expect("materialize unindexed session"); app.app_server_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: crate::RemoteAppServerEndpoint::UnixSocket { socket_path: test_path_buf("/tmp/unused.sock").abs(), }, @@ -2335,6 +2336,7 @@ async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result< .await?; let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; app.app_server_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?, }; app.chat_widget.remote_connection = diff --git a/codex-rs/tui/src/app/agents_overview_usage_tests.rs b/codex-rs/tui/src/app/agents_overview_usage_tests.rs index 467c3df01b..8608be3e79 100644 --- a/codex-rs/tui/src/app/agents_overview_usage_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_usage_tests.rs @@ -200,6 +200,7 @@ async fn selected_usage_is_cached_and_account_changes_discard_old_results() -> R app.agents_overview.usage_disabled = true; app.agents_overview.pending_usage = Some((thread_id, request_id)); app.app_server_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: crate::RemoteAppServerEndpoint::UnixSocket { socket_path: test_path_buf("/tmp/test.sock").abs(), }, diff --git a/codex-rs/tui/src/app/daemon_menu_tests.rs b/codex-rs/tui/src/app/daemon_menu_tests.rs index 8991b49309..ffb5eeb0c0 100644 --- a/codex-rs/tui/src/app/daemon_menu_tests.rs +++ b/codex-rs/tui/src/app/daemon_menu_tests.rs @@ -18,6 +18,7 @@ async fn daemon_menu_is_read_only_and_confirmation_can_cancel_or_handoff() { app.daemon_cli_executable = Some(AbsolutePathBuf::from_absolute_path(package.path().join("bin/codex")).unwrap()); app.app_server_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: crate::RemoteAppServerEndpoint::UnixSocket { socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock").unwrap(), }, diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index d7ae69c7b8..3952d213c4 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -154,6 +154,7 @@ impl App { startup_elapsed_before_app: Duration, startup_bootstrap: Option, startup_hooks_browser: Option, + daemon_startup_warning: Option, mut startup_draft: StartupDraftPump, managed_worktree: Option, daemon_cli_executable: Option, @@ -698,6 +699,12 @@ impl App { started.session.windows_sandbox_host }) }; + // This launch warning belongs to the TUI, independent of picker/trust client replacement. + if let Some(warning) = daemon_startup_warning { + app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::StartupWarningsCell::new(vec![warning]), + ))); + } let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let runtime_keymap = RuntimeKeymap::from_config(&local_settings.tui.keymap).map_err(|err| { diff --git a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs index 1daac6c13e..e9424e8c9b 100644 --- a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs +++ b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs @@ -14,7 +14,10 @@ async fn agents_navigation_requires_local_daemon() -> Result<()> { AppServerTarget::Remote { endpoint: endpoint.clone(), }, - AppServerTarget::LocalDaemon { endpoint }, + AppServerTarget::LocalDaemon { + endpoint, + allow_embedded_fallback: true, + }, ] { let enabled = matches!(target, AppServerTarget::LocalDaemon { .. }); app.app_server_target = target; diff --git a/codex-rs/tui/src/app/tests/background_exit_tests.rs b/codex-rs/tui/src/app/tests/background_exit_tests.rs index 911af0c7f8..378eff923a 100644 --- a/codex-rs/tui/src/app/tests/background_exit_tests.rs +++ b/codex-rs/tui/src/app/tests/background_exit_tests.rs @@ -164,6 +164,7 @@ async fn embedded_exit_keeps_the_session_summary() { fn prepare_local_daemon_thread(app: &mut App) -> Result { app.app_server_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: crate::RemoteAppServerEndpoint::UnixSocket { socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, }, diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index a83dd9c085..874f2af411 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -158,6 +158,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv socket_path: codex_utils_absolute_path::AbsolutePathBuf::try_from(socket_path.clone())?, }; app.app_server_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: endpoint.clone(), }; let interrupted_setup = previous_thread.is_none() && !overview_initialized; diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index be68e78b0b..aad1b59261 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -678,6 +678,7 @@ async fn delete_current_thread_navigates_only_after_success() -> Result<()> { for target in [ AppServerTarget::Embedded, AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: endpoint.clone(), }, AppServerTarget::Remote { endpoint }, @@ -984,6 +985,7 @@ async fn archive_current_thread_returns_shared_servers_to_agents() -> Result<()> let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?; for target in [ AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: endpoint.clone(), }, AppServerTarget::Remote { endpoint }, @@ -2898,6 +2900,7 @@ async fn agents_overview_seeds_loaded_threads_when_recent_listing_is_unavailable .await?; let started = app_server.start_thread(&app.config).await?; app.app_server_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: crate::RemoteAppServerEndpoint::UnixSocket { socket_path: test_path_buf("/tmp/unused.sock").abs(), }, @@ -4433,6 +4436,7 @@ async fn external_writer_escape_preserves_snapshot_and_explicit_quits() -> Resul let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?; for target in [ AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: endpoint.clone(), }, AppServerTarget::Remote { endpoint }, @@ -4616,6 +4620,7 @@ async fn command_center_read_only_open_requests_and_failure_preservation() -> Re ) .await?; app.app_server_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?, }; let current = Box::pin(server.start_thread(&app.config)).await?; diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index cd875484d7..0358ef3ecc 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -28,6 +28,7 @@ async fn windows_sandbox_setup_uses_local_app_server_connection() { auth_token: None, }; app.app_server_target = crate::AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: endpoint.clone(), }; assert!(app.windows_sandbox_setup_is_local()); diff --git a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs index 4d3ee2165a..811db659dc 100644 --- a/codex-rs/tui/src/app/tests/startup_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/startup_defaults_tests.rs @@ -33,6 +33,7 @@ async fn run_startup_for_test( Duration::ZERO, Some(bootstrap), /*startup_hooks_browser*/ None, + /*daemon_startup_warning*/ None, crate::startup_draft::tests::quiet_startup_test_pump(), /*managed_worktree*/ None, /*daemon_cli_executable*/ None, diff --git a/codex-rs/tui/src/app/tests/worktree_background_terminals_tests.rs b/codex-rs/tui/src/app/tests/worktree_background_terminals_tests.rs index 6f6e0e1db7..71351534ae 100644 --- a/codex-rs/tui/src/app/tests/worktree_background_terminals_tests.rs +++ b/codex-rs/tui/src/app/tests/worktree_background_terminals_tests.rs @@ -6,6 +6,7 @@ use pretty_assertions::assert_eq; #[test] fn old_local_daemon_worktree_error_suggests_update() -> Result<()> { let target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: crate::RemoteAppServerEndpoint::UnixSocket { socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, }, diff --git a/codex-rs/tui/src/app_server_connection.rs b/codex-rs/tui/src/app_server_connection.rs index 8df6681e7e..ac3ab1cb2c 100644 --- a/codex-rs/tui/src/app_server_connection.rs +++ b/codex-rs/tui/src/app_server_connection.rs @@ -22,6 +22,7 @@ pub(crate) async fn connect(target: &AppServerTarget) -> color_eyre::Result { // Revalidate at the real connection, not just the earlier discovery probe. let (socket_path, _directory) = @@ -41,7 +42,7 @@ pub(crate) async fn connect(target: &AppServerTarget) -> color_eyre::Result { + AppServerTarget::LocalDaemon { endpoint, .. } | AppServerTarget::Remote { endpoint } => { connect_remote_app_server(endpoint.clone()).await } } diff --git a/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs b/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs index bd8660426e..c9b2ee4129 100644 --- a/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs +++ b/codex-rs/tui/src/bottom_pane/experimental_features_view_tests.rs @@ -257,3 +257,24 @@ fn voice_discovery_requires_the_client_runtime() { ); } } + +#[test] +fn daemon_auto_start_menu_snapshot() { + let feature = Feature::DaemonAutoStart; + let stage = feature.stage(); + let (app_tx, _app_rx) = tokio::sync::mpsc::unbounded_channel(); + let view = ExperimentalFeaturesView::new( + vec![ExperimentalFeatureItem { + key: feature.key().to_string(), + name: stage.experimental_menu_name().unwrap().to_string(), + description: stage.experimental_menu_description().unwrap().to_string(), + enabled: feature.default_enabled(), + writable: true, + }], + ThreadId::new(), + /*catalog_rx*/ None, + AppEventSender::new(app_tx), + crate::keymap::RuntimeKeymap::defaults().list, + ); + snapshot_view("daemon_auto_start_menu", &view); +} diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__daemon_auto_start_menu.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__daemon_auto_start_menu.snap new file mode 100644 index 0000000000..7627e249b9 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__experimental_features_view__tests__daemon_auto_start_menu.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/experimental_features_view_tests.rs +expression: buffer_text(&buffer) +--- + Experimental features + Checked features are configured on. Some experimental features take effect + only in new tasks or after restarting the Codex server. + +› [ ] Automatically start the background server Use the shared local server + for new, resumed, and forked + sessions. Takes effect next + launch. + + Press space to select or enter to save diff --git a/codex-rs/tui/src/chatwidget/rendering_tests.rs b/codex-rs/tui/src/chatwidget/rendering_tests.rs index 902bc0ec55..333f3146f6 100644 --- a/codex-rs/tui/src/chatwidget/rendering_tests.rs +++ b/codex-rs/tui/src/chatwidget/rendering_tests.rs @@ -373,6 +373,7 @@ async fn external_writer_notice_offers_command_center_on_shared_servers() { let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500").unwrap(); for target in [ crate::AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: endpoint.clone(), }, crate::AppServerTarget::Remote { endpoint }, diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 7056100445..79d4d4d3d0 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -2832,6 +2832,7 @@ async fn slash_delete_confirmation_requests_current_thread_delete() { for target in [ crate::AppServerTarget::Embedded, crate::AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: endpoint.clone(), }, crate::AppServerTarget::Remote { endpoint }, diff --git a/codex-rs/tui/src/daemon_startup.rs b/codex-rs/tui/src/daemon_startup.rs index 20a189cc0e..d37bce9407 100644 --- a/codex-rs/tui/src/daemon_startup.rs +++ b/codex-rs/tui/src/daemon_startup.rs @@ -1,7 +1,10 @@ -//! Local daemon discovery policy. Explicit embedded launches never probe the shared server. +//! Local daemon launch policy. Explicit embedded launches never discover or start a daemon; +//! automatic launches require a successful shared-server connection. use super::*; +pub(super) const FAILURE_HINT: &str = "To work without the background server, rerun the same command with --no-daemon (including resume or fork and its arguments)."; + pub(super) fn exclusion( cli: &Cli, cli_kv_overrides: &[(String, toml::Value)], diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs index 49725cbb92..48fe876481 100644 --- a/codex-rs/tui/src/daemon_startup_tests.rs +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -1,4 +1,4 @@ -//! Implicit daemon discovery is opportunistic; explicit endpoints remain authoritative. +//! Opportunistic attachment may fall back; automatic startup requires a shared server. use super::*; use crate::legacy_core::config::ConfigBuilder; @@ -14,6 +14,7 @@ async fn daemon_connection_rejects_unprotected_socket_before_handshake() -> colo let socket_path = AbsolutePathBuf::from_absolute_path_checked(parent.join("server.sock"))?; let mut listener = codex_uds::UnixListener::bind(socket_path.as_path()).await?; let target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, }; tokio::select! { @@ -25,7 +26,12 @@ async fn daemon_connection_rejects_unprotected_socket_before_handshake() -> colo #[tokio::test] async fn daemon_startup_falls_back_only_for_implicit_endpoints() -> color_eyre::Result<()> { - for scenario in ["missing socket", "failed handshake", "explicit endpoint"] { + for scenario in [ + "missing socket", + "failed handshake", + "explicit endpoint", + "required daemon", + ] { let home = TempDir::new()?; let config = ConfigBuilder::default() .codex_home(home.path().to_path_buf()) @@ -50,7 +56,10 @@ async fn daemon_startup_falls_back_only_for_implicit_endpoints() -> color_eyre:: let mut target = if scenario == "explicit endpoint" { AppServerTarget::Remote { endpoint } } else { - AppServerTarget::LocalDaemon { endpoint } + AppServerTarget::LocalDaemon { + endpoint, + allow_embedded_fallback: scenario != "required daemon", + } }; let original_target = target.clone(); let mut state_db = None; @@ -69,8 +78,13 @@ async fn daemon_startup_falls_back_only_for_implicit_endpoints() -> color_eyre:: ) .await; reject_handshake.abort(); - if scenario == "explicit endpoint" { + if scenario == "explicit endpoint" || scenario == "required daemon" { assert!(result.is_err()); + if scenario == "required daemon" { + let message = result.err().unwrap().to_string(); + assert!(message.contains("rerun the same command with --no-daemon")); + assert!(message.contains("failed to connect to remote app server")); + } assert_eq!(target, original_target); assert!(state_db.is_none()); } else { @@ -162,3 +176,19 @@ fn daemon_eligibility_preserves_launch_options_and_explains_exclusions() { None ); } + +#[test] +fn daemon_exclusion_warning_snapshot() { + use crate::history_cell::HistoryCell; + let cell = crate::history_cell::StartupWarningsCell::new(vec![ + "Running without the shared background server: --strict-config requires embedded mode." + .into(), + ]); + let text = cell + .transcript_lines(/*width*/ 80) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + insta::assert_snapshot!("daemon_exclusion_warning", text); +} diff --git a/codex-rs/tui/src/experimental_features.rs b/codex-rs/tui/src/experimental_features.rs index dee31a7713..6cb5887f36 100644 --- a/codex-rs/tui/src/experimental_features.rs +++ b/codex-rs/tui/src/experimental_features.rs @@ -108,7 +108,8 @@ pub(crate) async fn write( let key = format!("features.{}", serde_json::json!(name)); Ok(crate::config_update::replace_config_value( key, - if *enabled || feature.default_enabled { + // Keep the daemon opt-out explicit even before rollout defaults enable it. + if *enabled || feature.default_enabled || name == "daemon_auto_start" { serde_json::json!(enabled) } else { serde_json::Value::Null diff --git a/codex-rs/tui/src/experimental_features_tests.rs b/codex-rs/tui/src/experimental_features_tests.rs index a5f6797f72..987b8bcdb8 100644 --- a/codex-rs/tui/src/experimental_features_tests.rs +++ b/codex-rs/tui/src/experimental_features_tests.rs @@ -180,6 +180,7 @@ async fn experimental_feature_writes_use_server_defaults_and_refresh_configured_ "initialize" => json!({"userAgent": "experimental-test"}), "experimentalFeature/list" => { json!({"data": [ + {"name": "daemon_auto_start", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": !wrote || scenario == "overridden", "defaultEnabled": false}, {"name": "default_off", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": !wrote || scenario == "overridden", "defaultEnabled": false}, {"name": "default_on", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": !wrote, "defaultEnabled": true}, {"name": "new_server_feature", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": wrote, "defaultEnabled": false} @@ -190,6 +191,7 @@ async fn experimental_feature_writes_use_server_defaults_and_refresh_configured_ request.params, Some(json!({ "edits": [ + {"keyPath": "features.\"daemon_auto_start\"", "value": false, "mergeStrategy": "replace"}, {"keyPath": "features.\"default_off\"", "value": null, "mergeStrategy": "replace"}, {"keyPath": "features.\"default_on\"", "value": false, "mergeStrategy": "replace"}, {"keyPath": "features.\"new_server_feature\"", "value": true, "mergeStrategy": "replace"} @@ -226,6 +228,7 @@ async fn experimental_feature_writes_use_server_defaults_and_refresh_configured_ handle, thread_id, vec![ + ("daemon_auto_start".to_string(), false), ("default_off".to_string(), false), ("default_on".to_string(), false), ("new_server_feature".to_string(), true), diff --git a/codex-rs/tui/src/external_agent_config_migration/flow.rs b/codex-rs/tui/src/external_agent_config_migration/flow.rs index 1bbddee3dd..ebaf050519 100644 --- a/codex-rs/tui/src/external_agent_config_migration/flow.rs +++ b/codex-rs/tui/src/external_agent_config_migration/flow.rs @@ -19,7 +19,7 @@ use super::source::run_external_agent_config_source_prompt; pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE: &str = "No compatible setup was found to import."; pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE: &str = "Import from other apps is unavailable in remote sessions. Start Codex locally and run /import."; -pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE: &str = "Import from other apps is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import."; +pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE: &str = "Import from other apps is unavailable while Codex is connected to the local app-server daemon. Restart with `codex --no-daemon` and run /import."; pub(crate) enum ExternalAgentConfigMigrationFlowOutcome { Started(Vec>), diff --git a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__flow__tests__external_agent_config_migration_messages.snap b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__flow__tests__external_agent_config_migration_messages.snap index 59a70e4e0e..04d7038b3c 100644 --- a/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__flow__tests__external_agent_config_migration_messages.snap +++ b/codex-rs/tui/src/external_agent_config_migration/snapshots/codex_tui__external_agent_config_migration__flow__tests__external_agent_config_migration_messages.snap @@ -39,5 +39,5 @@ expression: messages Run /import again to check for additional items. No compatible setup was found to import. Import from other apps is unavailable in remote sessions. Start Codex locally and run /import. -Import from other apps is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import. +Import from other apps is unavailable while Codex is connected to the local app-server daemon. Restart with `codex --no-daemon` and run /import. A previous external agent import is still running. Wait for it to finish before importing again. diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 5bef8f1e93..e038520521 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -66,6 +66,7 @@ fn connected_server_version_notice_snapshot() { #[test] fn local_daemon_version_notice_snapshot() { let target = crate::AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: crate::RemoteAppServerEndpoint::UnixSocket { socket_path: AbsolutePathBuf::from_absolute_path( std::env::temp_dir().join("codex.sock"), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7c00ce4c50..013231e6c8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -299,8 +299,13 @@ async fn start_embedded_app_server( #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AppServerTarget { Embedded, - LocalDaemon { endpoint: RemoteAppServerEndpoint }, - Remote { endpoint: RemoteAppServerEndpoint }, + LocalDaemon { + endpoint: RemoteAppServerEndpoint, + allow_embedded_fallback: bool, + }, + Remote { + endpoint: RemoteAppServerEndpoint, + }, } impl AppServerTarget { @@ -517,11 +522,25 @@ async fn start_app_server( if let Some(connection) = connection { match connection { Ok(app_server) => return Ok(app_server), - Err(err) if matches!(target, AppServerTarget::LocalDaemon { .. }) => { + Err(err) + if matches!( + target, + AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, + .. + } + ) => + { tracing::debug!(%err, "local daemon connection failed; starting embedded app server"); *target = AppServerTarget::Embedded; *state_db = init_state_db_for_app_server_target(&config, target).await?; } + Err(err) if matches!(target, AppServerTarget::LocalDaemon { .. }) => { + return Err(color_eyre::eyre::eyre!( + "{err:#}\n{}", + daemon_startup::FAILURE_HINT + )); + } Err(err) => return Err(err), } } @@ -948,6 +967,7 @@ fn app_server_target_for_launch( None if can_reuse_implicit_local_daemon && exec_server_url.is_none() => { default_daemon_socket.map_or(AppServerTarget::Embedded, |socket_path| { AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, } }) @@ -1040,6 +1060,7 @@ async fn run_ratatui_app( mut state_db: Option, environment_manager: Arc, managed_worktree: Option, + daemon_startup_warning: Option, startup_draft: startup_draft::StartupDraft, ) -> color_eyre::Result { let uses_remote_workspace = app_server_target.uses_remote_workspace(); @@ -1896,6 +1917,7 @@ async fn run_ratatui_app( startup_elapsed_before_app, startup_bootstrap, startup_hooks_browser, + daemon_startup_warning, startup_draft, managed_worktree, daemon_cli_executable, @@ -2291,6 +2313,7 @@ requires_openai_auth = {requires_openai_auth} enabled, LoginStatus::NotAuthenticated, AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: shared_endpoint.clone(), }, false, @@ -2911,6 +2934,7 @@ requires_openai_auth = {requires_openai_auth} assert_eq!( target, AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, } ); @@ -3012,6 +3036,7 @@ requires_openai_auth = {requires_openai_auth} #[test] fn should_load_configured_environments_for_local_daemon() -> color_eyre::Result<()> { let target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, }, @@ -3069,6 +3094,7 @@ requires_openai_auth = {requires_openai_auth} let config = build_config(&temp_dir).await?; let cwd = temp_dir.path().join("project"); let target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, }, @@ -3405,6 +3431,7 @@ requires_openai_auth = {requires_openai_auth} -> std::io::Result<()> { let temp_dir = TempDir::new()?; let target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, }, @@ -3481,6 +3508,7 @@ requires_openai_auth = {requires_openai_auth} &environment_manager )); let local_daemon = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, }, diff --git a/codex-rs/tui/src/session_start_tests.rs b/codex-rs/tui/src/session_start_tests.rs index c05839714b..0872a254a7 100644 --- a/codex-rs/tui/src/session_start_tests.rs +++ b/codex-rs/tui/src/session_start_tests.rs @@ -67,6 +67,7 @@ async fn archived_session_requires_confirmation_before_resume_or_fork() -> Resul let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?; for server_target in [ crate::AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: endpoint.clone(), }, crate::AppServerTarget::Remote { endpoint }, diff --git a/codex-rs/tui/src/snapshots/codex_tui__daemon_startup_tests__daemon_exclusion_warning.snap b/codex-rs/tui/src/snapshots/codex_tui__daemon_startup_tests__daemon_exclusion_warning.snap new file mode 100644 index 0000000000..0dce819cdf --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__daemon_startup_tests__daemon_exclusion_warning.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/daemon_startup_tests.rs +expression: text +--- +⚠ Running without the shared background server: --strict-config requires + embedded mode. diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 82e9eba18d..b138dd0d92 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -155,7 +155,7 @@ pub(super) async fn run_main_inner( .await; } - let daemon_exclusion = daemon_startup::exclusion( + let mut daemon_exclusion = daemon_startup::exclusion( &cli, &cli_kv_overrides, &launch_loader_overrides, @@ -202,7 +202,7 @@ pub(super) async fn run_main_inner( } else { None }; - let app_server_target = app_server_target_for_launch( + let mut app_server_target = app_server_target_for_launch( explicit_remote_endpoint, default_daemon, reuse_implicit_local_daemon, @@ -384,6 +384,54 @@ pub(super) async fn run_main_inner( strict_config, )) .await?; + let auto_start_daemon = config.features.enabled(Feature::DaemonAutoStart) + && !cli.agents_overview + && !cli.no_daemon + && !app_server_target.uses_remote_workspace(); + if auto_start_daemon + && daemon_exclusion.is_none() + && should_show_bedrock_setup_wizard( + LoginStatus::NotAuthenticated, + config.model_provider.requires_openai_auth, + &config, + &AppServerTarget::Embedded, + ) + && startup_draft + .run_until( + config + .auth_config() + .load_auth(/*enable_codex_api_key_env*/ false), + ) + .await? + .ok() + .flatten() + .is_none() + { + // The Bedrock wizard configures its provider through the embedded server. + daemon_exclusion = Some("Bedrock sign-in"); + app_server_target = AppServerTarget::Embedded; + } + if auto_start_daemon && daemon_exclusion.is_none() { + startup_draft.flush_pending_events().await?; + let output = startup_draft + .tui_mut() + .with_restored(|| async { + // Package installation may print progress; keep ordinary Ctrl+C handling. + crossterm::terminal::disable_raw_mode()?; + codex_app_server_daemon::run(codex_app_server_daemon::LifecycleCommand::Start) + .await + .map_err(|err| { + std::io::Error::other(format!("{err:#}\n{}", daemon_startup::FAILURE_HINT)) + }) + }) + .await?; + app_server_target = AppServerTarget::LocalDaemon { + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::from_absolute_path_checked(output.socket_path)?, + }, + allow_embedded_fallback: false, + }; + } startup_draft.apply_config(&config); let mut cloud_config_bundle = if workload_identity_selected { @@ -418,6 +466,13 @@ pub(super) async fn run_main_inner( } else { None }; + let daemon_startup_warning = daemon_exclusion + .filter(|_| auto_start_daemon) + .map(|reason| { + format!( + "Running without the shared background server: {reason} requires embedded mode." + ) + }); #[cfg(target_os = "macos")] let local_runtime_paths = local_runtime_paths.with_allowed_symlinked_codex_home( codex_config::allowed_symlinked_codex_home(&config.config_layer_stack, &config.codex_home), @@ -659,6 +714,7 @@ pub(super) async fn run_main_inner( state_db, environment_manager, managed_worktree.clone(), + daemon_startup_warning, startup_draft, ) .await diff --git a/codex-rs/tui/src/status/remote_connection.rs b/codex-rs/tui/src/status/remote_connection.rs index 34bd903f2b..938c9603c5 100644 --- a/codex-rs/tui/src/status/remote_connection.rs +++ b/codex-rs/tui/src/status/remote_connection.rs @@ -22,7 +22,7 @@ pub(crate) fn remote_connection_status_value( ) -> Option { let endpoint = match app_server_target { AppServerTarget::Embedded => return None, - AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => { + AppServerTarget::LocalDaemon { endpoint, .. } | AppServerTarget::Remote { endpoint } => { endpoint } }; @@ -80,7 +80,7 @@ pub(crate) fn server_version_notice_key( let mut hasher = Sha256::new(); let endpoint = match target { AppServerTarget::Embedded => None, - AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => { + AppServerTarget::LocalDaemon { endpoint, .. } | AppServerTarget::Remote { endpoint } => { Some(endpoint) } }; @@ -189,6 +189,7 @@ mod tests { let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; let daemon_target = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path: socket_path.clone(), }, @@ -220,6 +221,7 @@ mod tests { socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?, }; let local = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: endpoint.clone(), }; let remote = AppServerTarget::Remote { endpoint }; @@ -265,6 +267,7 @@ mod tests { }) }; let local = AppServerTarget::LocalDaemon { + allow_embedded_fallback: true, endpoint: socket("/c")?, }; let remote = AppServerTarget::Remote { diff --git a/codex-rs/tui/tests/suite/focus_palette.rs b/codex-rs/tui/tests/suite/focus_palette.rs index c65b039325..70e6a6341d 100644 --- a/codex-rs/tui/tests/suite/focus_palette.rs +++ b/codex-rs/tui/tests/suite/focus_palette.rs @@ -402,6 +402,12 @@ fn no_daemon_skips_startup_and_discovery() -> Result<()> { let workspace = tempfile::tempdir()?; let home = tempfile::tempdir()?; write_test_config(home.path(), workspace.path())?; + let config = home.path().join("config.toml"); + let contents = std::fs::read_to_string(&config)?; + std::fs::write( + config, + format!("features.daemon_auto_start = true\n{contents}"), + )?; 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 { @@ -431,6 +437,55 @@ fn no_daemon_skips_startup_and_discovery() -> Result<()> { std::io::ErrorKind::WouldBlock ); } + ensure!( + !String::from_utf8_lossy(&terminal.output) + .contains("Running without the shared background server") + ); } Ok(()) } + +#[test] +fn auto_daemon_start_failure_exits_with_manual_fallback_hint() -> Result<()> { + let workspace = tempfile::tempdir()?; + let home = tempfile::tempdir()?; + write_test_config(home.path(), workspace.path())?; + let config = home.path().join("config.toml"); + let contents = std::fs::read_to_string(&config)?; + std::fs::write( + config, + format!("features.daemon_auto_start = true\n{contents}"), + )?; + // An incomplete selected package must fail without installing a replacement. + std::fs::create_dir_all(home.path().join("packages/app-server-daemon/current"))?; + let mut terminal = PtyCodex::start(workspace.path(), home, &[])?; + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + terminal.read_output(Duration::from_millis(/*millis*/ 50))?; + terminal.answer_startup_queries()?; + if let Some(status) = terminal.child.try_wait()? { + ensure!(!status.success()); + let output = String::from_utf8_lossy(&terminal.output); + let failure = &output[output.find("Error:").context("missing fatal error")?..]; + let failure = codex_ansi_escape::ansi_escape(failure) + .to_string() + .replace( + terminal + ._codex_home + .path() + .canonicalize()? + .to_string_lossy() + .as_ref(), + "[CODEX_HOME]", + ) + .replace( + terminal._codex_home.path().to_string_lossy().as_ref(), + "[CODEX_HOME]", + ) + .replace('\r', ""); + insta::assert_snapshot!("daemon_auto_start_failure", failure.trim()); + return Ok(()); + } + } + bail!("auto-start did not fail: {}", terminal.screen_contents()) +} diff --git a/codex-rs/tui/tests/suite/snapshots/all__suite__focus_palette__daemon_auto_start_failure.snap b/codex-rs/tui/tests/suite/snapshots/all__suite__focus_palette__daemon_auto_start_failure.snap new file mode 100644 index 0000000000..0d40a84782 --- /dev/null +++ b/codex-rs/tui/tests/suite/snapshots/all__suite__focus_palette__daemon_auto_start_failure.snap @@ -0,0 +1,7 @@ +--- +source: tui/tests/suite/focus_palette.rs +assertion_line: 484 +expression: failure.trim() +--- +Error: daemon executable not found at [CODEX_HOME]/packages/app-server-daemon/current/bin/codex; repair the existing installation, or run `codex app-server daemon start` to install a missing daemon +To work without the background server, rerun the same command with --no-daemon (including resume or fork and its arguments).