mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
merge code-branch-20250928-230546 via /merge
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user