Add --no-daemon to bypass the shared background server (#46088)

## What changed

- Run with `--no-daemon` without starting or probing the shared server, even when it is already running. Preserve the flag through `resume` and `fork`, and honor it for session archive commands.
- Reject combinations with `--remote`, `codex agents`, and `codex queue`, which require a server connection. Point users to `codex --no-daemon` when the agents overview cannot start its shared server.
- Centralize daemon eligibility checks and exclude launches using `--profile` or `CODEX_EXEC_SERVER_URL` from implicit daemon reuse.

## Testing

Add coverage for flag propagation, incompatible command combinations, and daemon eligibility. Add a PTY test verifying that `--no-daemon` starts the TUI without creating daemon state or connecting to an existing control socket.

GitOrigin-RevId: acc6a7cb339df6433bb8273c796194cdceda37c1
This commit is contained in:
Eric Traut
2026-09-17 02:46:49 +00:00
committed by copyberry
parent 8452164c76
commit 787823cf95
10 changed files with 271 additions and 67 deletions
+36 -1
View File
@@ -337,6 +337,9 @@ struct DebugTraceReduceCommand {
#[derive(Debug, Parser)]
struct AgentsCommand {
/// The agents overview requires a shared server; this option is rejected.
#[arg(long, hide = true)]
no_daemon: bool,
#[clap(flatten)]
remote: InteractiveRemoteOptions,
@@ -1215,6 +1218,7 @@ async fn cli_main(
if let Some(options) = agents_options {
interactive.cwd = options.cwd.clone().or(interactive.cwd.take());
interactive.no_alt_screen |= options.no_alt_screen;
interactive.no_daemon |= options.no_daemon;
}
let root_strict_config = interactive.strict_config;
interactive
@@ -2797,6 +2801,18 @@ async fn run_interactive_tui(
remote_auth_token_env: Option<String>,
arg0_paths: Arg0DispatchPaths,
) -> std::io::Result<AppExitInfo> {
if interactive.no_daemon {
if interactive.agents_overview {
return Ok(AppExitInfo::fatal(
"--no-daemon cannot be used with codex agents. The agents overview requires a shared server. Use codex --no-daemon to work without it.",
));
}
if remote.is_some() {
return Ok(AppExitInfo::fatal(
"--no-daemon cannot be used with --remote.",
));
}
}
if let Some(prompt) = interactive.prompt.take() {
// Normalize CRLF/CR to LF so CLI-provided text can't leak `\r` into TUI state.
interactive.prompt = Some(prompt.replace("\r\n", "\n").replace('\r', "\n"));
@@ -2833,7 +2849,9 @@ async fn run_interactive_tui(
.map_err(std::io::Error::other)?;
codex_app_server_daemon::run(AppServerLifecycleCommand::Start)
.await
.map_err(std::io::Error::other)?;
.map_err(|err| std::io::Error::other(format!(
"{err:#}\nThe agents overview requires a shared server. Use codex --no-daemon to work without it."
)))?;
}
let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env.clone()) {
@@ -3056,6 +3074,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli)
approval_policy,
web_search,
no_alt_screen,
no_daemon,
prompt,
mut config_overrides,
..
@@ -3076,6 +3095,7 @@ fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli)
interactive.web_search = true;
}
interactive.no_alt_screen |= no_alt_screen;
interactive.no_daemon |= no_daemon;
if strict_config {
interactive.strict_config = true;
}
@@ -4266,6 +4286,21 @@ mod tests {
assert_eq!(interactive.resume_session_id, None);
}
#[test]
fn resume_and_fork_preserve_no_daemon() {
for (command, finalize) in [
("resume", finalize_resume_from_args as fn(&[&str]) -> TuiCli),
("fork", finalize_fork_from_args as fn(&[&str]) -> TuiCli),
] {
for args in [
["codex", "--no-daemon", command, "--last"],
["codex", command, "--last", "--no-daemon"],
] {
assert!(finalize(&args).no_daemon);
}
}
}
#[test]
fn resume_and_fork_preserve_no_alt_screen() {
for (command, finalize) in [
+32
View File
@@ -298,3 +298,35 @@ async fn features_list_honors_cloud_managed_feature_requirements() -> Result<()>
Ok(())
}
#[test]
fn no_daemon_rejects_agents_and_explicit_remote_targets() -> Result<()> {
for args in [
"--no-daemon agents",
"agents --no-daemon",
"--no-daemon queue --thread example --message hello",
"--no-daemon --remote ws://localhost:9999 agents",
"--no-daemon --remote ws://localhost:9999",
"--no-daemon --remote ws://localhost:9999 archive example",
"--no-daemon --remote ws://localhost:9999 queue --thread example --message hello",
"--remote ws://localhost:9999 resume --no-daemon --last",
"--no-daemon fork --remote ws://localhost:9999 session-name",
] {
let args = args.split_whitespace().collect::<Vec<_>>();
let home = TempDir::new()?;
let expected = if args.contains(&"agents") {
"--no-daemon cannot be used with codex agents."
} else if args.contains(&"queue") && !args.contains(&"--remote") {
"--no-daemon cannot be used with codex queue."
} else {
"--no-daemon cannot be used with --remote."
};
codex_command(home.path())?
.args(args)
.assert()
.failure()
.stderr(contains(expected));
assert!(!home.path().join("app-server-daemon").exists());
}
Ok(())
}
+4
View File
@@ -80,6 +80,10 @@ pub struct Cli {
#[arg(long = "no-alt-screen", default_value_t = false)]
pub no_alt_screen: bool,
/// Run without the shared background server, even if it is already running.
#[arg(long)]
pub no_daemon: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}
+53
View File
@@ -0,0 +1,53 @@
//! Local daemon discovery policy. Explicit embedded launches never probe the shared server.
use super::*;
pub(super) fn exclusion(
cli: &Cli,
cli_kv_overrides: &[(String, toml::Value)],
loader_overrides: &LoaderOverrides,
workload_identity_selected: bool,
exec_server_url: Option<&std::ffi::OsStr>,
) -> Option<&'static str> {
if cli.no_daemon {
Some("--no-daemon")
} else if cli.shared.worktree {
Some("--worktree")
} else if cli.oss {
Some("--oss")
} else if workload_identity_selected {
Some("workload identity")
} else if exec_server_url.is_some() {
Some("executor selection (CODEX_EXEC_SERVER_URL)")
} else if cli.agents_overview {
None
} else if cli.config_profile_v2.is_some() {
Some("--profile")
} else {
config_exclusion(
cli_kv_overrides,
loader_overrides,
cli.strict_config,
cli.bypass_hook_trust,
)
}
}
pub(super) fn config_exclusion(
cli_kv_overrides: &[(String, toml::Value)],
loader_overrides: &LoaderOverrides,
strict_config: bool,
bypass_hook_trust: bool,
) -> Option<&'static str> {
if !cli_kv_overrides.is_empty() {
Some("command-line configuration overrides (-c, --enable, --disable, or --search)")
} else if !loader_overrides_are_default(loader_overrides) {
Some("custom configuration loader")
} else if strict_config {
Some("--strict-config")
} else if bypass_hook_trust {
Some("--dangerously-bypass-hook-trust")
} else {
None
}
}
+79
View File
@@ -83,3 +83,82 @@ async fn daemon_startup_falls_back_only_for_implicit_endpoints() -> color_eyre::
}
Ok(())
}
#[test]
fn daemon_eligibility_preserves_launch_options_and_explains_exclusions() {
use clap::Parser;
for (args, expected) in [
("--no-daemon", Some("--no-daemon")),
("--worktree", Some("--worktree")),
("--oss", Some("--oss")),
("--profile test", Some("--profile")),
("--strict-config", Some("--strict-config")),
(
"--dangerously-bypass-hook-trust",
Some("--dangerously-bypass-hook-trust"),
),
(
"-m test --cd /tmp -i image.png -a never -s workspace-write --add-dir /tmp --no-alt-screen hello",
None,
),
] {
let cli = Cli::parse_from(std::iter::once("codex").chain(args.split_whitespace()));
assert_eq!(
daemon_startup::exclusion(
&cli,
&[],
&LoaderOverrides::default(),
/*workload_identity_selected*/ false,
/*exec_server_url*/ None
),
expected
);
}
let mut cli = Cli::parse_from(["codex"]);
let overrides = vec![("web_search".into(), toml::Value::String("live".into()))];
let loader = LoaderOverrides {
ignore_user_config: true,
..Default::default()
};
for (kv, loader, workload, executor, expected) in [
(
&overrides[..],
LoaderOverrides::default(),
false,
None,
"command-line configuration overrides (-c, --enable, --disable, or --search)",
),
(&[][..], loader, false, None, "custom configuration loader"),
(
&[][..],
LoaderOverrides::default(),
true,
None,
"workload identity",
),
(
&[][..],
LoaderOverrides::default(),
false,
Some(std::ffi::OsStr::new("executor")),
"executor selection (CODEX_EXEC_SERVER_URL)",
),
] {
assert_eq!(
daemon_startup::exclusion(&cli, kv, &loader, workload, executor),
Some(expected)
);
}
cli.agents_overview = true;
cli.strict_config = true;
assert_eq!(
daemon_startup::exclusion(
&cli,
&overrides,
&LoaderOverrides::default(),
/*workload_identity_selected*/ false,
/*exec_server_url*/ None
),
None
);
}
+2 -52
View File
@@ -987,19 +987,6 @@ fn loader_overrides_are_default(loader_overrides: &LoaderOverrides) -> bool {
loader_overrides_are_default
}
fn can_reuse_implicit_local_daemon(
cli_kv_overrides: &[(String, toml::Value)],
loader_overrides: &LoaderOverrides,
strict_config: bool,
has_non_replayable_launch_overrides: bool,
) -> bool {
// A reused daemon cannot adopt this invocation's full launch config state.
cli_kv_overrides.is_empty()
&& loader_overrides_are_default(loader_overrides)
&& !strict_config
&& !has_non_replayable_launch_overrides
}
/// Restore terminal modes before a fatal startup exit bypasses destructor cleanup.
fn restore_terminal_before_fatal_exit() {
if crossterm::terminal::is_raw_mode_enabled().unwrap_or(false) {
@@ -2175,6 +2162,8 @@ fn should_show_bedrock_setup_wizard(
.is_login_method_allowed(ForcedLoginMethod::Api)
}
mod daemon_startup;
#[cfg(test)]
#[path = "daemon_startup_tests.rs"]
mod daemon_startup_tests;
@@ -3020,45 +3009,6 @@ requires_openai_auth = {requires_openai_auth}
Ok(())
}
#[test]
fn can_reuse_implicit_local_daemon_requires_default_launch_config() -> color_eyre::Result<()> {
let mut loader_overrides = LoaderOverrides::default();
let cli_kv_overrides = vec![("web_search".to_string(), toml::Value::String("live".into()))];
assert!(can_reuse_implicit_local_daemon(
&[],
&LoaderOverrides::default(),
/*strict_config*/ false,
/*has_non_replayable_launch_overrides*/ false,
));
assert!(!can_reuse_implicit_local_daemon(
&cli_kv_overrides,
&LoaderOverrides::default(),
/*strict_config*/ false,
/*has_non_replayable_launch_overrides*/ false,
));
loader_overrides.ignore_user_config = true;
assert!(!can_reuse_implicit_local_daemon(
&[],
&loader_overrides,
/*strict_config*/ false,
/*has_non_replayable_launch_overrides*/ false,
));
assert!(!can_reuse_implicit_local_daemon(
&[],
&LoaderOverrides::default(),
/*strict_config*/ true,
/*has_non_replayable_launch_overrides*/ false,
));
assert!(!can_reuse_implicit_local_daemon(
&[],
&LoaderOverrides::default(),
/*strict_config*/ false,
/*has_non_replayable_launch_overrides*/ true,
));
Ok(())
}
#[test]
fn should_load_configured_environments_for_local_daemon() -> color_eyre::Result<()> {
let target = AppServerTarget::LocalDaemon {
+8 -3
View File
@@ -228,6 +228,9 @@ pub(super) async fn start_app_server_for_session_command(
arg0_paths,
explicit_remote_endpoint,
} = options;
if cli.no_daemon && explicit_remote_endpoint.is_some() {
return Err(eyre!("--no-daemon cannot be used with --remote."));
}
let loader_overrides = LoaderOverrides::default();
let strict_config = cli.strict_config;
let raw_overrides = cli.config_overrides.raw_overrides.clone();
@@ -245,13 +248,15 @@ pub(super) async fn start_app_server_for_session_command(
}
let workload_identity_selected = codex_login::is_workload_identity_selected();
let reuse_implicit_local_daemon = !workload_identity_selected
&& super::can_reuse_implicit_local_daemon(
let reuse_implicit_local_daemon = !cli.no_daemon
&& !workload_identity_selected
&& super::daemon_startup::config_exclusion(
&cli_kv_overrides,
&launch_loader_overrides,
strict_config,
cli.bypass_hook_trust,
);
)
.is_none();
let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon {
super::maybe_probe_default_daemon_socket(codex_home.as_path()).await
} else {
@@ -29,6 +29,11 @@ pub async fn run_session_queue_command(
message: String,
options: SessionArchiveCommandOptions,
) -> Result<String> {
if options.cli.no_daemon && options.explicit_remote_endpoint.is_none() {
return Err(eyre!(
"--no-daemon cannot be used with codex queue. Queuing must discover the shared server to avoid writing through a separate server."
));
}
let codex_home = find_codex_home().wrap_err("failed to find Codex home")?;
let explicit_remote = options.explicit_remote_endpoint.is_some();
let mut app_server =
+13 -10
View File
@@ -13,6 +13,11 @@ pub(super) async fn run_main_inner(
loader_overrides: LoaderOverrides,
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
) -> std::io::Result<AppExitInfo> {
if cli.no_daemon && explicit_remote_endpoint.is_some() {
return Err(std::io::Error::other(
"--no-daemon cannot be used with --remote.",
));
}
let strict_config = cli.strict_config;
if cli.shared.worktree {
if explicit_remote_endpoint.is_some() {
@@ -150,16 +155,14 @@ pub(super) async fn run_main_inner(
.await;
}
let reuse_implicit_local_daemon = !cli.shared.worktree
&& !cli.oss
&& !workload_identity_selected
&& (cli.agents_overview
|| can_reuse_implicit_local_daemon(
&cli_kv_overrides,
&launch_loader_overrides,
strict_config,
cli.bypass_hook_trust,
));
let daemon_exclusion = daemon_startup::exclusion(
&cli,
&cli_kv_overrides,
&launch_loader_overrides,
workload_identity_selected,
std::env::var_os(codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR).as_deref(),
);
let reuse_implicit_local_daemon = daemon_exclusion.is_none();
let search_only_config_override = !workload_identity_selected
&& cli.web_search
&& startup_preflight::has_only_search_config_override(&cli_kv_overrides)
+39 -1
View File
@@ -14,7 +14,6 @@ use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use anyhow::ensure;
#[cfg(target_os = "macos")]
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -396,3 +395,42 @@ pub(super) fn write_test_config(codex_home: &Path, repo_root: &Path) -> Result<(
)
.context("write focus-test API-key authentication")
}
#[test]
fn no_daemon_skips_startup_and_discovery() -> Result<()> {
for running in [false, true] {
let workspace = tempfile::tempdir()?;
let home = tempfile::tempdir()?;
write_test_config(home.path(), workspace.path())?;
let socket_path = codex_app_server_client::app_server_control_socket_path(home.path())?;
std::fs::create_dir_all(socket_path.as_path().parent().unwrap())?;
let listener = if running {
let listener = std::os::unix::net::UnixListener::bind(socket_path.as_path())?;
listener.set_nonblocking(true)?;
Some(listener)
} else {
None
};
let mut terminal = PtyCodex::start(workspace.path(), home, &["--no-daemon"])?;
terminal.wait_for_startup()?;
terminal.write_input(b"/status")?;
terminal.wait_for_screen("show current session configuration")?;
terminal.read_output(Duration::from_millis(/*millis*/ 200))?;
terminal.write_input(b"\r")?;
terminal.wait_for_screen("Model:")?;
ensure!(
!terminal
._codex_home
.path()
.join("app-server-daemon")
.exists()
);
if let Some(listener) = listener {
assert_eq!(
listener.accept().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
}
}
Ok(())
}