Allow compatible feature overrides when starting the shared daemon (#46529)

## Why

Feature overrides previously forced embedded mode even when they could work with the shared daemon. A running daemon can also have different feature settings from the current invocation, including when the invocation uses defaults.

## What changed

- Allow selected Boolean feature overrides and `suppress_unstable_features_warning` in daemon mode. Keep explicit overrides that disable shared services in embedded mode.
- Pass shared-service feature overrides to newly launched daemons and persist them for restarts and updates without changing a running daemon's settings.
- Check daemon feature settings with `experimentalFeature/list` before attaching. Warn and fall back to embedded mode on mismatches, failed checks, or an incompatible code-mode host fallback policy. Skip this check for the agents overview.
- Resolve worktree configuration before daemon selection and allow worktree sessions to auto-start the daemon.
- Forward `suppress_unstable_features_warning` in thread configuration overrides.

## Testing

Add coverage for override eligibility and precedence, launch-feature persistence and reuse, and TUI fallback warnings for feature and host-policy mismatches. Extend worktree integration coverage to exercise daemon auto-start and warning suppression.

GitOrigin-RevId: 68c316f74843b1dfd8fa39bdb48bd1f649fee57f
This commit is contained in:
Eric Traut
2026-09-19 00:14:40 +00:00
committed by copyberry
parent 6cf2ff11b3
commit 1537497e52
25 changed files with 591 additions and 100 deletions
@@ -2,6 +2,7 @@ mod pid;
#[cfg(windows)]
pub(crate) mod windows;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
@@ -21,14 +22,17 @@ pub(crate) struct BackendPaths {
pub(crate) pid_file: PathBuf,
pub(crate) update_pid_file: PathBuf,
pub(crate) remote_control_enabled: bool,
pub(crate) feature_overrides: BTreeMap<String, bool>,
}
pub(crate) fn pid_backend(paths: BackendPaths) -> PidBackend {
PidBackend::new(
let mut backend = PidBackend::new(
paths.codex_bin,
paths.pid_file,
paths.remote_control_enabled,
)
);
backend.feature_overrides = paths.feature_overrides;
backend
}
pub(crate) fn pid_update_loop_backend(paths: BackendPaths) -> PidBackend {
+27 -6
View File
@@ -3,6 +3,9 @@
#[path = "pid_identity.rs"]
mod identity;
#[cfg(any(unix, windows))]
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::io::SeekFrom;
use std::path::Path;
use std::path::PathBuf;
@@ -33,6 +36,7 @@ const STDERR_LOG_TAIL_BYTES: u64 = 4096;
#[derive(Debug)]
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
pub(crate) struct PidBackend {
pub(super) feature_overrides: BTreeMap<String, bool>,
codex_bin: PathBuf,
pid_file: PathBuf,
lock_file: PathBuf,
@@ -98,6 +102,7 @@ impl PidBackend {
pub(crate) fn new(codex_bin: PathBuf, pid_file: PathBuf, remote_control_enabled: bool) -> Self {
let lock_file = pid_file.with_extension("pid.lock");
Self {
feature_overrides: BTreeMap::new(),
codex_bin,
pid_file,
lock_file,
@@ -114,6 +119,7 @@ impl PidBackend {
) -> Self {
let lock_file = pid_file.with_extension("pid.lock");
Self {
feature_overrides: BTreeMap::new(),
codex_bin,
pid_file,
lock_file,
@@ -362,22 +368,37 @@ impl PidBackend {
}
#[cfg(any(unix, windows))]
fn command_args(&self) -> Vec<&str> {
match &self.command_kind {
fn command_args(&self) -> Vec<Cow<'_, str>> {
let mut args = match &self.command_kind {
PidCommandKind::AppServer {
remote_control_enabled: true,
} => vec!["app-server", "--remote-control", "--listen", "unix://"],
} => vec![
"app-server".into(),
"--remote-control".into(),
"--listen".into(),
"unix://".into(),
],
PidCommandKind::AppServer {
remote_control_enabled: false,
} => vec!["app-server", "--listen", "unix://"],
} => vec!["app-server".into(), "--listen".into(), "unix://".into()],
PidCommandKind::UpdateLoop { restore_release } => {
let mut args = vec!["app-server", "daemon", "pid-update-loop"];
let mut args = vec![
"app-server".into(),
"daemon".into(),
"pid-update-loop".into(),
];
if let Some(release) = restore_release {
args.extend(["--restore-release", release.as_str()]);
args.extend(["--restore-release".into(), release.as_str().into()]);
}
args
}
};
if matches!(self.command_kind, PidCommandKind::AppServer { .. }) {
for (name, enabled) in &self.feature_overrides {
args.extend(["-c".into(), format!("features.{name}={enabled}").into()]);
}
}
args
}
#[cfg(any(unix, windows))]
@@ -91,7 +91,7 @@ impl PidBackend {
command
// Handoff suppression belongs to the foreground CLI, not its long-lived children.
.env_remove(crate::telemetry::HANDOFF_ENV)
.args(self.command_args())
.args(self.command_args().iter().map(std::borrow::Cow::as_ref))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(stderr_log.into_std().await));
@@ -577,6 +577,7 @@ async fn exited_unreaped_updater_is_reaped() {
#[test]
fn update_loop_uses_hidden_app_server_subcommand() {
let backend = PidBackend {
feature_overrides: Default::default(),
codex_bin: "codex".into(),
pid_file: "updater.pid".into(),
lock_file: "updater.pid.lock".into(),
+20
View File
@@ -0,0 +1,20 @@
//! Invocation feature overrides apply only when starting a missing daemon.
//! The lifecycle lock protects their persistence against concurrent starts and updates.
use crate::Daemon;
use crate::LifecycleOutput;
use crate::ensure_supported_platform;
use anyhow::Result;
use std::collections::BTreeMap;
/// Start a missing daemon with these features, or leave a running daemon unchanged.
/// Callers must check the running server's configuration before using it.
pub async fn start_with_features(features: &BTreeMap<String, bool>) -> Result<LifecycleOutput> {
ensure_supported_platform()?;
#[cfg(windows)]
crate::backend::windows::ensure_not_elevated()?;
let daemon = Daemon::from_environment()?;
let _operation_lock = daemon.acquire_operation_lock().await?;
let selected = daemon.current_installation()?;
Box::pin(selected.start(features)).await
}
+14 -4
View File
@@ -5,6 +5,8 @@ mod backend;
use backend::windows::try_lock_file;
mod client;
mod install_lock;
mod launch;
pub use launch::start_with_features;
mod managed_install;
mod prepare_install;
pub use prepare_install::InstallRequest;
@@ -15,6 +17,7 @@ pub mod telemetry;
mod thread_recovery;
mod update_loop;
use std::collections::BTreeMap;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
@@ -371,7 +374,7 @@ impl Daemon {
let _operation_lock = self.acquire_operation_lock().await?;
let selected = self.current_installation()?;
match command {
LifecycleCommand::Start => selected.start().await,
LifecycleCommand::Start => selected.start(&BTreeMap::new()).await,
LifecycleCommand::Restart => selected.restart().await,
LifecycleCommand::Stop => {
let output = selected.stop().await?;
@@ -384,9 +387,9 @@ impl Daemon {
}
}
async fn start(&self) -> Result<LifecycleOutput> {
async fn start(&self, feature_overrides: &BTreeMap<String, bool>) -> Result<LifecycleOutput> {
let mut managed = self.clone();
let settings = self.load_settings().await?;
let mut settings = self.load_settings().await?;
let (status, backend, pid, info) = if let Ok(info) = client::probe(&self.socket_path).await
{
(
@@ -410,6 +413,12 @@ impl Daemon {
prepare_install::prepare(self, &settings).await?;
managed.managed_codex_bin = self.current_managed_codex_bin()?;
managed.ensure_managed_codex_bin()?;
// Only a fresh launch may replace these settings. Keep them for restarts
// and updates, without changing the user's config or a running daemon.
if settings.feature_overrides != *feature_overrides {
settings.feature_overrides = feature_overrides.clone();
settings.save(&self.settings_file).await?;
}
let pid = managed.start_managed_backend(&settings).await?;
(
LifecycleStatus::Started,
@@ -652,7 +661,7 @@ impl Daemon {
let _ = selected
.set_remote_control_locked(RemoteControlMode::Enabled)
.await?;
let output = selected.start().await?;
let output = selected.start(&BTreeMap::new()).await?;
return Ok(RemoteControlStartOutput::Start(output));
}
@@ -954,6 +963,7 @@ impl Daemon {
pid_file: self.pid_file.clone(),
update_pid_file: self.update_pid_file.clone(),
remote_control_enabled: settings.remote_control_enabled,
feature_overrides: settings.feature_overrides.clone(),
}
}
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::path::Path;
use std::time::Duration;
@@ -17,6 +18,7 @@ pub(crate) const MAX_SHUTDOWN_GRACE_SECONDS: u32 = 5 * 60;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DaemonSettings {
pub(crate) remote_control_enabled: bool,
pub(crate) feature_overrides: BTreeMap<String, bool>,
pub(crate) auto_update_enabled: bool,
pub(crate) update_interval_minutes: u32,
pub(crate) shutdown_grace_seconds: u32,
@@ -26,6 +28,7 @@ impl Default for DaemonSettings {
fn default() -> Self {
Self {
remote_control_enabled: false,
feature_overrides: BTreeMap::new(),
auto_update_enabled: true,
update_interval_minutes: DEFAULT_UPDATE_INTERVAL_MINUTES,
shutdown_grace_seconds: DEFAULT_SHUTDOWN_GRACE_SECONDS,
@@ -44,6 +47,8 @@ struct StopSettings {
struct StoredSettings {
#[serde(default)]
remote_control_enabled: bool,
#[serde(default)]
feature_overrides: BTreeMap<String, bool>,
#[serde(default = "default_shutdown_grace_seconds")]
shutdown_grace_seconds: u32,
#[serde(default)]
@@ -54,6 +59,7 @@ impl Default for StoredSettings {
fn default() -> Self {
Self {
remote_control_enabled: false,
feature_overrides: BTreeMap::new(),
shutdown_grace_seconds: DEFAULT_SHUTDOWN_GRACE_SECONDS,
updater: UpdaterSettings::default(),
}
@@ -127,6 +133,7 @@ impl DaemonSettings {
validate_shutdown_grace(settings.shutdown_grace_seconds)?;
Ok(Self {
remote_control_enabled: settings.remote_control_enabled,
feature_overrides: settings.feature_overrides,
auto_update_enabled: settings.updater.auto_update_enabled,
update_interval_minutes: settings.updater.update_interval_minutes,
shutdown_grace_seconds: settings.shutdown_grace_seconds,
@@ -161,6 +168,14 @@ impl DaemonSettings {
"remoteControlEnabled".to_string(),
Value::Bool(self.remote_control_enabled),
);
if self.feature_overrides.is_empty() {
settings.remove("featureOverrides");
} else {
settings.insert(
"featureOverrides".to_string(),
serde_json::to_value(&self.feature_overrides)?,
);
}
let contents =
serde_json::to_vec_pretty(&settings).context("failed to serialize settings")?;
let temporary_path = path.with_extension("tmp");
@@ -23,7 +23,7 @@ async fn remote_control_save_preserves_updater_settings() {
tokio::fs::write(
&path,
r#"{"remoteControlEnabled":true,"shutdownGraceSeconds":25,"updater":{"autoUpdateEnabled":false,"updateIntervalMinutes":17},"futureSetting":42}"#,
r#"{"remoteControlEnabled":true,"shutdownGraceSeconds":25,"updater":{"autoUpdateEnabled":false,"updateIntervalMinutes":17},"featureOverrides":{"api_key_model_discovery":true,"code_mode_host":false},"futureSetting":42}"#,
)
.await
.expect("write settings");
@@ -43,6 +43,7 @@ async fn remote_control_save_preserves_updater_settings() {
"remoteControlEnabled": false,
"shutdownGraceSeconds": 25,
"updater": {"autoUpdateEnabled": false, "updateIntervalMinutes": 17},
"featureOverrides": {"api_key_model_discovery": true, "code_mode_host": false},
"futureSetting": 42,
})
);
@@ -541,6 +541,82 @@ async fn manual_update_restarts_managed_daemon_with_automatic_updates_disabled()
check_manual_update_restart("standalone").await;
}
#[cfg(unix)]
#[tokio::test]
async fn daemon_start_and_restart_preserve_launch_features() {
for features in [
std::collections::BTreeMap::new(),
std::collections::BTreeMap::from([
("api_key_model_discovery".to_string(), true),
("code_mode_host".to_string(), false),
]),
] {
let home = TempDir::new().unwrap();
let (daemon, _) = manual_update_daemon(&home);
let args_path = home.path().join("launch-args");
std::fs::write(
&daemon.settings_file,
r#"{"featureOverrides":{"auth_elicitation":true},"updater":{"autoUpdateEnabled":false}}"#,
)
.unwrap();
std::fs::write(&daemon.managed_codex_bin, format!(
"#!/bin/sh\nif [ \"$1\" = --version ]; then echo codex 1.0.0; exit; fi\nif [ \"$3\" = --help ]; then exit; fi\nprintf '%s\\n' \"$@\" > '{}'\nexec sleep 30\n",
args_path.display(),
)).unwrap();
let control = async {
let deadline = tokio::time::Instant::now() + Duration::from_secs(/*secs*/ 10);
while !args_path.exists() {
assert!(
tokio::time::Instant::now() < deadline,
"daemon did not launch"
);
tokio::time::sleep(Duration::from_millis(/*millis*/ 20)).await;
}
test_control_server(&daemon, home.path())
.await
.abort_handle()
};
let (started, server) = tokio::join!(daemon.start(&features), control);
assert_eq!(started.unwrap().status, crate::LifecycleStatus::Started);
assert_eq!(
daemon.load_settings().await.unwrap().feature_overrides,
features
);
let expected = if features.is_empty() {
"app-server\n--listen\nunix://\n--managed-daemon\n"
} else {
"app-server\n--listen\nunix://\n-c\nfeatures.api_key_model_discovery=true\n-c\nfeatures.code_mode_host=false\n--managed-daemon\n"
};
assert_eq!(std::fs::read_to_string(&args_path).unwrap(), expected);
let reused = daemon
.start(&std::collections::BTreeMap::from([(
"api_key_model_discovery".to_string(),
false,
)]))
.await
.unwrap();
assert_eq!(reused.status, crate::LifecycleStatus::AlreadyRunning);
assert_eq!(
daemon.load_settings().await.unwrap().feature_overrides,
features
);
assert_eq!(std::fs::read_to_string(&args_path).unwrap(), expected);
std::fs::remove_file(&args_path).unwrap();
let restarted = daemon.restart().await;
let deadline = tokio::time::Instant::now() + Duration::from_secs(/*secs*/ 10);
while std::fs::read_to_string(&args_path).ok().as_deref() != Some(expected)
&& tokio::time::Instant::now() < deadline
{
tokio::time::sleep(Duration::from_millis(/*millis*/ 20)).await;
}
let args = std::fs::read_to_string(args_path);
daemon.stop().await.unwrap();
server.abort();
assert_eq!(restarted.unwrap().status, crate::LifecycleStatus::Restarted);
assert_eq!(args.unwrap(), expected);
}
}
#[cfg(unix)]
#[tokio::test]
async fn manual_update_restarts_local_daemon_with_automatic_updates_disabled() {
+27 -14
View File
@@ -161,6 +161,12 @@ async fn worktree_start_and_fork(backend: &str) -> anyhow::Result<()> {
for path in [&home, &source, &launcher, &launcher.join("extra")] {
fs::create_dir(path)?;
}
// Forking without --cd must select the daemon using the resolved project, not this launcher.
fs::create_dir(launcher.join(".codex"))?;
fs::write(
launcher.join(".codex/config.toml"),
"features.auth_elicitation = false\n",
)?;
fs::write(
source.join("AGENTS.md"),
"committed destination instructions",
@@ -185,7 +191,7 @@ cli_auth_credentials_store = "file"
chatgpt_base_url = "{}/source/backend-api"
analytics.enabled = false
check_for_update_on_startup = false
features.daemon_auto_start = true
features.daemon_auto_start = {}
model_provider = "local"
model = "test-model"
sandbox_mode = "workspace-write"
@@ -203,6 +209,7 @@ trust_level = "trusted"
trust_level = "trusted"
"#,
server.uri(),
backend == "daemon",
server.uri(),
server.uri(),
serde_json::to_string(&source)?,
@@ -279,6 +286,10 @@ trust_level = "trusted"
"--enable".into(),
"worktrees".into(),
"--no-alt-screen".into(),
"-c".into(),
"features.mcp_oauth_refresh_coordination=true".into(),
"-c".into(),
"suppress_unstable_features_warning=true".into(),
];
let mut owner = None;
let mut previous: Vec<String> = Vec::new();
@@ -358,19 +369,10 @@ trust_level = "trusted"
})
.mount(&server)
.await;
if backend == "daemon" && previous.is_empty() {
// Policy mocks must be ready before the daemon loads its startup configuration.
let started = Command::new(&program)
.envs(&env)
.args(["app-server", "daemon", "start"])
.output()?;
anyhow::ensure!(
started.status.success(),
"daemon startup failed: {}",
String::from_utf8_lossy(&started.stderr)
);
}
let mut args = worktree_args.clone();
if backend == "daemon" && previous.is_empty() {
args.extend(["-c".into(), "features.auth_elicitation=true".into()]);
}
if fork {
args.extend([
"--add-dir".into(),
@@ -518,6 +520,10 @@ trust_level = "trusted"
.context("metrics export while the TUI is running")?;
}
let startup_result = observed.as_ref().map(|result| result.as_ref().map(|_| ()));
assert!(
!output.contains("Under-development features enabled:"),
"{output}"
);
if !matches!(startup_result, Ok(Ok(()))) || renamed.is_err() {
// Bypass libtest capture and report before tearing down the Windows PTY.
let _ = writeln!(
@@ -613,7 +619,14 @@ trust_level = "trusted"
("terminal_name", "unknown"),
("multiplexer", "none"),
("daemon_selection_reason", "incompatible_option"),
("daemon_auto_start", "enabled"),
(
"daemon_auto_start",
if backend == "daemon" {
"enabled"
} else {
"disabled"
},
),
("auto_update", "enabled"),
("auto_update_setting", "default"),
("update_interval_setting", "default"),
@@ -67,7 +67,7 @@ impl App {
tokio::select! {
_ = response_tx.closed() => {},
_guard = lock.lock() => crate::experimental_features::fetch(
handle, thread_id, "tui-experimental-features", response_tx,
handle, Some(thread_id), "tui-experimental-features", response_tx,
),
}
});
+7
View File
@@ -1877,6 +1877,7 @@ fn config_request_overrides_from_config(
| "permissions"
| "sandbox_workspace_write"
| "shell_environment_policy"
| "suppress_unstable_features_warning"
)
})
.filter_map(|(key, value)| {
@@ -2869,6 +2870,10 @@ mod tests {
..ConfigOverrides::default()
})
.cli_overrides(vec![
(
"suppress_unstable_features_warning".to_string(),
toml::Value::Boolean(true),
),
(
"features.multi_agent_mode".to_string(),
toml::Value::Boolean(true),
@@ -2900,11 +2905,13 @@ mod tests {
overrides.get("features").cloned(),
overrides.get("sandbox_workspace_write").cloned(),
overrides.get("instructions").cloned(),
overrides.get("suppress_unstable_features_warning").cloned(),
),
(
Some(serde_json::json!({ "multi_agent_mode": true })),
Some(serde_json::json!({ "network_access": false })),
None,
Some(serde_json::json!(true)),
)
);
for mode in [ThreadParamsMode::Embedded, ThreadParamsMode::Remote] {
+96 -5
View File
@@ -1,7 +1,16 @@
//! Local daemon launch policy. Explicit embedded launches never discover or start a daemon;
//! automatic launches require a successful shared-server connection.
//! incompatible feature settings use embedded mode. Compatible automatic launches
//! require a successful shared-server connection.
use super::*;
use std::collections::BTreeMap;
const SERVER_FEATURES: [Feature; 4] = [
Feature::ApiKeyModelDiscovery,
Feature::CodeModeHost,
Feature::AuthElicitation,
Feature::McpOAuthRefreshCoordination,
];
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).";
@@ -40,16 +49,21 @@ pub(super) fn config_exclusion(
strict_config: bool,
bypass_hook_trust: bool,
) -> Option<&'static str> {
// Worktree allocation is client-owned, and thread requests already forward this feature.
if !cli_kv_overrides
.iter()
.all(|(key, value)| match key.as_str() {
"features.worktrees" => value.is_bool(),
"suppress_unstable_features_warning" => value.is_bool(),
"features" => value.as_table().is_some_and(|features| {
features.len() == 1 && features.get("worktrees").is_some_and(toml::Value::is_bool)
!features.is_empty()
&& features
.iter()
.all(|(name, value)| allowed_feature(name) && value.is_bool())
}),
_ => false,
_ => key.strip_prefix("features.").is_some_and(allowed_feature) && value.is_bool(),
})
// Older clients cannot check compatibility before attaching. Do not disable
// shared services they may rely on through a new daemon's CLI overrides.
|| server_features(cli_kv_overrides).values().any(|enabled| !enabled)
{
Some("command-line configuration overrides (-c, --enable, --disable, or --search)")
} else if !loader_overrides_are_default(loader_overrides) {
@@ -62,3 +76,80 @@ pub(super) fn config_exclusion(
None
}
}
fn allowed_feature(name: &str) -> bool {
matches!(
name,
// Client gates and per-thread settings already forwarded in thread requests.
"daemon_auto_start" | "worktrees" | "realtime_conversation" | "standalone_web_search"
// Shared services and threadless MCP operations need daemon compatibility checks.
| "api_key_model_discovery" | "code_mode_host" | "auth_elicitation"
| "mcp_oauth_refresh_coordination"
// Removed flags still passed by older launch scripts.
| "remote_models" | "request_rule" | "responses_websockets_v2"
| "workspace_owner_usage_nudge" | "tool_search_always_defer_mcp_tools"
| "remote_compaction_v2" | "multi_agent_mode"
)
}
pub(super) fn server_features(overrides: &[(String, toml::Value)]) -> BTreeMap<String, bool> {
let layer = codex_config::build_cli_overrides_layer(overrides);
SERVER_FEATURES
.into_iter()
.filter_map(|feature| {
let name = feature.key();
let enabled = layer.get("features")?.get(name)?.as_bool()?;
Some((name.to_string(), enabled))
})
.collect()
}
/// Best-effort configured readback, not a guarantee about startup-captured service state.
pub(super) async fn compatibility_warning(
target: &AppServerTarget,
config: &Config,
) -> Option<String> {
if !matches!(target, AppServerTarget::LocalDaemon { .. }) {
return None;
}
// The feature-list RPC cannot report this process-scoped structured setting.
if !config.features.enabled(Feature::CodeModeHost)
&& config.code_mode.disable_in_process_fallback
{
return Some("Running without the shared background server: code-mode host fallback policy requires embedded mode.".to_string());
}
let check = async {
let client = app_server_connection::connect(target)
.await
.map_err(|_| "could not connect to check daemon feature settings".to_string())?;
let (tx, rx) = tokio::sync::oneshot::channel();
crate::experimental_features::fetch(
client.request_handle(),
/*thread_id*/ None,
"tui-daemon-features",
tx,
);
let result = rx.await;
let _ = client.shutdown().await;
let features = result.map_err(|_| "daemon feature check was interrupted".to_string())??;
// A previous client may have launched this daemon with overrides, even if
// this client has none. Check effective values, including defaults.
for feature in SERVER_FEATURES {
let name = feature.key();
let enabled = config.features.enabled(feature);
if features
.iter()
.find(|feature| feature.name == name)
.is_some_and(|feature| feature.enabled)
!= enabled
{
return Err(format!("daemon does not report features.{name}={enabled}"));
}
}
Ok::<(), String>(())
}
.await;
check
.err()
.map(|reason| format!("Running without the shared background server: {reason}."))
}
+74 -1
View File
@@ -6,11 +6,20 @@ use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn worktree_feature_override_allows_daemon_without_allowing_other_features() {
fn audited_overrides_allow_daemon_without_allowing_arbitrary_config() {
for (raw, eligible) in [
("features.worktrees=true", true),
("features.worktrees=false", true),
("features={worktrees=true}", true),
(
"features={worktrees=true,api_key_model_discovery=false}",
false,
),
("features.auth_elicitation=false", false),
("features.code_mode_host=false", false),
("features.mcp_oauth_refresh_coordination=false", false),
("suppress_unstable_features_warning=true", true),
("suppress_unstable_features_warning='true'", false),
("features={worktrees=true,shell_tool=false}", false),
("features.shell_tool=false", false),
("features.worktrees.enabled=true", false),
@@ -36,6 +45,70 @@ fn worktree_feature_override_allows_daemon_without_allowing_other_features() {
}
}
#[test]
fn monorepo_wrapper_overrides_are_eligible_and_select_only_server_features() {
let overrides = codex_utils_cli::CliConfigOverrides {
raw_overrides: [
"features.realtime_conversation=true",
"features.worktrees=true",
"features.remote_models=true",
"features.api_key_model_discovery=true",
"features.request_rule=true",
"features.auth_elicitation=true",
"features.mcp_oauth_refresh_coordination=true",
"features.responses_websockets_v2=true",
"features.workspace_owner_usage_nudge=true",
"features.tool_search_always_defer_mcp_tools=true",
"features.remote_compaction_v2=true",
"features.standalone_web_search=true",
"features.multi_agent_mode=true",
"features.code_mode_host=true",
"suppress_unstable_features_warning=true",
]
.map(str::to_string)
.to_vec(),
}
.parse_overrides()
.unwrap();
assert_eq!(
daemon_startup::config_exclusion(
&overrides,
&LoaderOverrides::default(),
/*strict_config*/ false,
/*bypass_hook_trust*/ false,
),
None
);
assert_eq!(
daemon_startup::server_features(&overrides),
std::collections::BTreeMap::from([
("api_key_model_discovery".to_string(), true),
("auth_elicitation".to_string(), true),
("code_mode_host".to_string(), true),
("mcp_oauth_refresh_coordination".to_string(), true),
])
);
}
#[test]
fn daemon_features_follow_cli_table_replacement_and_last_value() {
let overrides = codex_utils_cli::CliConfigOverrides {
raw_overrides: [
"features.api_key_model_discovery=true",
"features={code_mode_host=false}",
"features.code_mode_host=true",
]
.map(str::to_string)
.to_vec(),
}
.parse_overrides()
.unwrap();
assert_eq!(
daemon_startup::server_features(&overrides),
std::collections::BTreeMap::from([("code_mode_host".to_string(), true),])
);
}
#[test]
fn daemon_launch_telemetry_records_once_on_connection_or_early_return() {
for connected in [false, true] {
+4 -4
View File
@@ -18,7 +18,7 @@ use tokio::sync::oneshot;
pub(crate) fn fetch(
request_handle: AppServerRequestHandle,
thread_id: ThreadId,
thread_id: Option<ThreadId>,
request_id: &'static str,
mut response_tx: oneshot::Sender<Result<Vec<ExperimentalFeature>, String>>,
) {
@@ -38,7 +38,7 @@ pub(crate) fn fetch(
params: ExperimentalFeatureListParams {
cursor,
limit: Some(100),
thread_id: Some(thread_id.to_string()),
thread_id: thread_id.map(|id| id.to_string()),
},
},
)
@@ -88,7 +88,7 @@ pub(crate) async fn write(
let (tx, rx) = oneshot::channel();
fetch(
request_handle.clone(),
thread_id,
Some(thread_id),
"tui-experimental-save-readback",
tx,
);
@@ -133,7 +133,7 @@ pub(crate) async fn write(
let (tx, rx) = oneshot::channel();
fetch(
request_handle,
thread_id,
Some(thread_id),
"tui-experimental-save-readback",
tx,
);
@@ -81,7 +81,7 @@ async fn experimental_features_rpc_paginates_thread_config_and_bounds_bad_server
let (tx, rx) = oneshot::channel();
fetch(
AppServerRequestHandle::Remote(client.request_handle()),
thread_id,
Some(thread_id),
"tui-experimental-features",
tx,
);
@@ -126,7 +126,7 @@ async fn experimental_features_rpc_paginates_thread_config_and_bounds_bad_server
let (tx, rx) = oneshot::channel();
fetch(
AppServerRequestHandle::Remote(client.request_handle()),
thread_id,
Some(thread_id),
"tui-experimental-features",
tx,
);
+72 -57
View File
@@ -399,57 +399,6 @@ pub(super) async fn run_main_inner(
strict_config,
))
.await?;
let auto_start_daemon = config.features.enabled(Feature::DaemonAutoStart)
&& !cli.shared.worktree
&& !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()?;
let result =
codex_app_server_daemon::run(codex_app_server_daemon::LifecycleCommand::Start)
.await;
daemon_telemetry::record_start(&config, &result).await;
result.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 {
@@ -484,13 +433,79 @@ 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."
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;
}
let daemon_features = daemon_startup::server_features(&cli_kv_overrides);
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()?;
let result = codex_app_server_daemon::start_with_features(&daemon_features).await;
daemon_telemetry::record_start(&config, &result).await;
result.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,
};
}
// The overview must inspect the shared server's agents regardless of local settings.
let compatibility_warning = if cli.agents_overview {
None
} else {
startup_draft
.run_until(daemon_startup::compatibility_warning(
&app_server_target,
&config,
))
.await?
};
if compatibility_warning.is_some() {
app_server_target = AppServerTarget::Embedded;
daemon_exclusion = Some("daemon feature settings");
}
let daemon_startup_warning = compatibility_warning.or_else(|| {
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),
@@ -0,0 +1,104 @@
//! Exercises daemon compatibility fallback through real TUI startup.
use super::focus_palette::PtyCodex;
use super::focus_palette::write_test_config;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use codex_app_server_protocol::JSONRPCMessage;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::net::UnixListener;
use tokio_tungstenite::tungstenite::Message;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn incompatible_daemon_falls_back_for_default_and_explicit_features() -> Result<()> {
for scenario in ["default", "explicit", "host policy"] {
let cwd = codex_utils_cargo_bin::repo_root()?;
let home = tempfile::tempdir_in("/tmp")?;
write_test_config(home.path(), &cwd)?;
if scenario == "host policy" {
let path = home.path().join("config.toml");
let contents = std::fs::read_to_string(&path)?;
std::fs::write(
path,
format!(
"features.code_mode_host = {{enabled=false, disable_in_process_fallback=true}}\n{contents}"
),
)?;
}
let socket = codex_app_server_client::app_server_control_socket_path(home.path())?;
std::fs::create_dir_all(socket.parent().unwrap())?;
let listener = UnixListener::bind(socket.as_path())?;
let server = tokio::spawn(async move {
let mut socket = loop {
let (stream, _) = listener.accept().await?;
if let Ok(socket) = tokio_tungstenite::accept_async(stream).await {
break socket;
}
};
while let Some(Ok(Message::Text(text))) = socket.next().await {
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
continue;
};
let response = if request.method == "initialize" {
json!({"id": request.id, "result": {"userAgent": "daemon-test/0.0.0"}})
} else {
assert_eq!(request.method, "experimentalFeature/list");
json!({"id": request.id, "result": {"data": [{
"name": "api_key_model_discovery", "stage": "underDevelopment",
"displayName": null, "description": null, "announcement": null,
"enabled": scenario == "default", "defaultEnabled": false,
}], "nextCursor": null}})
};
socket
.send(Message::Text(response.to_string().into()))
.await?;
}
Ok::<_, anyhow::Error>(())
});
let args = if scenario == "explicit" {
vec!["-c", "features.api_key_model_discovery=true"]
} else {
vec![]
};
let mut terminal = PtyCodex::start(&cwd, home, &args)?;
terminal.wait_for_startup()?;
terminal.wait_for_screen("startup issue")?;
terminal.write_input(b"\x14")?;
let (snapshot, warning_end) = match scenario {
"default" => (
"daemon_feature_mismatch",
"features.api_key_model_discovery=false.",
),
"explicit" => (
"daemon_override_mismatch",
"features.api_key_model_discovery=true.",
),
"host policy" => ("daemon_host_policy_mismatch", "requires embedded mode."),
_ => unreachable!(),
};
terminal.wait_for_screen(warning_end)?;
let screen = terminal.screen_contents();
let warning = screen
.lines()
.find(|line| line.contains("Running without the shared background server"))
.context("missing warning line")?;
insta::assert_snapshot!(snapshot, warning);
terminal.write_input(b"\x14")?;
terminal.write_input(b"/status")?;
terminal.wait_for_screen("show current session configuration")?;
terminal.read_output(std::time::Duration::from_millis(/*millis*/ 200))?;
terminal.write_input(b"\r")?;
terminal.wait_for_screen("Model:")?;
ensure!(!terminal.screen_contains("unix://"));
if scenario == "host policy" {
server.abort();
continue;
}
tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 5), server).await???;
}
Ok(())
}
@@ -71,6 +71,11 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() ->
methods.lock().unwrap().push(request.method.clone());
let result = match request.method.as_str() {
"initialize" => json!({"userAgent": "trust-pty"}),
"experimentalFeature/list" => json!({"data": (["code_mode_host", "auth_elicitation"].map(|name| json!({
"name": name, "stage": "stable", "displayName": null,
"description": null, "announcement": null,
"enabled": true, "defaultEnabled": true,
}))), "nextCursor": null}),
"account/read" => {
json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false})
}
+10 -1
View File
@@ -458,7 +458,16 @@ fn auto_daemon_start_failure_exits_with_manual_fallback_hint() -> Result<()> {
)?;
// 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 mut terminal = PtyCodex::start(
workspace.path(),
home,
&[
"-c",
"features.api_key_model_discovery=true",
"-c",
"suppress_unstable_features_warning=true",
],
)?;
let deadline = Instant::now() + STARTUP_TIMEOUT;
while Instant::now() < deadline {
terminal.read_output(Duration::from_millis(/*millis*/ 50))?;
+2
View File
@@ -1,5 +1,7 @@
// Aggregates all former standalone integration tests as modules.
#[cfg(unix)]
mod daemon_compatibility;
#[cfg(unix)]
mod directory_trust;
#[cfg(unix)]
mod focus_palette;
+10 -1
View File
@@ -36,7 +36,8 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
"status": {"type": "active", "activeFlags": []}, "cwd": server_cwd,
"cliVersion": "0.0.0", "source": "cli", "turns": [{"id": "running", "items": [], "status": "inProgress", "error": null}]
});
for connection in 0..3 {
// The first connection checks daemon compatibility before TUI startup.
for connection in -1..3 {
let mut socket = loop {
let (stream, _) = listener.accept().await?;
// Startup probes the default daemon socket before opening its WebSocket.
@@ -77,6 +78,14 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
}
let result = match request.method.as_str() {
"initialize" => json!({"userAgent": "reconnect-pty"}),
// An older daemon can omit the client's default-disabled features.
"experimentalFeature/list" => {
json!({"data": (["code_mode_host", "auth_elicitation"].map(|name| json!({
"name": name, "stage": "stable", "displayName": null,
"description": null, "announcement": null,
"enabled": true, "defaultEnabled": true,
}))), "nextCursor": null})
}
"account/read" => {
json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false})
}
@@ -0,0 +1,5 @@
---
source: tui/tests/suite/daemon_compatibility.rs
expression: warning
---
⚠ Running without the shared background server: daemon does not report features.api_key_model_discovery=false.
@@ -0,0 +1,5 @@
---
source: tui/tests/suite/daemon_compatibility.rs
expression: warning
---
⚠ Running without the shared background server: code-mode host fallback policy requires embedded mode.
@@ -0,0 +1,5 @@
---
source: tui/tests/suite/daemon_compatibility.rs
expression: warning
---
⚠ Running without the shared background server: daemon does not report features.api_key_model_discovery=true.