mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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}."))
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
+5
@@ -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.
|
||||
+5
@@ -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.
|
||||
+5
@@ -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.
|
||||
Reference in New Issue
Block a user