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
This commit is contained in:
Eric Traut
2026-09-17 04:53:51 +00:00
committed by copyberry
parent 4b0f19d6f9
commit 70e8fe1be3
37 changed files with 513 additions and 17 deletions
+2
View File
@@ -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",
+1
View File
@@ -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 }
+217
View File
@@ -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::<std::collections::HashMap<_, _>>();
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(())
}
+6
View File
@@ -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"
},
+12
View File
@@ -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",
+1
View File
@@ -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 }
@@ -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(),
},
@@ -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 =
@@ -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(),
},
@@ -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(),
},
+7
View File
@@ -154,6 +154,7 @@ impl App {
startup_elapsed_before_app: Duration,
startup_bootstrap: Option<AppServerBootstrap>,
startup_hooks_browser: Option<HooksListEntry>,
daemon_startup_warning: Option<String>,
mut startup_draft: StartupDraftPump,
managed_worktree: Option<crate::ManagedTuiWorktree>,
daemon_cli_executable: Option<AbsolutePathBuf>,
@@ -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| {
@@ -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;
@@ -164,6 +164,7 @@ async fn embedded_exit_keeps_the_session_summary() {
fn prepare_local_daemon_thread(app: &mut App) -> Result<ThreadId> {
app.app_server_target = AppServerTarget::LocalDaemon {
allow_embedded_fallback: true,
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
},
@@ -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;
@@ -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?;
+1
View File
@@ -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());
@@ -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,
@@ -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")?,
},
+2 -1
View File
@@ -22,6 +22,7 @@ pub(crate) async fn connect(target: &AppServerTarget) -> color_eyre::Result<AppS
#[cfg(windows)]
AppServerTarget::LocalDaemon {
endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path },
..
} => {
// 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<AppS
.await?;
Ok(AppServerClient::Remote(app_server))
}
AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => {
AppServerTarget::LocalDaemon { endpoint, .. } | AppServerTarget::Remote { endpoint } => {
connect_remote_app_server(endpoint.clone()).await
}
}
@@ -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);
}
@@ -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
@@ -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 },
@@ -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 },
+4 -1
View File
@@ -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)],
+34 -4
View File
@@ -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::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("daemon_exclusion_warning", text);
}
+2 -1
View File
@@ -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
@@ -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),
@@ -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<Line<'static>>),
@@ -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.
+1
View File
@@ -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"),
+31 -3
View File
@@ -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<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
managed_worktree: Option<ManagedTuiWorktree>,
daemon_startup_warning: Option<String>,
startup_draft: startup_draft::StartupDraft,
) -> color_eyre::Result<AppExitInfo> {
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")?,
},
+1
View File
@@ -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 },
@@ -0,0 +1,6 @@
---
source: tui/src/daemon_startup_tests.rs
expression: text
---
⚠ Running without the shared background server: --strict-config requires
embedded mode.
+58 -2
View File
@@ -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
+5 -2
View File
@@ -22,7 +22,7 @@ pub(crate) fn remote_connection_status_value(
) -> Option<RemoteConnectionStatus> {
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 {
+55
View File
@@ -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())
}
@@ -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).