mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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")?,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+14
@@ -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 },
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>>),
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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")?,
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/daemon_startup_tests.rs
|
||||
expression: text
|
||||
---
|
||||
⚠ Running without the shared background server: --strict-config requires
|
||||
embedded mode.
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
+7
@@ -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).
|
||||
Reference in New Issue
Block a user