merge code-branch-20250928-230546 via /merge

This commit is contained in:
James Peter
2025-09-29 10:08:08 +10:00
parent 4be720b6c1
commit 611e2f2954
7 changed files with 478 additions and 54 deletions
+1 -1
View File
@@ -1788,7 +1788,7 @@ impl App<'_> {
}
SlashCommand::Update => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_update_command();
widget.handle_update_command(command_args.trim());
}
}
SlashCommand::Notifications => {
@@ -100,7 +100,7 @@ impl UpdateSettingsView {
.unwrap_or_else(|| command.join(" "));
self.app_event_tx.send_background_event(format!(
"⬆️ Update available: {} → {}. Running `{}`...",
"⬆️ Update available: {} → {}. Opening guided upgrade with `{}`…",
self.current_version, latest, display
));
self.app_event_tx.send(AppEvent::RunUpdateCommand {
@@ -109,7 +109,7 @@ impl UpdateSettingsView {
latest_version: Some(latest.clone()),
});
self.app_event_tx.send_background_event(format!(
"↻ Restart Code after `{}` completes to finish upgrading to {}.",
"↻ Complete the guided terminal steps for `{}` then restart Code to finish upgrading to {}.",
display, latest
));
self.is_complete = true;
+91 -17
View File
@@ -69,6 +69,7 @@ use self::agent_install::{
start_agent_install_session,
start_direct_terminal_session,
start_prompt_terminal_session,
start_upgrade_terminal_session,
wrap_command,
};
use self::auto_coordinator::{start_auto_coordinator, AutoCoordinatorCommand};
@@ -115,6 +116,7 @@ use crate::bottom_pane::{
AutoCoordinatorViewModel,
CountdownState,
};
use crate::exec_command::strip_bash_lc_and_escape;
use codex_git_tooling::{
create_ghost_commit,
restore_ghost_commit,
@@ -8605,16 +8607,48 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn handle_update_command(&mut self) {
if crate::updates::upgrade_ui_enabled() {
pub(crate) fn handle_update_command(&mut self, command_args: &str) {
if !crate::updates::upgrade_ui_enabled() {
self.app_event_tx.send_background_event(
"`/update` — updates are disabled in debug builds. Set SHOW_UPGRADE=1 to preview.".
to_string(),
);
return;
}
let trimmed = command_args.trim();
if trimmed.eq_ignore_ascii_case("settings")
|| trimmed.eq_ignore_ascii_case("ui")
|| trimmed.eq_ignore_ascii_case("config")
{
self.show_update_settings_ui();
return;
}
self.app_event_tx.send_background_event(
"`/update` — updates are disabled in debug builds. Set SHOW_UPGRADE=1 to preview.".
to_string(),
);
match crate::updates::resolve_upgrade_resolution() {
crate::updates::UpgradeResolution::Command { command, display } => {
if command.is_empty() {
self.history_push(history_cell::new_error_event(
"`/upgrade` — no upgrade command available for this install.".to_string(),
));
self.request_redraw();
return;
}
let latest = self.latest_upgrade_version.clone();
self.push_background_tail(
"Opening a guided upgrade terminal to finish installing updates.".to_string(),
);
if let Some(launch) = self.launch_update_command(command, display, latest) {
self.app_event_tx.send(AppEvent::OpenTerminal(launch));
}
}
crate::updates::UpgradeResolution::Manual { instructions } => {
self.show_update_settings_ui();
self.history_push(history_cell::new_background_event(instructions));
self.request_redraw();
}
}
}
pub(crate) fn handle_notifications_command(&mut self, args: String) {
@@ -10131,19 +10165,52 @@ impl ChatWidget<'_> {
return None;
}
let id = self.terminal.alloc_id();
if let Some(version) = latest_version {
self.pending_upgrade_notice = Some((id, version));
let command_text = if display.trim().is_empty() {
strip_bash_lc_and_escape(&command)
} else {
display.clone()
};
if command_text.trim().is_empty() {
self.history_push(history_cell::new_error_event(
"`/update` — unable to resolve upgrade command text.".to_string(),
));
self.request_redraw();
return None;
}
Some(TerminalLaunch {
let id = self.terminal.alloc_id();
if let Some(version) = &latest_version {
self.pending_upgrade_notice = Some((id, version.clone()));
}
let (controller_tx, controller_rx) = mpsc::channel();
let controller = TerminalRunController { tx: controller_tx };
let display_label = Self::truncate_with_ellipsis(&format!("Guided: {command_text}"), 128);
let launch = TerminalLaunch {
id,
title: "Upgrade Code".to_string(),
command,
command_display: display,
controller: None,
command: Vec::new(),
command_display: display_label,
controller: Some(controller.clone()),
auto_close_on_success: false,
start_running: true,
})
};
let cwd = self.config.cwd.to_string_lossy().to_string();
start_upgrade_terminal_session(
self.app_event_tx.clone(),
id,
command_text,
latest_version,
Some(cwd),
controller,
controller_rx,
self.config.debug,
);
Some(launch)
}
pub(crate) fn terminal_open(&mut self, launch: &TerminalLaunch) {
@@ -11722,10 +11789,17 @@ impl ChatWidget<'_> {
let tx = self.app_event_tx.clone();
tokio::spawn(async move {
match crate::updates::auto_upgrade_if_enabled(&cfg).await {
Ok(Some(version)) => {
tx.send(AppEvent::AutoUpgradeCompleted { version });
Ok(outcome) => {
if let Some(version) = outcome.installed_version {
tx.send(AppEvent::AutoUpgradeCompleted { version });
}
if let Some(message) = outcome.user_notice {
tx.send(AppEvent::InsertBackgroundEvent {
message,
placement: BackgroundPlacement::Tail,
});
}
}
Ok(None) => {}
Err(err) => {
tracing::warn!("auto-upgrade: background task failed: {err:?}");
}
+130 -8
View File
@@ -36,6 +36,10 @@ enum GuidedTerminalMode {
},
Prompt { user_prompt: String },
DirectCommand { command: String },
Upgrade {
initial_command: String,
latest_version: Option<String>,
},
}
#[derive(Debug, Deserialize)]
@@ -112,6 +116,45 @@ pub(super) fn start_direct_terminal_session(
);
}
pub(super) fn start_upgrade_terminal_session(
app_event_tx: AppEventSender,
terminal_id: u64,
initial_command: String,
latest_version: Option<String>,
cwd: Option<String>,
controller: TerminalRunController,
controller_rx: Receiver<TerminalRunEvent>,
debug_enabled: bool,
) {
#[cfg(test)]
{
let _ = (
app_event_tx,
terminal_id,
initial_command,
latest_version,
cwd,
controller,
controller_rx,
debug_enabled,
);
return;
}
start_guided_terminal_session(
app_event_tx,
terminal_id,
GuidedTerminalMode::Upgrade {
initial_command,
latest_version,
},
cwd,
controller,
controller_rx,
debug_enabled,
);
}
fn start_guided_terminal_session(
app_event_tx: AppEventSender,
terminal_id: u64,
@@ -130,9 +173,9 @@ fn start_guided_terminal_session(
Err(err) => {
let helper = match &mode {
GuidedTerminalMode::AgentInstall { .. } => "Install helper",
GuidedTerminalMode::Prompt { .. } | GuidedTerminalMode::DirectCommand { .. } => {
"Terminal helper"
}
GuidedTerminalMode::Prompt { .. }
| GuidedTerminalMode::DirectCommand { .. } => "Terminal helper",
GuidedTerminalMode::Upgrade { .. } => "Upgrade helper",
};
let msg = format!("Failed to start {helper} runtime: {err}");
app_event_tx.send(AppEvent::TerminalChunk {
@@ -161,9 +204,9 @@ fn start_guided_terminal_session(
) {
let helper = match &mode {
GuidedTerminalMode::AgentInstall { .. } => "Install helper",
GuidedTerminalMode::Prompt { .. } | GuidedTerminalMode::DirectCommand { .. } => {
"Terminal helper"
}
GuidedTerminalMode::Prompt { .. }
| GuidedTerminalMode::DirectCommand { .. } => "Terminal helper",
GuidedTerminalMode::Upgrade { .. } => "Upgrade helper",
};
let msg = if debug_enabled {
format!("{helper} error: {err:#}")
@@ -262,6 +305,25 @@ fn run_guided_loop(
),
"direct_terminal_flow",
),
GuidedTerminalMode::Upgrade {
initial_command,
latest_version,
} => {
let latest = latest_version
.as_ref()
.map(String::as_str)
.unwrap_or("unknown");
(
"Upgrade helper",
format!(
"You are helping the user upgrade Code to the latest available version. The preferred upgrade command is `{initial_command}`. Prioritize resolving permission prompts (including sudo password requests) and confirm the upgrade succeeds."
),
format!(
"Upgrade target: Code (latest version: {latest}).\nPrimary command: {initial_command}.\nPlatform: {platform}.\nSandbox: {sandbox}.\nWorking directory: {cwd_text}.\nRun the command, diagnose any failures (especially permissions), and guide the user until the upgrade completes."
),
"upgrade_terminal_flow",
)
}
};
if debug_enabled {
@@ -301,6 +363,20 @@ fn run_guided_loop(
cwd_text,
);
}
GuidedTerminalMode::Upgrade {
initial_command,
latest_version,
} => {
debug!(
"[{}] Starting upgrade session: command={} latest_version={:?} platform={} sandbox={} cwd={}",
helper_label,
initial_command,
latest_version,
platform,
sandbox,
cwd_text,
);
}
}
}
@@ -343,7 +419,7 @@ fn run_guided_loop(
conversation.push(make_message("user", initial_user));
let mut steps = match mode {
GuidedTerminalMode::DirectCommand { .. } => 1,
GuidedTerminalMode::DirectCommand { .. } | GuidedTerminalMode::Upgrade { .. } => 1,
_ => 0,
};
@@ -391,6 +467,50 @@ fn run_guided_loop(
id: terminal_id,
message: "Analyzing output…".to_string(),
});
} else if let GuidedTerminalMode::Upgrade { initial_command, .. } = mode {
let wrapped = wrap_command(initial_command);
if wrapped.is_empty() {
app_event_tx.send(AppEvent::TerminalChunk {
id: terminal_id,
chunk: b"Unable to build upgrade command for execution.\n".to_vec(),
_is_stderr: true,
});
app_event_tx.send(AppEvent::TerminalUpdateMessage {
id: terminal_id,
message: "Upgrade command could not be constructed.".to_string(),
});
return Ok(());
}
app_event_tx.send(AppEvent::TerminalRunCommand {
id: terminal_id,
command: wrapped,
command_display: initial_command.clone(),
controller: Some(controller.clone()),
});
let Some((output, exit_code)) = collect_command_output(controller_rx)
.context("collecting upgrade command output")?
else {
if debug_enabled {
debug!("[Upgrade helper] Initial command cancelled by user");
}
return Ok(());
};
let truncated = tail_chars(&output, MAX_OUTPUT_CHARS);
let summary = format!(
"Command: {initial_command}\nExit code: {}\nOutput (last {} chars):\n{}",
exit_code
.map(|c| c.to_string())
.unwrap_or_else(|| "unknown".to_string()),
truncated.chars().count(),
truncated
);
conversation.push(make_message("user", summary));
app_event_tx.send(AppEvent::TerminalSetAssistantMessage {
id: terminal_id,
message: "Analyzing output…".to_string(),
});
}
loop {
@@ -452,7 +572,9 @@ fn run_guided_loop(
let require_confirmation = match mode {
GuidedTerminalMode::AgentInstall { .. } => steps > 1,
GuidedTerminalMode::Prompt { .. } => steps > 1,
GuidedTerminalMode::DirectCommand { .. } => true,
GuidedTerminalMode::DirectCommand { .. } | GuidedTerminalMode::Upgrade { .. } => {
true
}
};
let final_command = if require_confirmation {
let (gate_tx, gate_rx) = channel();
+64
View File
@@ -647,6 +647,32 @@ pub(crate) fn make_chatwidget_manual_with_sender() -> (
(widget, app_event_tx, rx, op_rx)
}
struct EnvGuard {
key: String,
prev: Option<String>,
}
impl EnvGuard {
fn set(key: &str, value: &str) -> Self {
let prev = std::env::var(key).ok();
std::env::set_var(key, value);
Self {
key: key.to_string(),
prev,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
if let Some(prev) = self.prev.take() {
std::env::set_var(&self.key, prev);
} else {
std::env::remove_var(&self.key);
}
}
}
fn drain_insert_history(
rx: &std::sync::mpsc::Receiver<AppEvent>,
) -> Vec<Vec<ratatui::text::Line<'static>>> {
@@ -928,6 +954,9 @@ fn pump_app_events(chat: &mut ChatWidget<'_>, rx: &std::sync::mpsc::Receiver<App
AppEvent::DispatchCommand(SlashCommand::Undo, _command_text) => {
chat.handle_undo_command();
}
AppEvent::DispatchCommand(SlashCommand::Update, command_text) => {
chat.handle_update_command(&command_text);
}
AppEvent::ShowUndoOptions { index } => {
chat.show_undo_restore_options(index);
}
@@ -1014,6 +1043,41 @@ fn slash_agents_opens_overview() {
);
}
#[test]
fn slash_upgrade_opens_guided_terminal() {
let _guard = EnvGuard::set("CODEX_MANAGED_BY_NPM", "1");
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
chat.latest_upgrade_version = Some("9.9.9".to_string());
let script = [
ScriptStep::key_char('/'),
ScriptStep::key_char('u'),
ScriptStep::key_char('p'),
ScriptStep::key_char('g'),
ScriptStep::key_char('r'),
ScriptStep::key_char('a'),
ScriptStep::key_char('d'),
ScriptStep::key_char('e'),
ScriptStep::enter(),
];
run_script(&mut chat, &script, &rx);
let mut saw_open_terminal = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::OpenTerminal(launch) = event {
saw_open_terminal = true;
assert_eq!(launch.title, "Upgrade Code");
assert!(
launch.command_display.contains("Guided"),
"expected guided terminal display, got {}",
launch.command_display
);
}
}
assert!(saw_open_terminal, "expected guided upgrade terminal to open");
}
#[test]
fn slash_undo_shows_no_snapshot_state() {
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
+188 -24
View File
@@ -147,9 +147,15 @@ pub fn resolve_upgrade_resolution() -> UpgradeResolution {
}
}
pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<Option<String>> {
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AutoUpgradeOutcome {
pub installed_version: Option<String>,
pub user_notice: Option<String>,
}
pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<AutoUpgradeOutcome> {
if !config.auto_upgrade_enabled {
return Ok(None);
return Ok(AutoUpgradeOutcome::default());
}
let resolution = resolve_upgrade_resolution();
@@ -160,7 +166,7 @@ pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<Option<S
} if !command.is_empty() => (command, command_display),
_ => {
info!("auto-upgrade enabled but no managed installer detected; skipping");
return Ok(None);
return Ok(AutoUpgradeOutcome::default());
}
};
@@ -168,24 +174,24 @@ pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<Option<S
Ok(info) => info,
Err(err) => {
warn!("auto-upgrade: failed to check for updates: {err}");
return Ok(None);
return Ok(AutoUpgradeOutcome::default());
}
};
let Some(latest_version) = info.latest_version.clone() else {
// Already up to date
return Ok(None);
return Ok(AutoUpgradeOutcome::default());
};
let lock = match AutoUpgradeLock::acquire(&config.codex_home) {
Ok(Some(lock)) => lock,
Ok(None) => {
info!("auto-upgrade already in progress by another instance; skipping");
return Ok(None);
return Ok(AutoUpgradeOutcome::default());
}
Err(err) => {
warn!("auto-upgrade: unable to acquire lock: {err}");
return Ok(None);
return Ok(AutoUpgradeOutcome::default());
}
};
@@ -194,17 +200,75 @@ pub async fn auto_upgrade_if_enabled(config: &Config) -> anyhow::Result<Option<S
latest_version = latest_version.as_str(),
"auto-upgrade: running managed installer"
);
let result = run_upgrade_command(command).await;
let result = execute_upgrade_command(&command).await;
drop(lock);
let mut outcome = AutoUpgradeOutcome {
installed_version: None,
user_notice: None,
};
match result {
Ok(()) => {
info!("auto-upgrade: successfully installed {latest_version}");
Ok(Some(latest_version))
Ok(primary) => {
if primary.success {
info!("auto-upgrade: successfully installed {latest_version}");
outcome.installed_version = Some(latest_version);
return Ok(outcome);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
if !starts_with_sudo(&command) {
info!("auto-upgrade: retrying with sudo -n");
let sudo_command = wrap_with_sudo(&command);
match execute_upgrade_command(&sudo_command).await {
Ok(fallback) if fallback.success => {
info!("auto-upgrade: sudo retry succeeded; installed {latest_version}");
outcome.installed_version = Some(latest_version);
return Ok(outcome);
}
Ok(fallback) => {
if sudo_requires_manual_intervention(&fallback.stderr, fallback.status)
{
outcome.user_notice = Some(format!(
"Automatic upgrade needs your attention. Run `/upgrade` to finish with `{}`.",
command_display
));
}
warn!(
"auto-upgrade: sudo retry failed: status={:?} stderr={}",
fallback.status,
truncate_for_log(&fallback.stderr)
);
return Ok(outcome);
}
Err(err) => {
warn!("auto-upgrade: sudo retry error: {err}");
outcome.user_notice = Some(format!(
"Automatic upgrade could not escalate permissions. Run `/upgrade` to finish with `{}`.",
command_display
));
return Ok(outcome);
}
}
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = primary; // suppress unused warning on non-Unix targets
}
warn!(
"auto-upgrade: upgrade command failed: status={:?} stderr={}",
primary.status,
truncate_for_log(&primary.stderr)
);
Ok(outcome)
}
Err(err) => {
warn!("auto-upgrade: upgrade command failed: {err}");
Ok(None)
warn!("auto-upgrade: failed to launch upgrade command: {err}");
Ok(outcome)
}
}
}
@@ -288,7 +352,14 @@ impl Drop for AutoUpgradeLock {
}
}
async fn run_upgrade_command(command: Vec<String>) -> anyhow::Result<()> {
#[derive(Debug, Clone)]
struct CommandCapture {
success: bool,
status: Option<i32>,
stderr: String,
}
async fn execute_upgrade_command(command: &[String]) -> anyhow::Result<CommandCapture> {
if command.is_empty() {
anyhow::bail!("upgrade command is empty");
}
@@ -297,18 +368,111 @@ async fn run_upgrade_command(command: Vec<String>) -> anyhow::Result<()> {
if command.len() > 1 {
cmd.args(&command[1..]);
}
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
let status = cmd.status().await?;
if !status.success() {
anyhow::bail!(
"upgrade command exited with status {}",
status.code().map_or_else(|| "signal".to_string(), |c| c.to_string())
);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let output = cmd.output().await?;
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
Ok(CommandCapture {
success: output.status.success(),
status: output.status.code(),
stderr,
})
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn wrap_with_sudo(command: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(command.len() + 3);
out.push("sudo".to_string());
out.push("-n".to_string());
out.push("--".to_string());
out.extend(command.iter().cloned());
out
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn starts_with_sudo(command: &[String]) -> bool {
command
.first()
.map(|c| c.eq_ignore_ascii_case("sudo"))
.unwrap_or(false)
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn sudo_requires_manual_intervention(stderr: &str, status: Option<i32>) -> bool {
let lowered = stderr.to_ascii_lowercase();
let needs_password = lowered.contains("password is required")
|| lowered.contains("a password is required")
|| lowered.contains("no tty present and no askpass program specified")
|| lowered.contains("must be run from a terminal")
|| lowered.contains("may not run sudo")
|| lowered.contains("permission denied");
needs_password && status == Some(1)
}
fn truncate_for_log(text: &str) -> String {
const LIMIT: usize = 256;
if text.len() <= LIMIT {
text.replace('\n', " ")
} else {
let mut truncated = text[..LIMIT].to_string();
truncated.push_str("…");
truncated.replace('\n', " ")
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
#[test]
fn truncate_for_log_short_text_preserved() {
let text = "no issues";
assert_eq!(truncate_for_log(text), text);
}
#[test]
fn truncate_for_log_truncates_long_messages() {
let long = "a".repeat(300);
let truncated = truncate_for_log(&long);
assert!(truncated.ends_with('…'));
assert!(truncated.len() <= 257);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn wrap_with_sudo_injects_dash_dash() {
let original = vec!["npm".to_string(), "install".to_string()];
let wrapped = wrap_with_sudo(&original);
assert_eq!(wrapped[0], "sudo");
assert_eq!(wrapped[1], "-n");
assert_eq!(wrapped[2], "--");
assert_eq!(&wrapped[3..], original);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn starts_with_sudo_detects_prefix_case_insensitive() {
assert!(starts_with_sudo(&["sudo".into(), "true".into()]));
assert!(starts_with_sudo(&["SUDO".into(), "true".into()]));
assert!(!starts_with_sudo(&["npm".into(), "install".into()]));
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn sudo_requires_manual_intervention_matches_common_errors() {
let stderr = "sudo: a password is required";
assert!(sudo_requires_manual_intervention(stderr, Some(1)));
let stderr_case = "sudo: Permission denied";
assert!(sudo_requires_manual_intervention(stderr_case, Some(1)));
let stderr_other = "sudo: authentication succeeded";
assert!(!sudo_requires_manual_intervention(stderr_other, Some(0)));
}
Ok(())
}
fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
+2 -2
View File
@@ -47,8 +47,8 @@ Notes
- `/prompts`: show example prompts.
- `/status`: show current session configuration and token usage.
- `/limits`: visualize current hourly and weekly rate-limit usage.
- `/update`: check the installed version, detect available upgrades, and run the
appropriate installer when possible.
- `/update`: check the installed version, detect available upgrades, and open a
guided upgrade terminal that runs the installer interactively when possible.
- `/notifications`: inspect and toggle TUI notifications.
- `/mcp`: manage MCP servers (status/on/off/add).
- `/validation [status|on|off|<tool> (on|off)]`: inspect or toggle validation