Compare commits

...

2 Commits

Author SHA1 Message Date
Yaroslav Volovich
5d17805f66 Document startup auto-update flow 2026-02-25 18:52:09 +00:00
Yaroslav Volovich
76b25805bf Add experimental startup auto-update 2026-02-25 18:42:42 +00:00
6 changed files with 243 additions and 0 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -1493,6 +1493,7 @@ dependencies = [
"codex-tui",
"codex-utils-cargo-bin",
"codex-utils-cli",
"codex-utils-pty",
"codex-windows-sandbox",
"libc",
"owo-colors",

View File

@@ -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 }

View File

@@ -0,0 +1,187 @@
//! End-to-end PTY coverage for the startup auto-update handoff.
//!
//! This test seeds cached update state and a fake `npm` on `PATH`, then runs
//! the real `codex` binary. Its job is to document and enforce the contract
//! that startup reuses the existing updater path and preserves the updater
//! working directory and argv.
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<String> = 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(())
}

View File

@@ -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"
},

View File

@@ -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));

View File

@@ -1,4 +1,9 @@
#![cfg(not(debug_assertions))]
//! Renders the startup update prompt and reports which startup path to take.
//!
//! This module owns the selection layer for update prompts. It decides whether
//! startup should continue normally or hand control back to the CLI to run an
//! updater command, but it does not execute the updater itself.
use crate::history_cell::padded_emoji;
use crate::key_hint;
@@ -13,6 +18,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;
@@ -27,11 +33,24 @@ use ratatui::widgets::Clear;
use ratatui::widgets::WidgetRef;
use tokio_stream::StreamExt;
/// Describes what startup should do after resolving update prompt state.
///
/// The TUI returns this instead of spawning updater processes directly so the
/// CLI remains the single owner of command execution, exit behavior, and
/// update status messaging.
pub(crate) enum UpdatePromptOutcome {
/// Proceed into the normal TUI startup path.
Continue,
/// Exit startup and run the detected updater command through the CLI.
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 +62,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| {