diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a4bd8e47e1..db3e1c3359 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1493,6 +1493,7 @@ dependencies = [ "codex-tui", "codex-utils-cargo-bin", "codex-utils-cli", + "codex-utils-pty", "codex-windows-sandbox", "libc", "owo-colors", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 1d955bbaaf..3d0f143f3a 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -60,5 +60,6 @@ codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows- assert_cmd = { workspace = true } assert_matches = { workspace = true } codex-utils-cargo-bin = { workspace = true } +codex-utils-pty = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/cli/tests/auto_update_startup.rs b/codex-rs/cli/tests/auto_update_startup.rs new file mode 100644 index 0000000000..c44cb0def4 --- /dev/null +++ b/codex-rs/cli/tests/auto_update_startup.rs @@ -0,0 +1,180 @@ +use std::collections::HashMap; +use std::time::Duration; + +use pretty_assertions::assert_eq; +use tokio::select; +use tokio::time::timeout; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[tokio::test] +#[cfg_attr( + debug_assertions, + ignore = "update checks are disabled in debug builds (cfg(not(debug_assertions)))" +)] +/// Runs the real CLI startup path with a seeded cached update result and a +/// mocked `npm` binary so the auto-update branch is exercised end to end. +async fn startup_auto_update_runs_detected_npm_command() -> anyhow::Result<()> { + if cfg!(windows) { + // This test installs a POSIX shell script as the mocked updater. + return Ok(()); + } + + let tmp = tempfile::tempdir()?; + let codex_home = tmp.path().join("codex-home"); + std::fs::create_dir_all(&codex_home)?; + + let cwd = std::env::current_dir()?; + let config_contents = format!( + r#" +model_provider = "ollama" + +[projects] +"{cwd}" = {{ trust_level = "trusted" }} + +[features] +startup_auto_update = true +"#, + cwd = cwd.display() + ); + std::fs::write(codex_home.join("config.toml"), config_contents)?; + + // Seed a cached "newer version" result so startup takes the normal update + // flow without depending on a live version check. + let version_json = serde_json::json!({ + "latest_version": "999.0.0", + "last_checked_at": "9999-01-01T00:00:00Z", + "dismissed_version": serde_json::Value::Null, + }); + std::fs::write( + codex_home.join("version.json"), + format!("{}\n", serde_json::to_string(&version_json)?), + )?; + + // Put a fake `npm` first on PATH and record the exact working directory and + // arguments the CLI uses. + let bin_dir = tmp.path().join("bin"); + std::fs::create_dir_all(&bin_dir)?; + let npm_log_path = tmp.path().join("mock-npm.log"); + let npm_script_path = bin_dir.join("npm"); + std::fs::write( + &npm_script_path, + format!( + r#"#!/bin/sh +set -eu +{{ + printf 'cwd=%s\n' "$(pwd)" + for arg in "$@"; do + printf 'arg=%s\n' "$arg" + done +}} > "{}" +"#, + npm_log_path.display() + ), + )?; + #[cfg(unix)] + { + let mut perms = std::fs::metadata(&npm_script_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&npm_script_path, perms)?; + } + + let codex_cli = codex_utils_cargo_bin::cargo_bin("codex")?; + let mut env = HashMap::new(); + env.insert("CODEX_HOME".to_string(), codex_home.display().to_string()); + env.insert("CODEX_MANAGED_BY_NPM".to_string(), "1".to_string()); + env.insert( + "PATH".to_string(), + match std::env::var("PATH") { + Ok(path) => format!("{}:{path}", bin_dir.display()), + Err(_) => bin_dir.display().to_string(), + }, + ); + + let args = vec!["-c".to_string(), "analytics.enabled=false".to_string()]; + let spawned = codex_utils_pty::spawn_pty_process( + codex_cli.to_string_lossy().as_ref(), + &args, + &cwd, + &env, + &None, + ) + .await?; + let mut output = Vec::new(); + let mut output_rx = spawned.output_rx; + let mut exit_rx = spawned.exit_rx; + let writer_tx = spawned.session.writer_sender(); + + let exit_code_result = timeout(Duration::from_secs(20), async { + loop { + select! { + result = output_rx.recv() => match result { + Ok(chunk) => { + if chunk.windows(4).any(|window| window == b"\x1b[6n") { + // The TUI asks the terminal for cursor position + // during startup; reply so the PTY session can + // finish initializing. + let _ = writer_tx.send(b"\x1b[1;1R".to_vec()).await; + } + output.extend_from_slice(&chunk); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break exit_rx.await, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + }, + result = &mut exit_rx => break result, + } + } + }) + .await; + + let exit_code = match exit_code_result { + Ok(Ok(code)) => code, + Ok(Err(err)) => return Err(err.into()), + Err(_) => { + spawned.session.terminate(); + anyhow::bail!("timed out waiting for codex CLI to exit"); + } + }; + + while let Ok(chunk) = output_rx.try_recv() { + output.extend_from_slice(&chunk); + } + + let output = String::from_utf8_lossy(&output).to_string(); + assert_eq!( + exit_code, 0, + "Codex should exit successfully. Output:\n{output}" + ); + assert!( + output.contains("Updating Codex via `npm install -g @openai/codex`..."), + "expected auto-update execution message, got: {output}" + ); + assert!( + output.contains("Update ran successfully! Please restart Codex."), + "expected successful update message, got: {output}" + ); + + let npm_log = std::fs::read_to_string(&npm_log_path)?; + let cwd_line = format!("cwd={}", cwd.display()); + assert!( + npm_log.lines().any(|line| line == cwd_line), + "expected npm to run in cwd {}, got log:\n{npm_log}", + cwd.display(), + ); + + let args_seen: Vec = npm_log + .lines() + .filter_map(|line| line.strip_prefix("arg=").map(ToString::to_string)) + .collect(); + assert_eq!( + args_seen, + vec![ + "install".to_string(), + "-g".to_string(), + "@openai/codex".to_string(), + ] + ); + + Ok(()) +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b800cb6941..f067a56e56 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -403,6 +403,9 @@ "sqlite": { "type": "boolean" }, + "startup_auto_update": { + "type": "boolean" + }, "steer": { "type": "boolean" }, @@ -1684,6 +1687,9 @@ "sqlite": { "type": "boolean" }, + "startup_auto_update": { + "type": "boolean" + }, "steer": { "type": "boolean" }, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 4b3f0b9dec..a48629b8be 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -139,6 +139,9 @@ pub enum Feature { Personality, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, + /// Automatically take the existing startup "Update now" path and run the + /// detected package-manager update command. + StartupAutoUpdate, /// Use the Responses API WebSocket transport for OpenAI by default. ResponsesWebsockets, /// Enable Responses API websocket v2 mode. @@ -640,6 +643,16 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::StartupAutoUpdate, + key: "startup_auto_update", + stage: Stage::Experimental { + name: "Auto-update on startup", + menu_description: "Automatically run the detected npm/bun/brew update command when a newer Codex version is already known.", + announcement: "NEW: Auto-update on startup can automatically run the detected npm, bun, or Homebrew update command when a new Codex version is available.", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::ResponsesWebsockets, key: "responses_websockets", @@ -759,6 +772,15 @@ mod tests { assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false); } + #[test] + fn startup_auto_update_is_experimental_and_disabled_by_default() { + assert!(matches!( + Feature::StartupAutoUpdate.stage(), + Stage::Experimental { .. } + )); + assert_eq!(Feature::StartupAutoUpdate.default_enabled(), false); + } + #[test] fn collab_is_legacy_alias_for_multi_agent() { assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab)); diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index 43ee0dbd40..cbe624b65e 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -13,6 +13,7 @@ use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use crate::updates; use codex_core::config::Config; +use codex_core::features::Feature; use color_eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -32,6 +33,12 @@ pub(crate) enum UpdatePromptOutcome { RunUpdate(UpdateAction), } +/// Resolves startup update state before the main TUI loop begins. +/// +/// This either renders the interactive update prompt or returns the same +/// `RunUpdate` outcome that the manual "Update now" selection uses. When +/// `Feature::StartupAutoUpdate` is enabled, the interactive prompt is skipped +/// and the existing update execution path is reused unchanged. pub(crate) async fn run_update_prompt_if_needed( tui: &mut Tui, config: &Config, @@ -43,6 +50,13 @@ pub(crate) async fn run_update_prompt_if_needed( return Ok(UpdatePromptOutcome::Continue); }; + if config.features.enabled(Feature::StartupAutoUpdate) { + // Reuse the exact same `RunUpdate` path as the interactive "Update now" + // selection so auto-update cannot drift from manual update behavior. + tui.terminal.clear()?; + return Ok(UpdatePromptOutcome::RunUpdate(update_action)); + } + let mut screen = UpdatePromptScreen::new(tui.frame_requester(), latest_version.clone(), update_action); tui.draw(u16::MAX, |frame| {