mirror of
https://github.com/openai/codex.git
synced 2026-02-28 19:53:48 +00:00
Compare commits
2 Commits
dev/cc/new
...
codex/star
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d17805f66 | ||
|
|
76b25805bf |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -1493,6 +1493,7 @@ dependencies = [
|
||||
"codex-tui",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-cli",
|
||||
"codex-utils-pty",
|
||||
"codex-windows-sandbox",
|
||||
"libc",
|
||||
"owo-colors",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
187
codex-rs/cli/tests/auto_update_startup.rs
Normal file
187
codex-rs/cli/tests/auto_update_startup.rs
Normal 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(())
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user