grok-build-upstream-mirror/crates/codegen/xai-grok-update/src/auto_update.rs

4886 lines
187 KiB
Rust
Raw Normal View History

use anyhow::{Context, Result};
use std::io::{self, Write};
use std::process::{Command, Stdio};
use std::time::Duration;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use futures::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use tokio::io::AsyncWriteExt;
use crate::version::{
UpdateConfig, fetch_latest_version, get_installed_grok_version, get_latest_version,
is_version_cache_fresh, try_fetch_stable_pointer, write_version_cache,
};
use xai_grok_shell::util::config;
use xai_grok_shell::util::grok_home::{grok_application, grok_home};
#[derive(Clone, Copy, Debug)]
pub enum UpdateRunMode {
Blocking,
NonBlocking,
}
const PROMPT_UPDATE_NOW: &str = "Update now? [Y/n/d]";
const MSG_AUTO_UPDATE_BACKGROUND: &str = "Auto-update running in background.";
const MSG_RUN_UPDATE_MANUAL: &str = "Run `grok update` to get the latest version.";
/// Manual-install one-liner for this platform's bootstrap installer.
fn manual_install_cmd() -> &'static str {
if cfg!(windows) {
"irm https://x.ai/cli/install.ps1 | iex"
} else {
"curl -fsSL https://x.ai/cli/install.sh | bash"
}
}
/// Build a reinstall hint for a known installer type.
fn reinstall_hint(installer: &str) -> String {
match installer {
"npm" => "Please reinstall via npm:\n npm i -g @xai-official/grok".to_string(),
"gh-release" => "Please reinstall via GitHub Releases:\n gh release download --repo xai-org-shared/grok-build --pattern 'grok-*' --output grok && chmod +x grok".to_string(),
_ => format!("Please reinstall via:\n {}", manual_install_cmd()),
}
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateStatus {
pub current_version: String,
pub latest_version: Option<String>,
pub update_available: bool,
pub installer: Option<String>,
pub channel: String,
pub auto_update: Option<bool>,
pub error: Option<String>,
}
/// Format and print an [`UpdateStatus`] to stdout.
pub fn print_update_status(status: &UpdateStatus, json: bool) -> anyhow::Result<()> {
if json {
let payload = serde_json::to_string(status)?;
println!("{payload}");
return Ok(());
}
if let Some(error) = status.error.as_deref() {
println!(
"Grok Build - v{} [{}]",
status.current_version, status.channel
);
println!("Update check failed: {error}");
return Ok(());
}
let channel_label = format!(" [{}]", status.channel);
if status.update_available {
if let Some(latest_version) = status.latest_version.as_deref() {
println!(
"A new version of Grok Build is available: {} -> {}{}",
status.current_version, latest_version, channel_label
);
} else {
println!("A new version of Grok Build is available.");
}
return Ok(());
}
if let Some(latest_version) = status.latest_version.as_deref() {
println!(
"Grok Build - v{} (latest: {}){}",
status.current_version, latest_version, channel_label
);
return Ok(());
}
println!("Grok Build - v{}{}", status.current_version, channel_label);
Ok(())
}
pub async fn check_update_status(update_config: &UpdateConfig) -> UpdateStatus {
let installer = get_installer().await.map(|value| value.to_string());
let current_version = get_installed_grok_version();
let current_config = config::load_config().await;
let auto_update = current_config.cli.auto_update;
let channel = update_config.channel.clone();
let Some(ref inst) = installer else {
return UpdateStatus {
current_version,
latest_version: None,
update_available: false,
installer,
channel,
auto_update,
error: None,
};
};
match get_latest_version(inst, update_config).await {
Ok(latest_version) => {
let mut error = None;
// --check reports upgrades only; a rolled-back pointer isn't a "new version" to advertise here (auto-update converges separately).
let allow_downgrade = false;
let update_available =
match needs_update(&current_version, &latest_version, &channel, allow_downgrade) {
Some(value) => value,
None => {
// Distinguish parse failure from unsupported channel for clearer diagnostics.
let parse_ok = semver::Version::parse(&current_version).is_ok()
&& semver::Version::parse(&latest_version).is_ok();
error = Some(if parse_ok {
format!(
"Unsupported release channel '{}' (current={}, latest={}). \
Supported channels: stable, alpha, enterprise.",
channel, current_version, latest_version
)
} else {
format!(
"Failed to parse versions (current={}, latest={})",
current_version, latest_version
)
});
false
}
};
UpdateStatus {
current_version,
latest_version: Some(latest_version),
update_available,
installer,
channel,
auto_update,
error,
}
}
Err(err) => UpdateStatus {
current_version,
latest_version: None,
update_available: false,
installer,
channel,
auto_update,
error: Some(err.to_string()),
},
}
}
/// Installer + version the leader/background path should converge to: an
/// upgrade OR an authoritative-installer rollback. `None` means stay put. Gates
/// on the installer (via `installer_allows_downgrade`) so npm is never
/// downgraded — the decision depends on the installer, never the caller.
pub async fn auto_update_target(update_config: &UpdateConfig) -> Option<(&'static str, String)> {
let installer = get_installer().await?;
let current = get_installed_grok_version();
let latest = fetch_latest_version(installer, update_config).await.ok()?;
needs_update(
&current,
&latest,
&update_config.channel,
installer_allows_downgrade(installer),
)
.unwrap_or(false)
.then_some((installer, latest))
}
/// Outcome of [`ensure_latest_on_disk`].
#[derive(Debug)]
pub struct EnsureLatestOutcome {
/// Version this call downloaded and installed; `None` when the disk was
/// already current (or there was no installer).
pub installed: Option<String>,
/// The running process differs from what is now on disk in the channel's
/// update direction — the caller should relaunch onto the on-disk binary.
pub relaunch_needed: bool,
}
/// One leader auto-update pass: converge the on-disk install to the channel
/// pointer (downloading **only** when the disk is actually behind it), then
/// report whether the running process should relaunch onto the on-disk binary.
///
/// Unlike [`run_update`] this never uses the compiled-in version for the
/// download decision — a binary already installed by another process (TUI
/// background download, explicit `grok update`) is reused as-is. This both
/// removes the duplicate download in leader mode and stops the pre-fix
/// hourly re-download while a busy leader keeps deferring its relaunch.
///
/// When the disk version is unknowable ([`disk_version_for_installer`]:
/// npm-managed installs, Windows copy-based installs, dev builds), this
/// degrades to the pre-fix behavior — download when the *running* process is
/// stale, relaunch only after a download this pass actually installed
/// something. Note the Windows consequence: the hourly busy-leader
/// re-download is NOT fixed there; only the symlink layout can prove the
/// disk is current without exec'ing the binary.
pub async fn ensure_latest_on_disk(update_config: &UpdateConfig) -> Result<EnsureLatestOutcome> {
let mut outcome = EnsureLatestOutcome {
installed: None,
relaunch_needed: false,
};
let Some(installer) = get_installer().await else {
return Ok(outcome);
};
Synced from monorepo Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
2026-07-17 14:19:50 +01:00
heal_managed_install(installer).await;
let allow_downgrade = installer_allows_downgrade(installer);
let latest = fetch_latest_version(installer, update_config).await?;
let effective_current =
disk_version_for_installer(installer).unwrap_or_else(get_installed_grok_version);
if needs_update(
&effective_current,
&latest,
&update_config.channel,
allow_downgrade,
)
.unwrap_or(false)
{
run_install_script(installer, Some(&latest), update_config).await?;
outcome.installed = Some(latest.clone());
}
// Relaunch when the running binary differs from what's on disk in the
// channel's update direction — covers binaries installed by other
// processes, not just the install above.
let running = get_installed_grok_version();
if let Some(disk_now) =
disk_version_for_installer(installer).or_else(|| outcome.installed.clone())
{
outcome.relaunch_needed =
needs_update(&running, &disk_now, &update_config.channel, allow_downgrade)
.unwrap_or(false);
}
Ok(outcome)
}
/// Disk-version probe gated on the installer actually maintaining the
/// managed `~/.grok/bin/grok` symlink.
///
/// Only the internal (install.sh / CDN) and gh-release installers write that
/// symlink. npm manages its own global install, so for npm a symlink left
/// over from a previous internal install would LIE about the npm install's
/// version — in the worst direction, a leftover symlink "newer" than the npm
/// registry would make every updater report "already up to date" and
/// silently suppress npm updates forever. Unknown installers are treated
/// like npm (no trustworthy disk version).
fn disk_version_for_installer(installer: &str) -> Option<String> {
match installer {
"internal" | "gh-release" => crate::version::installed_on_disk_version(),
_ => None,
}
}
fn env_installer() -> Option<&'static str> {
if let Ok(v) = std::env::var("GROK_INSTALLER") {
return match v.to_ascii_lowercase().as_str() {
"npm" => Some("npm"),
"internal" => Some("internal"),
"gh-release" | "gh" => Some("gh-release"),
_ => None,
};
}
if std::env::var_os("GROK_MANAGED_BY_NPM").is_some() {
return Some("npm");
}
if std::env::var_os("GROK_MANAGED_BY_INTERNAL").is_some() {
return Some("internal");
}
if std::env::var_os("npm_config_user_agent").is_some() {
return Some("npm");
}
None
}
pub async fn get_installer() -> Option<&'static str> {
if let Some(i) = env_installer() {
return Some(i);
}
let cfg = config::load_config().await;
match cfg.cli.installer.as_deref() {
Some("npm") => Some("npm"),
Some("gh-release") => Some("gh-release"),
_ => Some("internal"),
}
}
fn needs_update(current: &str, target: &str, channel: &str, allow_downgrade: bool) -> Option<bool> {
let current = semver::Version::parse(current).ok()?;
let target = semver::Version::parse(target).ok()?;
match channel {
// NOTE: With the 0.2.X versioning scheme, all versions are plain
// semver (no pre-release suffix). The pre-release checks in this
// match are dead code but kept as a safety net.
"stable" | "enterprise" => {
if !target.pre.is_empty() {
tracing::warn!(
%current, %target,
channel = %channel,
"stable/enterprise channel received pre-release candidate, rejecting"
);
return Some(false);
}
if !current.pre.is_empty() {
return Some(true);
}
}
"alpha" => {}
_ => return None,
}
Some(if allow_downgrade {
target != current
} else {
target > current
})
}
/// Returns `true` for installer backends whose version source is authoritative
/// (managed by xAI directly), meaning a pointer rollback is intentional and
/// should trigger a client downgrade. Returns `false` for backends like npm
/// where stale corporate registries/proxies can return arbitrarily old versions.
///
/// Users who installed via `install.sh` are classified as `"internal"` by
/// `get_installer()`, so they also get rollback support.
fn installer_allows_downgrade(installer: &str) -> bool {
match installer {
"internal" | "gh-release" => true,
"npm" => false,
_ => false,
}
}
/// Result of a background update availability check.
#[derive(Debug, Clone)]
pub struct UpdateAvailable {
/// The latest version string (e.g. "0.1.200").
pub latest_version: String,
}
/// Outcome of [`check_update_background`].
pub struct BackgroundUpdateCheck {
/// `Some` when the *running* binary is older than the channel pointer —
/// drives the in-TUI restart hint regardless of who downloads the binary.
pub update: Option<UpdateAvailable>,
/// Handle to the background `grok update` child, `Some` only when a
/// download was actually started (the on-disk install was behind the
/// pointer). The TUI parks this and `wait()`s on it at quit-for-update
/// time instead of spawning a second downloader.
pub download: Option<tokio::process::Child>,
}
impl BackgroundUpdateCheck {
fn none() -> Self {
Self {
update: None,
download: None,
}
}
}
/// Check for available updates without blocking the TUI startup.
///
/// Sets [`BackgroundUpdateCheck::update`] when the running binary is older
/// than the channel pointer. If `auto_update` is enabled **and the on-disk
/// install is also behind the pointer**, kicks off a non-blocking download
/// (spawns `grok update` as a detached child process) so the new binary is
/// ready when the user quits and relaunches. When another process (an earlier
/// TUI, the leader's hourly checker) already put the target version on disk,
/// no download is started — only the restart hint is surfaced.
pub async fn check_update_background(update_config: &UpdateConfig) -> BackgroundUpdateCheck {
let Some(installer) = get_installer().await else {
return BackgroundUpdateCheck::none();
};
Synced from monorepo Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
2026-07-17 14:19:50 +01:00
heal_managed_install(installer).await;
if is_version_cache_fresh().await {
return BackgroundUpdateCheck::none();
}
let current_config = config::load_config().await;
if current_config.cli.auto_update == Some(false) {
return BackgroundUpdateCheck::none();
}
let current_version = get_installed_grok_version();
let latest_version = match fetch_latest_version(installer, update_config).await {
Ok(v) => v,
Err(_) => return BackgroundUpdateCheck::none(),
};
let allow_downgrade = installer_allows_downgrade(installer);
if !needs_update(
&current_version,
&latest_version,
&update_config.channel,
allow_downgrade,
)
.unwrap_or(false)
{
let stable_ptr = try_fetch_stable_pointer().await;
write_version_cache(&latest_version, stable_ptr.as_deref()).await;
return BackgroundUpdateCheck::none();
}
// Only download when the on-disk install is behind the pointer; the
// running process being stale (checked above) just means "show the
// restart hint". The quit-for-update path's `grok update` child resolves
// to "Already up to date" against the same disk state. Gated on the
// installer maintaining the managed symlink — for npm a leftover symlink
// would wrongly suppress the download (see `disk_version_for_installer`).
let disk_needs_download = match disk_version_for_installer(installer) {
Some(disk) => needs_update(
&disk,
&latest_version,
&update_config.channel,
allow_downgrade,
)
.unwrap_or(true),
None => true,
};
// Kick off a non-blocking download so the binary is ready when the
// user restarts (or accepts the in-TUI restart prompt).
let download = if disk_needs_download {
match run_update_subcommand(UpdateRunMode::NonBlocking).await {
Ok(child) => child,
Err(e) => {
tracing::warn!("Background update download failed to start: {e}");
None
}
}
} else {
tracing::info!(
latest_version = %latest_version,
"Background update: target already on disk, skipping download"
);
None
};
BackgroundUpdateCheck {
update: Some(UpdateAvailable { latest_version }),
download,
}
}
/// Returns Ok(true) if a blocking update ran; otherwise Ok(false).
pub async fn run_update_if_available(
run_mode: UpdateRunMode,
interactive: bool,
update_config: &UpdateConfig,
) -> Result<bool> {
Synced from monorepo Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
2026-07-17 14:19:50 +01:00
let Some(inst) = get_installer().await else {
// Skip update check if no known installer.
return Ok(false);
Synced from monorepo Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
2026-07-17 14:19:50 +01:00
};
heal_managed_install(inst).await;
if is_version_cache_fresh().await {
return Ok(false);
}
let current_config = config::load_config().await;
// Skip update check if auto-update is explicitly disabled.
if current_config.cli.auto_update == Some(false) {
return Ok(false);
}
// Resolve effective auto_update: None defaults to true (first-run).
let auto_update = current_config.cli.auto_update.unwrap_or(true);
if current_config.cli.auto_update.is_none()
&& let Err(e) = config::update_config(|st| {
if st.cli.auto_update.is_none() {
st.cli.auto_update = Some(true);
}
})
.await
{
tracing::warn!("Failed to save auto-update setting: {}", e);
}
let current_version = get_installed_grok_version();
// Fetch without writing version.json — we only cache after confirming the
// update is not needed or after a successful blocking install. This prevents
// a failed background download from suppressing retries for the TTL window.
let latest_version = match fetch_latest_version(inst, update_config).await {
Ok(v) => v,
Err(_) => return Ok(false),
};
if !needs_update(
&current_version,
&latest_version,
&update_config.channel,
installer_allows_downgrade(inst),
)
.unwrap_or(false)
{
let stable_ptr = try_fetch_stable_pointer().await;
write_version_cache(&latest_version, stable_ptr.as_deref()).await;
return Ok(false);
}
let channel_label = format!(" [{}]", update_config.channel);
if auto_update {
eprintln!(
"A new version of Grok Build is available: {} -> {}{}",
current_version, latest_version, channel_label
);
if interactive {
if let Err(e) = run_update_subcommand(run_mode).await {
eprintln!("Update failed: {}", e);
} else if matches!(run_mode, UpdateRunMode::Blocking) {
return Ok(true);
} else {
eprintln!("{}", MSG_AUTO_UPDATE_BACKGROUND);
return Ok(false);
}
} else if let Err(e) = run_update_subcommand(run_mode).await {
eprintln!("Update failed: {}", e);
} else if matches!(run_mode, UpdateRunMode::Blocking) {
return Ok(true);
}
return Ok(false);
} else {
if current_config
.cli
.dismissed_version
.as_deref()
.is_some_and(|v| v == latest_version)
{
return Ok(false);
}
eprintln!(
"A new version of Grok Build is available: {} -> {}{}",
current_version, latest_version, channel_label
);
if interactive {
eprintln!("{}", PROMPT_UPDATE_NOW);
let mut line = String::new();
if io::stdin().read_line(&mut line).is_ok() {
let ans = line.trim().to_ascii_lowercase();
if ans.is_empty() || ans == "y" || ans == "yes" {
if let Err(e) = run_update_subcommand(run_mode).await {
eprintln!("Update failed: {}", e);
} else if matches!(run_mode, UpdateRunMode::Blocking) {
return Ok(true);
} else {
eprintln!("{}", MSG_AUTO_UPDATE_BACKGROUND);
return Ok(false);
}
} else if ans == "d" || ans == "dismiss" {
let dismissed = latest_version.clone();
if let Err(e) = config::update_config(|st| {
st.cli.dismissed_version = Some(dismissed);
})
.await
{
tracing::warn!("Failed to save dismissed version: {}", e);
}
}
}
} else {
eprintln!("{}", MSG_RUN_UPDATE_MANUAL);
}
}
Ok(false)
}
/// Launch "grok update" in blocking or non-blocking mode.
///
/// In `NonBlocking` mode the spawned child's handle is returned so the caller
/// can later `wait()` on the in-flight download (e.g. the TUI's
/// quit-for-update path) instead of blind-spawning a second downloader.
/// Dropping the handle does not kill the child (`kill_on_drop` is off), so
/// callers that don't care can ignore it. `Blocking` mode returns `None`.
async fn run_update_subcommand(run_mode: UpdateRunMode) -> Result<Option<tokio::process::Child>> {
let exe = std::env::current_exe()?;
let mut cmd = tokio::process::Command::new(exe);
cmd.arg("update");
match run_mode {
UpdateRunMode::Blocking => {
// stderr must be null, not piped: `.status()` does not drain
// pipes, so if the child writes more than the OS pipe buffer
// (~16 KB macOS / ~64 KB Linux) to stderr (e.g. download
// progress bars), the child blocks on the write while the
// parent blocks on waitpid — deadlocking both processes.
// With `panic = "abort"`, the blocked child eventually
// receives SIGABRT.
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
// inherit, not piped: the TUI is already restored so the
// parent's stderr fd is a normal terminal. inherit lets
// the child's diagnostic output reach the user. piped +
// status() would immediately close the read end → EPIPE
// → panic → SIGABRT (signal 6) under panic=abort.
.stderr(Stdio::inherit());
// No detach: the child must stay in the foreground process group so Ctrl+C cancels it with the parent; the atomic install protocol makes mid-download kills safe.
let status = cmd.status().await?;
if !status.success() {
anyhow::bail!("grok update failed with {}", status);
}
Ok(None)
}
UpdateRunMode::NonBlocking => {
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
// Detach = new session (Ctrl+C isolation), not handle abandonment:
// the child is still ours to wait() on.
xai_grok_tools::util::detach_command(&mut cmd);
let child = cmd.spawn()?;
Ok(Some(child))
}
}
}
/// Resolve the grok binary path for re-execution after an update.
///
/// `current_exe()` resolves symlinks via `/proc/self/exe` (see proc(5)),
/// so it returns the old versioned target after a symlink swap.
/// Prefer `~/.grok/bin/grok` which always points to the latest version.
fn resolve_restart_exe() -> Result<std::path::PathBuf> {
let canonical = grok_application();
if canonical.exists() {
return Ok(canonical);
}
Ok(std::env::current_exe()?)
}
/// Restart grok with the original command-line arguments to pick up the update.
pub fn restart_grok() -> Result<()> {
let exe = resolve_restart_exe()?;
let mut cmd = Command::new(exe);
for arg in std::env::args_os().skip(1) {
cmd.arg(arg);
}
cmd.env_clear();
cmd.envs(std::env::vars_os().filter(|(k, _)| k != "GROK_AUTO_UPDATE"));
eprintln!("Restarting Grok...");
// Use exec on Unix to replace the current process, avoiding stdio issues
// when the parent exits. On Windows, fall back to spawn + exit.
#[cfg(unix)]
{
// Flush output before exec to ensure messages are visible
let _ = io::stdout().flush();
let _ = io::stderr().flush();
let err = cmd.exec();
// exec only returns if there was an error
anyhow::bail!("Failed to exec: {}", err);
}
#[cfg(not(unix))]
{
// Flush output before exit to ensure messages are visible
let _ = io::stdout().flush();
let _ = io::stderr().flush();
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
let _ = cmd.spawn()?;
std::process::exit(0);
}
}
pub async fn run_install_script(
installer: &str,
target: Option<&str>,
update_config: &UpdateConfig,
) -> Result<()> {
let result = match installer {
"npm" => install_npm(
target,
&update_config.channel,
update_config.npm_registry.as_deref(),
),
"gh-release" => install_gh_release(target).await,
_ => install_internal(target, update_config).await,
};
if result.is_ok() {
remove_stale_models_cache().await;
}
result.map_err(|e| {
anyhow::anyhow!(
"Auto-update failed: {:#}\n\n{}",
e,
reinstall_hint(installer)
)
})
}
/// Detect the current platform (os, arch) for binary downloads.
pub(crate) fn detect_platform() -> Result<(&'static str, &'static str)> {
let os = if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "windows") {
"windows"
} else {
anyhow::bail!("Unsupported OS");
};
let arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
anyhow::bail!("Unsupported architecture");
};
Ok((os, arch))
}
/// Age past which a leftover `.tmp` download file (or a freshly-renamed
/// versioned binary) is considered abandoned (crashed/killed updater) and
/// safe for `cleanup_old_downloads` to sweep. Generous compared to the
/// longest plausible download (per-request budget is
/// [`DOWNLOAD_REQUEST_TIMEOUT`]; the leader check+download pass matches) so
/// a concurrent updater's in-flight or just-landed file is never deleted
/// out from under it.
const STALE_TMP_AGE: Duration = Duration::from_secs(60 * 60);
/// Total timeout for a CLI artifact download request (including body).
/// Previously 5 minutes, which was too tight on slow links and caused the
/// transfer to abort and restart from zero repeatedly.
const DOWNLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(20 * 60);
/// Unique temp path for an in-flight download of `dest`.
///
/// Appends `.{pid}-{seq}.tmp` to the FULL file name instead of using
/// `Path::with_extension`, which treats everything after the last dot of the
/// versioned name as the extension (`grok-0.1.181-linux-x86_64` →
/// `grok-0.1.tmp`) and therefore collides for every `0.1.x` version. The PID
/// plus a per-process counter makes the name unique per download attempt —
/// across processes (two updaters racing in the same instant, the accepted
/// lock-free residual race) and within one process — so no racer can ever
/// rename another's half-written temp file into place. Leftovers older than
/// [`STALE_TMP_AGE`] are swept by `cleanup_old_downloads`.
fn tmp_download_path(dest: &std::path::Path) -> std::path::PathBuf {
unique_temp_sibling(dest, "tmp")
}
/// Unique temp path `<base>.{pid}-{seq}.{ext}`, appended to the full name so a
/// versioned base like `grok-0.1.181` doesn't collide via `with_extension`.
/// PID + per-process counter keep racing updaters from clobbering each other.
fn unique_temp_sibling(base: &std::path::Path, ext: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let mut name = base
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(format!(
".{}-{}.{ext}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
base.with_file_name(name)
}
/// Set `+x` on the temp file before renaming onto `dest`, so a concurrent
/// same-version installer never execs `dest` while it is still 0644.
async fn publish_downloaded_artifact(tmp: &std::path::Path, dest: &std::path::Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(tmp, std::fs::Permissions::from_mode(0o755)).await?;
}
tokio::fs::rename(tmp, dest).await?;
Ok(())
}
/// Files smaller than this are not worth fragmenting across parallel chunks.
const PARALLEL_DOWNLOAD_MIN_BYTES: u64 = 16 * 1024 * 1024;
/// Pick chunk count from file size: 1 chunk per 16 MiB, capped at 8.
fn parallel_chunk_count(size: u64) -> u64 {
let size_mb = size / (1024 * 1024);
(size_mb / 16).clamp(1, 8)
}
/// Try a parallel byte-range download to `dest`. Returns Err if the server
/// doesn't advertise a Content-Length, the file is too small to be worth
/// splitting, the range request is rejected, or any chunk transfer fails.
/// The caller is expected to fall back to a single-connection download on Err.
async fn try_parallel_download(
url: &str,
dest: &std::path::Path,
with_progress: bool,
) -> Result<()> {
let client = reqwest::Client::builder()
.timeout(DOWNLOAD_REQUEST_TIMEOUT)
.build()?;
let head = client.head(url).send().await?;
if !head.status().is_success() {
anyhow::bail!("HEAD failed: HTTP {}", head.status());
}
let size = head
.content_length()
.ok_or_else(|| anyhow::anyhow!("response missing Content-Length"))?;
if size < PARALLEL_DOWNLOAD_MIN_BYTES {
anyhow::bail!("file too small for parallel download ({} bytes)", size);
}
let n_chunks = parallel_chunk_count(size);
if n_chunks < 2 {
anyhow::bail!(
"file size yields {} chunk(s); not worth parallelizing",
n_chunks
);
}
let chunk_size = size.div_ceil(n_chunks);
let pb = if with_progress {
let pb = ProgressBar::new(size);
pb.set_style(
ProgressStyle::default_bar()
.template(" {bar:30.cyan/dim} {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("━╸─"),
);
Some(pb)
} else {
None
};
let tmp = tmp_download_path(dest);
// Pre-allocate so each task can seek+write to its own range concurrently.
// One blocking-pool hop instead of two per tokio::fs call.
let tmp_for_alloc = tmp.clone();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
let f = std::fs::File::create(&tmp_for_alloc)?;
f.set_len(size)?;
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("blocking pre-allocate task panicked: {e}"))??;
let tasks = (0..n_chunks).map(|i| {
let start = i * chunk_size;
let end = std::cmp::min(start + chunk_size, size) - 1;
let url = url.to_string();
let tmp = tmp.clone();
let client = client.clone();
let pb = pb.clone();
async move { download_range(&client, &url, &tmp, start, end, pb.as_ref()).await }
});
let result = futures::future::try_join_all(tasks).await;
if let Some(pb) = &pb {
pb.finish_and_clear();
}
match result {
Ok(_) => {
publish_downloaded_artifact(&tmp, dest).await?;
Ok(())
}
Err(e) => {
let _ = tokio::fs::remove_file(&tmp).await;
Err(e)
}
}
}
/// Fetch bytes `[start, end]` (inclusive) of `url` and write them at `start`
/// in `dest`. Errors if the server doesn't return `206 Partial Content`.
///
/// Streams from the network into a `Vec<u8>` (so progress ticks smoothly as
/// bytes arrive), then issues a single `spawn_blocking` per chunk to do the
/// open + seek + write_all in `std::fs`. This avoids the per-write hop into
/// tokio's blocking pool that `tokio::fs::File::write_all` performs on every
/// ~8 KiB Bytes item from `bytes_stream()`.
async fn download_range(
client: &reqwest::Client,
url: &str,
dest: &std::path::Path,
start: u64,
end: u64,
progress: Option<&ProgressBar>,
) -> Result<()> {
let resp = client
.get(url)
.header("Range", format!("bytes={}-{}", start, end))
.send()
.await?;
if resp.status() != reqwest::StatusCode::PARTIAL_CONTENT {
anyhow::bail!("range request rejected: HTTP {}", resp.status());
}
let mut buf = Vec::with_capacity((end - start + 1) as usize);
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if let Some(pb) = progress {
pb.inc(chunk.len() as u64);
}
buf.extend_from_slice(&chunk);
}
let dest = dest.to_owned();
tokio::task::spawn_blocking(move || -> std::io::Result<()> {
use std::io::{Seek, SeekFrom, Write};
let mut f = std::fs::OpenOptions::new().write(true).open(&dest)?;
f.seek(SeekFrom::Start(start))?;
f.write_all(&buf)?;
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("blocking write task panicked: {e}"))??;
Ok(())
}
/// Download a file from `url` to `dest` with a terminal progress bar.
///
/// If the server provides a `Content-Length` header, a determinate bar is shown
/// with bytes downloaded, total size, and ETA. Otherwise a spinner with a byte
/// counter is used as a fallback.
#[doc(hidden)]
pub async fn download_with_progress(url: &str, dest: &std::path::Path) -> Result<()> {
// Try parallel byte-range first. Falls through to single-connection on any
// failure (HEAD missing Content-Length, ranges rejected, partial-fetch error).
match try_parallel_download(url, dest, true).await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::debug!("parallel download failed, falling back to single connection: {e}")
}
}
let client = reqwest::Client::builder()
.timeout(DOWNLOAD_REQUEST_TIMEOUT)
.build()?;
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Download failed: HTTP {}", resp.status());
}
let total_size = resp.content_length();
let pb = if let Some(size) = total_size {
let pb = ProgressBar::new(size);
pb.set_style(
ProgressStyle::default_bar()
.template(" {bar:30.cyan/dim} {bytes}/{total_bytes} ({eta})")
.unwrap()
.progress_chars("━╸─"),
);
pb
} else {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template(" {spinner:.cyan} {bytes} downloaded")
.unwrap(),
);
pb.enable_steady_tick(Duration::from_millis(100));
pb
};
// Stream to a temp file, then rename atomically
let tmp = tmp_download_path(dest);
let mut file = tokio::fs::File::create(&tmp).await?;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
pb.inc(chunk.len() as u64);
}
file.flush().await?;
drop(file);
pb.finish_and_clear();
publish_downloaded_artifact(&tmp, dest).await?;
Ok(())
}
/// Download a file silently (no progress bar).
#[doc(hidden)]
pub async fn download_silent(url: &str, dest: &std::path::Path) -> Result<()> {
match try_parallel_download(url, dest, false).await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::debug!("parallel download failed, falling back to single connection: {e}")
}
}
let client = reqwest::Client::builder()
.timeout(DOWNLOAD_REQUEST_TIMEOUT)
.build()?;
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Download failed: HTTP {}", resp.status());
}
let tmp = tmp_download_path(dest);
let mut file = tokio::fs::File::create(&tmp).await?;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
}
file.flush().await?;
drop(file);
publish_downloaded_artifact(&tmp, dest).await?;
Ok(())
}
/// Delete `~/.grok/models_cache.json` after a successful update.
///
/// The cache embeds the binary version and will be treated as a miss by the
/// new binary anyway, but removing it eagerly avoids a wasted disk read +
/// deserialize on first launch.
async fn remove_stale_models_cache() {
let cache = grok_home().join("models_cache.json");
match tokio::fs::remove_file(&cache).await {
Ok(()) => tracing::debug!("removed stale models_cache.json after update"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::debug!("failed to remove stale models cache: {e}"),
}
}
/// Remove the stale `grok-pager` symlink/binary from `~/.grok/bin/` left by
/// older installations that shipped a separate pager binary.
async fn remove_stale_pager(bin_dir: &std::path::Path) {
let name = if cfg!(windows) {
"grok-pager.exe"
} else {
"grok-pager"
};
let link = bin_dir.join(name);
if link.exists() || link.is_symlink() {
let _ = tokio::fs::remove_file(&link).await;
}
}
/// Fetch a CLI object from GCS. On Windows the public bucket may use a `.exe`
/// suffix; try that first, then the extensionless name used on macOS/Linux.
async fn download_cli_artifact_from_gcs(
gcs_base_url: &str,
object_name: &str,
dest: &std::path::Path,
with_progress: bool,
) -> Result<()> {
let base = gcs_base_url.trim_end_matches('/');
#[cfg(windows)]
{
let with_exe = format!("{}/{}.exe", base, object_name);
let r = if with_progress {
download_with_progress(&with_exe, dest).await
} else {
download_silent(&with_exe, dest).await
};
match r {
Ok(()) => return Ok(()),
Err(e) => tracing::debug!("{with_exe} not found, trying extensionless: {e}"),
}
}
let url = format!("{}/{}", base, object_name);
if with_progress {
download_with_progress(&url, dest).await
} else {
download_silent(&url, dest).await
}
}
async fn install_internal(target: Option<&str>, update_config: &UpdateConfig) -> Result<()> {
install_internal_from_bases(target, update_config, crate::version::CLI_BASE_URLS).await
}
/// Try the base-dependent install phase ([`download_verified_from_base`]:
/// version resolution, download, smoke test) against each base URL in turn,
/// falling through to the next on any failure. Used to keep installs working
/// when the primary CDN endpoint (Cloudflare) is unreachable but the fallback
/// (direct GCS) still resolves.
///
/// Download-phase side effects (download dir creation, binary fetch) are
/// idempotent, so retrying with a different base after a partial failure is
/// safe. Local activation ([`activate_verified_download`]: link swap,
/// cleanup, config persist) runs once after the first successful download —
/// its failures are not base-dependent, so they abort the install instead of
/// triggering a pointless re-download from the next base.
#[doc(hidden)]
pub async fn install_internal_from_bases(
target: Option<&str>,
update_config: &UpdateConfig,
bases: &[&str],
) -> Result<()> {
let mut last_err: Option<anyhow::Error> = None;
for (i, base) in bases.iter().enumerate() {
match download_verified_from_base(target, update_config, base).await {
Ok(download) => return activate_verified_download(&download).await,
Err(e) => {
if i + 1 < bases.len() {
tracing::warn!(
"install via {} failed ({:#}); trying next base URL",
base,
e
);
}
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no CLI base URLs to try")))
}
const SMOKE_TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
async fn smoke_test_binary(binary_path: &std::path::Path) -> bool {
let mut cmd = tokio::process::Command::new(binary_path);
cmd.arg("--version")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
xai_grok_tools::util::detach_command(&mut cmd);
match tokio::time::timeout(SMOKE_TEST_TIMEOUT, cmd.status()).await {
Ok(Ok(status)) => status.success(),
_ => false,
}
}
/// Test-only entry point: same as [`install_internal`] but reads from
/// `gcs_base_url` instead of the hardcoded GCS bucket. Persists installer
/// config and writes to `~/.grok/bin/`, so callers must isolate
/// `GROK_HOME`.
#[doc(hidden)]
pub async fn install_internal_from_base(
target: Option<&str>,
update_config: &UpdateConfig,
gcs_base_url: &str,
) -> Result<()> {
let download = download_verified_from_base(target, update_config, gcs_base_url).await?;
activate_verified_download(&download).await
}
/// A downloaded and smoke-tested binary in `~/.grok/downloads/`, not yet
/// activated as the managed `grok`/`agent`.
struct VerifiedDownload {
version: String,
binary_path: std::path::PathBuf,
}
/// Base-dependent install phase: resolve the version (per base when no
/// target is pinned), download the binary, and smoke-test it. Failures here
/// are worth retrying against another base URL.
async fn download_verified_from_base(
target: Option<&str>,
update_config: &UpdateConfig,
gcs_base_url: &str,
) -> Result<VerifiedDownload> {
let (os, arch) = detect_platform()?;
let platform = format!("{}-{}", os, arch);
let version = match target {
Some(v) => {
semver::Version::parse(v)
.map_err(|_| anyhow::anyhow!("invalid version format: '{}'", v))?;
v.to_string()
}
None => {
crate::version::fetch_gcs_version_from_base(&update_config.channel, gcs_base_url)
.await?
}
};
let grok_home = grok_home();
let download_dir = grok_home.join("downloads");
tokio::fs::create_dir_all(&download_dir).await?;
let binary_name = format!("grok-{}-{}", version, platform);
let binary_path = download_dir.join(&binary_name);
eprintln!(" Downloading grok v{} ({})...", version, platform);
// Published already +x (see `publish_downloaded_artifact`).
download_cli_artifact_from_gcs(gcs_base_url, &binary_name, &binary_path, true).await?;
// Smoke-test: run the binary before activating it. A truncated or
// corrupt download is caught here and never becomes the active grok.
if !smoke_test_binary(&binary_path).await {
let _ = tokio::fs::remove_file(&binary_path).await;
// No prefix: run_install_script's wrap adds "Auto-update failed:".
anyhow::bail!(
"downloaded binary failed to run.\n\
Your current version is unchanged.\n\
To update manually: {}",
manual_install_cmd()
);
}
Ok(VerifiedDownload {
version,
binary_path,
})
}
/// Local activation phase: swap the managed bin links to the downloaded
/// binary and finish bookkeeping. Nothing here depends on which base URL
/// served the download, so callers must not retry another base on failure.
async fn activate_verified_download(download: &VerifiedDownload) -> Result<()> {
let grok_home = grok_home();
let download_dir = grok_home.join("downloads");
let bin_dir = grok_home.join("bin");
tokio::fs::create_dir_all(&bin_dir).await?;
// Atomic swap of ~/.grok/bin/{grok,agent} -> downloaded binary.
let link_path = swap_managed_bin_links(&download.binary_path, &bin_dir).await?;
remove_stale_pager(&bin_dir).await;
eprintln!();
// Clean up old versioned binaries (keeps current + 1 previous).
cleanup_old_downloads(&download_dir, "grok", &download.version).await;
cleanup_old_downloads(&download_dir, "grok-pager", &download.version).await;
// Persist installer to config.toml so future runs auto-detect internal.
let _ = config::update_config(|st| {
st.cli.installer = Some("internal".to_string());
})
.await;
// Regenerate shell completions so they reflect the new binary's CLI surface.
// Best-effort: failures are silently ignored (same as the installer).
regenerate_completions(&link_path, &grok_home).await;
Ok(())
}
/// Regenerate shell completions after a binary update (best-effort).
///
/// Spawns the newly-installed binary with `completions <shell>` for each
/// supported shell and writes the output to the standard completion paths.
/// Failures are silently ignored — completions are a nice-to-have, not a
/// requirement for a successful update.
async fn regenerate_completions(binary: &std::path::Path, grok_home: &std::path::Path) {
// Derive $HOME independently — grok_home may be overridden via GROK_HOME
// env var, so grok_home.parent() isn't necessarily the user's home dir.
#[allow(deprecated)]
let user_home = std::env::home_dir().unwrap_or_default();
let completions: &[(&str, std::path::PathBuf)] = &[
("bash", grok_home.join("completions/bash/grok.bash")),
("zsh", grok_home.join("completions/zsh/_grok")),
("fish", user_home.join(".config/fish/completions/grok.fish")),
];
for (shell, dest) in completions {
if let Some(parent) = dest.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
let mut cmd = tokio::process::Command::new(binary);
cmd.args(["completions", shell])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
xai_grok_tools::util::detach_command(&mut cmd);
let Ok(output) = cmd.output().await else {
continue;
};
if output.status.success() && !output.stdout.is_empty() {
let _ = tokio::fs::write(dest, &output.stdout).await;
}
}
}
/// Compute a relative symlink target from `link` to `target`.
///
/// When both paths share a grandparent (e.g. `~/.grok/bin/grok` and
/// `~/.grok/downloads/grok-0.1.203-linux-x86_64`), returns a relative path
/// like `../downloads/grok-0.1.203-linux-x86_64`. When they share the same
/// parent directory, returns just the filename. Falls back to the absolute
/// `target` path for any other layout.
///
/// Relative symlinks survive Docker bind-mounts where `~/.grok/` is mapped
/// into a container with a different `$HOME` (and thus a different absolute
/// prefix).
#[cfg(unix)]
fn relative_symlink_target(target: &std::path::Path, link: &std::path::Path) -> std::path::PathBuf {
let (Some(target_parent), Some(link_parent)) = (target.parent(), link.parent()) else {
return target.to_path_buf();
};
// Same directory — just the filename (e.g. grok-latest -> grok-0.1.203-…)
if target_parent == link_parent
&& let Some(name) = target.file_name()
{
return std::path::PathBuf::from(name);
}
// Sibling directories — ../target_dir/filename (e.g. bin/grok -> ../downloads/grok-…)
if let (Some(tp), Some(lp)) = (target_parent.parent(), link_parent.parent())
&& tp == lp
&& let (Some(dir_name), Some(file_name)) = (target_parent.file_name(), target.file_name())
{
return std::path::Path::new("..").join(dir_name).join(file_name);
}
target.to_path_buf()
}
/// Swap `~/.grok/bin/{grok,agent}` to point at `binary_path`. Returns the
/// `grok` link path (for [`regenerate_completions`]).
///
/// `grok` and `agent` are first-class entry points that the bootstrap
/// installers (`install.sh`, `install.ps1`, `install-enterprise.sh`)
/// maintain in lockstep, and so must the updater — otherwise `grok update`
/// leaves `agent` pinned at the previous version.
///
/// Unix: atomic symlink swap with relative target (survives Docker
/// bind-mounts of `~/.grok/`). Windows: [`windows_replace_exe`].
///
/// **All-or-nothing.** Each link's prior state is captured (Unix: prior
/// symlink target; Windows: `.rollback.bak`; or `Absent` marker via
/// `symlink_metadata`) before the swap, and any earlier successful swaps
/// are rolled back if a later one fails — including *removing* a link that
/// didn't exist before. Restore failures go to `tracing::warn!`; the swap
/// error itself propagates unwrapped so the caller's `reinstall_hint` wrap
/// stays the user-visible message.
async fn swap_managed_bin_links(
binary_path: &std::path::Path,
bin_dir: &std::path::Path,
) -> Result<std::path::PathBuf> {
let grok_name = if cfg!(windows) { "grok.exe" } else { "grok" };
let agent_name = if cfg!(windows) { "agent.exe" } else { "agent" };
let grok_link = bin_dir.join(grok_name);
let agent_link = bin_dir.join(agent_name);
let link_paths: [std::path::PathBuf; 2] = [grok_link.clone(), agent_link];
// Capture every link up-front so a 2nd-link capture failure can't
// strand the 1st mid-swap.
let mut captured: Vec<LinkRollback> = Vec::with_capacity(link_paths.len());
for path in &link_paths {
match LinkRollback::capture(path).await {
Ok(rb) => captured.push(rb),
Err(e) => {
// Nothing swapped yet; drop any Windows .rollback.bak files.
for prior in &captured {
prior.cleanup().await;
}
return Err(e)
.with_context(|| format!("capturing rollback state for {}", path.display()));
}
}
}
let mut completed: Vec<&LinkRollback> = Vec::with_capacity(captured.len());
for (i, (link_path, rollback)) in link_paths.iter().zip(captured.iter()).enumerate() {
#[cfg(unix)]
let swap_result = {
let rel_target = relative_symlink_target(binary_path, link_path);
atomic_symlink_swap(&rel_target, link_path).await
};
#[cfg(windows)]
let swap_result = windows_replace_exe(binary_path, link_path).await;
#[cfg(not(any(unix, windows)))]
let swap_result: Result<()> = {
// No managed bin layout on this target; no-op.
let _ = (binary_path, link_path);
Ok(())
};
match swap_result {
Ok(()) => completed.push(rollback),
Err(e) => {
// Restore each successful swap in reverse. On restore
// failure keep the .rollback.bak as a recovery artifact
// (Windows only) and warn!; the swap error propagates so
// `reinstall_hint` is the user-visible message.
for prior in completed.iter().rev() {
if let Err(restore_err) = prior.restore().await {
let backup_note = prior.backup_path().map_or(String::new(), |p| {
format!(" (prior binary preserved at {})", p.display())
});
tracing::warn!(
"failed to roll back managed bin link {}: {restore_err:#}{backup_note}",
prior.link_path().display(),
);
continue;
}
prior.cleanup().await;
}
// Failed swap had no active state to restore; drop its backup.
rollback.cleanup().await;
// Drop backups for never-attempted later captures (Windows orphans).
for later in &captured[i + 1..] {
later.cleanup().await;
}
return Err(e);
}
}
}
for cap in &captured {
cap.cleanup().await;
}
Ok(grok_link)
}
/// Snapshot of a managed-bin link's prior state for rollback in
/// [`swap_managed_bin_links`]. `Absent` vs `Present` is discriminated up
/// front via `symlink_metadata` so capture errors never get misread as
/// "link was absent".
enum LinkRollback {
/// Link was absent before the swap; rollback removes the one we created.
Absent { link_path: std::path::PathBuf },
/// Link existed before the swap; rollback restores its prior contents.
Present {
link_path: std::path::PathBuf,
/// Unix: prior symlink target (relative or absolute).
#[cfg(unix)]
prior_target: std::path::PathBuf,
/// Windows: `.rollback.bak` copy of the previous binary.
#[cfg(windows)]
backup_path: std::path::PathBuf,
},
}
impl LinkRollback {
async fn capture(link_path: &std::path::Path) -> Result<Self> {
let lp = link_path.to_path_buf();
// `symlink_metadata` (lstat) handles valid symlinks, broken
// symlinks, and regular files alike. Any IO error other than
// NotFound aborts the swap before mutation.
match tokio::fs::symlink_metadata(&lp).await {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(LinkRollback::Absent { link_path: lp });
}
Err(e) => {
return Err(e).with_context(|| format!("stat {} before swap", lp.display()));
}
}
#[cfg(unix)]
{
let prior_target = tokio::fs::read_link(&lp)
.await
.with_context(|| format!("reading prior symlink target {}", lp.display()))?;
Ok(LinkRollback::Present {
link_path: lp,
prior_target,
})
}
#[cfg(windows)]
{
// Per-process+sequence backup name via `unique_temp_sibling`
// so concurrent updaters can't clobber each other's backups.
let backup_path = unique_temp_sibling(&lp, "rollback.bak");
tokio::fs::copy(&lp, &backup_path).await.with_context(|| {
format!(
"backing up {} to {} before swap",
lp.display(),
backup_path.display(),
)
})?;
Ok(LinkRollback::Present {
link_path: lp,
backup_path,
})
}
}
fn link_path(&self) -> &std::path::Path {
match self {
LinkRollback::Absent { link_path } => link_path,
LinkRollback::Present { link_path, .. } => link_path,
}
}
/// Path to the on-disk backup (Windows only — Unix is in-memory).
#[cfg(windows)]
fn backup_path(&self) -> Option<&std::path::Path> {
match self {
LinkRollback::Present { backup_path, .. } => Some(backup_path),
LinkRollback::Absent { .. } => None,
}
}
#[cfg(unix)]
fn backup_path(&self) -> Option<&std::path::Path> {
None
}
async fn restore(&self) -> Result<()> {
match self {
LinkRollback::Absent { link_path } => {
// Remove the link we created. NotFound (someone else
// cleaned up) is fine; anything else is a real failure.
match tokio::fs::remove_file(link_path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e).with_context(|| {
format!("removing rolled-back link {}", link_path.display())
}),
}
}
#[cfg(unix)]
LinkRollback::Present {
link_path,
prior_target,
} => atomic_symlink_swap(prior_target, link_path)
.await
.with_context(|| {
format!("restoring prior symlink target for {}", link_path.display())
}),
#[cfg(windows)]
LinkRollback::Present {
link_path,
backup_path,
} => {
// Route through `windows_replace_exe` so rollback inherits
// the same ERROR_SHARING_VIOLATION rename-aside fallback
// as the forward path.
windows_replace_exe(backup_path, link_path)
.await
.with_context(|| {
format!(
"restoring {} from {}",
link_path.display(),
backup_path.display()
)
})
}
}
}
async fn cleanup(&self) {
#[cfg(windows)]
if let LinkRollback::Present { backup_path, .. } = self {
let _ = tokio::fs::remove_file(backup_path).await;
}
#[cfg(unix)]
let _ = self; // no on-disk backup on Unix
}
}
/// Atomically swap a symlink to point to a new target.
///
/// Creates a temporary symlink next to `link_path`, then renames it over the
/// old symlink. This avoids the remove-then-create race where the path
/// briefly doesn't exist, and — crucially — never deletes the old target
/// file. On macOS (especially Apple Silicon), deleting a binary that a
/// running process has mmap'd causes SIGKILL because the kernel can no longer
/// verify the code signature of the executable pages.
#[cfg(unix)]
async fn atomic_symlink_swap(target: &std::path::Path, link_path: &std::path::Path) -> Result<()> {
// Per-racer temp name: a shared one makes remove_file → symlink racy
// (EEXIST, or ENOENT when another racer renames the link away).
sweep_stale_tmp_links(link_path, STALE_TMP_AGE).await;
let tmp_link = unique_temp_sibling(link_path, "tmp-link");
let _ = tokio::fs::remove_file(&tmp_link).await;
tokio::fs::symlink(target, &tmp_link).await?;
tokio::fs::rename(&tmp_link, link_path).await?;
Ok(())
}
/// Remove `<link>.*.tmp-link` siblings left by a swap that crashed between
/// symlink and rename. Only those older than `max_age` are removed, so a
/// concurrent racer's in-flight link is never deleted out from under it.
#[cfg(unix)]
async fn sweep_stale_tmp_links(link_path: &std::path::Path, max_age: Duration) {
let (Some(dir), Some(name)) = (
link_path.parent(),
link_path.file_name().and_then(|n| n.to_str()),
) else {
return;
};
let prefix = format!("{name}.");
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let fname = entry.file_name();
let Some(fname) = fname.to_str() else {
continue;
};
if !fname.starts_with(&prefix) || !fname.ends_with(".tmp-link") {
continue;
}
let stale = tokio::fs::symlink_metadata(entry.path())
.await
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
.is_some_and(|age| age > max_age);
if stale {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
/// Replace an executable that may be locked by a running process (Windows).
///
/// On Windows the kernel prevents writes to a running executable but allows
/// renames. If a direct copy fails with a sharing violation, this renames
/// `dest` aside and copies `src` into the freed path. If the copy then
/// fails, the rename is rolled back to avoid a broken install.
///
/// The aside target is normally `<dest>.old`, but a leftover `.old` can
/// itself still be a running image (the session that was live during the
/// previous update keeps executing the renamed-aside file), and a running
/// image can neither be deleted nor rename-replaced. In that case `dest` is
/// renamed to a unique `<dest>.old.{pid}-{seq}.old` sibling instead, so a
/// locked leftover can never block the update. All `.old` leftovers are
/// swept best-effort at the start of each cycle; still-locked ones survive
/// until a later update runs after those processes exit.
#[cfg(windows)]
async fn windows_replace_exe(src: &std::path::Path, dest: &std::path::Path) -> Result<()> {
let file_name = dest
.file_name()
.ok_or_else(|| anyhow::anyhow!("destination has no filename: {}", dest.display()))?
.to_string_lossy();
let old = dest.with_file_name(format!("{file_name}.old"));
sweep_old_exe_backups(&old).await;
match tokio::fs::copy(src, dest).await {
Ok(_) => return Ok(()),
// ERROR_SHARING_VIOLATION (32) / ERROR_ACCESS_DENIED (5): exe is
// locked by a running process. Fall through to rename-and-replace.
Err(e) if matches!(e.raw_os_error(), Some(32) | Some(5)) => {
tracing::debug!("exe locked, falling back to rename: {e}");
}
Err(e) => return Err(e.into()),
}
// A .old that survived the sweep is locked; renaming onto it would need
// to delete-replace it and fail, so divert to a guaranteed-free name.
let old_is_free = matches!(
tokio::fs::symlink_metadata(&old).await,
Err(e) if e.kind() == std::io::ErrorKind::NotFound
);
let mut aside = if old_is_free {
old.clone()
} else {
let diverted = unique_temp_sibling(&old, "old");
tracing::debug!(
"stale {} is locked; diverting aside to {}",
old.display(),
diverted.display()
);
diverted
};
// Move the locked file aside, then copy the new binary into place.
let mut rename_result = tokio::fs::rename(dest, &aside).await;
// Pid reuse can collide a diverted name with a dead updater's
// still-locked leftover, and a racer can occupy a just-checked-free
// .old; a fresh unique sibling clears both tails (3 attempts total).
for _ in 0..2 {
match &rename_result {
Err(e) if matches!(e.raw_os_error(), Some(32) | Some(5)) => {
tracing::debug!(
"rename aside to {} failed; retrying with a fresh name: {e}",
aside.display()
);
aside = unique_temp_sibling(&old, "old");
rename_result = tokio::fs::rename(dest, &aside).await;
}
_ => break,
}
}
rename_result.map_err(|e| {
anyhow::anyhow!(
"cannot rename locked executable {}: {e}\n\
Close all running grok sessions and retry.",
dest.display(),
)
})?;
match tokio::fs::copy(src, dest).await {
Ok(_) => Ok(()),
Err(e) => {
// Rollback: restore the old binary so the install isn't broken.
let _ = tokio::fs::rename(&aside, dest).await;
Err(e.into())
}
}
}
/// Best-effort removal of `<exe>.old` plus the unique
/// `<exe>.old.{pid}-{seq}.old` asides accumulated by prior update cycles.
/// Locked ones (still-running images) survive and are collected by a later
/// update once those processes exit. The `<exe>.old` prefix keeps the sweep
/// away from `<exe>` itself, other executables' leftovers, and the
/// `.rollback.bak` / `.tmp` sibling shapes.
///
/// Unlike `sweep_stale_tmp_links` there is deliberately no `max_age` gate:
/// rename preserves mtime, so a racer's seconds-old aside already looks
/// days old and age cannot distinguish it; in-use asides survive deletion
/// by being locked; and deleting a racer's fresh unlocked aside (its
/// rollback source while both racers converge on the same dest) is the
/// accepted lock-free residual race (see `tmp_download_path`).
#[cfg(windows)]
async fn sweep_old_exe_backups(old: &std::path::Path) {
let _ = tokio::fs::remove_file(old).await;
let (Some(dir), Some(old_name)) = (old.parent(), old.file_name().and_then(|n| n.to_str()))
else {
return;
};
let prefix = format!("{old_name}.");
let Ok(mut entries) = tokio::fs::read_dir(dir).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if name.starts_with(&prefix) && name.ends_with(".old") {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
/// Best-effort cleanup of old versioned binaries for a given binary name.
///
/// Mirrors the npm `cleanupOldVersions()` policy: keeps the current version
/// plus one previous version (in case a process is still running the old binary
/// and hasn't fully loaded all pages yet — deleting it on macOS causes SIGKILL
/// because the kernel can no longer verify the code signature).
///
/// `bin_prefix` is the binary name prefix, e.g. `"grok"` or `"grok-pager"`.
/// Files must match `{bin_prefix}-{digit}*` to be considered versioned binaries
/// (this avoids `grok-*` matching `grok-pager-*` or `grok-latest`).
///
/// Temporary/partial files (containing `.tmp`) are deleted only once they
/// are **stale** (mtime older than [`STALE_TMP_AGE`]). A fresh `.tmp` may be
/// a concurrent updater's in-flight download — the same-instant race the
/// lock-free design accepts — and deleting it out from under that updater
/// would make its atomic rename fail.
async fn cleanup_old_downloads(dir: &std::path::Path, bin_prefix: &str, current_version: &str) {
let prefix = format!("{}-", bin_prefix);
let current_semver = match semver::Version::parse(current_version) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
"cleanup_old_downloads: invalid current version '{}': {}",
current_version,
e
);
return;
}
};
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(rd) => rd,
Err(e) => {
tracing::warn!(
"cleanup_old_downloads: failed to read {}: {}",
dir.display(),
e
);
return;
}
};
let mut versioned: Vec<(semver::Version, String)> = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name().to_string_lossy().to_string();
if !name.starts_with(&prefix) {
continue;
}
// Temp/partial files: sweep only STALE ones. A fresh `.tmp` may be a
// concurrent updater's in-flight download — deleting it would make
// that updater's atomic rename fail with ENOENT.
if name.contains(".tmp") {
let stale = match entry.metadata().await.and_then(|m| m.modified()) {
Ok(modified) => std::time::SystemTime::now()
.duration_since(modified)
.map(|age| age > STALE_TMP_AGE)
// Future mtime (clock skew): can't tell — leave it.
.unwrap_or(false),
// Unknown mtime: leave it; it is swept once readable+old.
Err(_) => false,
};
if stale && let Err(e) = tokio::fs::remove_file(entry.path()).await {
tracing::warn!("failed to remove stale temp file {}: {}", name, e);
}
continue;
}
// Skip symlinks (e.g. grok-latest).
if let Ok(ft) = entry.file_type().await
&& ft.is_symlink()
{
continue;
}
// The suffix after the prefix must start with a digit to be a versioned
// binary (avoids `grok-latest`, `grok-pager-*` when prefix is `grok`).
let suffix = &name[prefix.len()..];
if !suffix.starts_with(|c: char| c.is_ascii_digit()) {
continue;
}
// Extract the version portion via the shared parser (handles the
// internal `grok-0.1.150-macos-aarch64`, pre-release, and npm
// `grok-0.1.150` layouts — see `version_from_versioned_binary_name`).
let Some(ver_str) = crate::version::version_from_versioned_binary_name(&name, bin_prefix)
else {
continue;
};
if let Ok(v) = semver::Version::parse(&ver_str) {
// Skip the current version — never delete it.
if v == current_semver {
continue;
}
versioned.push((v, name));
}
}
// Sort descending by version so the newest is first.
versioned.sort_by(|a, b| b.0.cmp(&a.0));
// Keep the most recent old version (index 0), delete the rest (index 1+).
// This matches the npm policy: current + 1 previous.
for (_, name) in versioned.iter().skip(1) {
let path = dir.join(name);
// Same freshness guard as the `.tmp` sweep: a versioned binary
// written moments ago is likely a concurrent installer's
// just-renamed download (its symlink swap hasn't happened yet) —
// deleting it would leave that installer's swap pointing at
// nothing. Old binaries from previous releases are days old.
let fresh = tokio::fs::metadata(&path)
.await
.and_then(|m| m.modified())
.ok()
.and_then(|modified| std::time::SystemTime::now().duration_since(modified).ok())
.is_some_and(|age| age <= STALE_TMP_AGE);
if fresh {
continue;
}
if let Err(e) = tokio::fs::remove_file(&path).await {
tracing::warn!("failed to remove old binary {}: {}", name, e);
}
}
}
Synced from monorepo Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
2026-07-17 14:19:50 +01:00
fn installer_manages_bin_entrypoints(installer: &str) -> bool {
matches!(installer, "internal" | "gh-release")
}
#[cfg_attr(not(any(unix, windows)), allow(clippy::unused_async))]
async fn heal_managed_install(installer: &str) {
if !installer_manages_bin_entrypoints(installer) {
return;
}
#[cfg(any(unix, windows))]
{
let bin_dir = grok_home().join("bin");
#[cfg(unix)]
reconcile_agent_to_grok(&bin_dir).await;
#[cfg(windows)]
reconcile_agent_exe_to_grok(&bin_dir).await;
}
}
#[cfg(unix)]
async fn reconcile_agent_to_grok(bin_dir: &std::path::Path) {
let grok_link = bin_dir.join("grok");
let agent_link = bin_dir.join("agent");
let Ok(grok_target) = tokio::fs::read_link(&grok_link).await else {
return;
};
if tokio::fs::metadata(&grok_link).await.is_err() {
return;
}
if let Ok(agent_target) = tokio::fs::read_link(&agent_link).await
&& agent_target == grok_target
{
return;
}
match atomic_symlink_swap(&grok_target, &agent_link).await {
Ok(()) => tracing::info!(
grok_target = %grok_target.display(),
"reconciled agent bin symlink to grok target"
),
Err(e) => tracing::warn!("failed to reconcile agent bin symlink: {e:#}"),
}
}
#[cfg(windows)]
async fn reconcile_agent_exe_to_grok(bin_dir: &std::path::Path) {
let grok_exe = bin_dir.join("grok.exe");
let agent_exe = bin_dir.join("agent.exe");
if tokio::fs::metadata(&grok_exe).await.is_err() {
return;
}
match agent_exe_differs(&grok_exe, &agent_exe).await {
Ok(true) => {}
Ok(false) => return,
Err(e) => {
tracing::debug!("agent.exe reconcile: compare failed: {e:#}");
return;
}
}
match windows_replace_exe(&grok_exe, &agent_exe).await {
Ok(()) => tracing::info!("reconciled agent.exe to grok.exe"),
Err(e) => tracing::warn!("failed to reconcile agent.exe to grok.exe: {e:#}"),
}
}
#[cfg(windows)]
async fn agent_exe_differs(
grok: &std::path::Path,
agent: &std::path::Path,
) -> std::io::Result<bool> {
use tokio::io::{AsyncReadExt, BufReader};
let grok_len = tokio::fs::metadata(grok).await?.len();
match tokio::fs::metadata(agent).await {
Ok(m) if m.len() != grok_len => return Ok(true),
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(true),
Err(e) => return Err(e),
}
let mut rg = BufReader::new(tokio::fs::File::open(grok).await?);
let mut ra = BufReader::new(tokio::fs::File::open(agent).await?);
let mut bg = [0u8; 64 * 1024];
let mut ba = [0u8; 64 * 1024];
loop {
let n = rg.read(&mut bg).await?;
if n == 0 {
return Ok(false);
}
ra.read_exact(&mut ba[..n]).await?;
if bg[..n] != ba[..n] {
return Ok(true);
}
}
}
/// Download a single asset from a GitHub release via `gh release download`.
async fn gh_release_download(tag: &str, pattern: &str, dest: &std::path::Path) -> Result<()> {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template(" {spinner:.cyan} Downloading from GitHub Releases...")
.unwrap(),
);
pb.enable_steady_tick(Duration::from_millis(100));
let mut cmd = tokio::process::Command::new("gh");
cmd.args([
"release",
"download",
tag,
"--repo",
crate::version::GH_RELEASE_REPO,
"--pattern",
pattern,
"--output",
&dest.to_string_lossy(),
"--clobber",
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
xai_grok_tools::util::detach_command(&mut cmd);
cmd.envs(xai_grok_tools::util::pager_env());
let output = cmd.output().await?;
pb.finish_and_clear();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"gh release download failed for {} tag {} from {}: {}",
pattern,
tag,
crate::version::GH_RELEASE_REPO,
stderr.trim()
);
}
Ok(())
}
/// Download and install grok from GitHub Releases (xai-org-shared/grok-build).
///
/// Uses `gh release download` to fetch the binary matching the current platform.
/// This works anywhere the `gh` CLI is authenticated, without needing npm or
/// internal network access.
async fn install_gh_release(target: Option<&str>) -> Result<()> {
let (os, arch) = detect_platform()?;
let platform = format!("{}-{}", os, arch);
let version = match target {
Some(v) => v.to_string(),
None => crate::version::fetch_gh_release_version("stable").await?,
};
let grok_home = grok_home();
let download_dir = grok_home.join("downloads");
let bin_dir = grok_home.join("bin");
tokio::fs::create_dir_all(&download_dir).await?;
tokio::fs::create_dir_all(&bin_dir).await?;
let binary_name = format!("grok-{}-{}", version, platform);
let binary_path = download_dir.join(&binary_name);
let tag = format!("v{}", version);
eprintln!(
" Downloading grok v{} ({}) from GitHub Releases...",
version, platform
);
gh_release_download(&tag, &binary_name, &binary_path).await?;
// chmod +x
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&binary_path, std::fs::Permissions::from_mode(0o755)).await?;
}
// Atomic swap of ~/.grok/bin/{grok,agent} -> downloaded binary.
swap_managed_bin_links(&binary_path, &bin_dir).await?;
// Update grok-latest -> versioned binary so any existing symlinks that route
// through it (e.g. /usr/local/bin/grok -> ~/.grok/downloads/grok-latest)
// resolve to the newly installed version.
#[cfg(unix)]
{
let latest_path = download_dir.join("grok-latest");
let rel_target = relative_symlink_target(&binary_path, &latest_path);
if let Err(e) = atomic_symlink_swap(&rel_target, &latest_path).await {
tracing::warn!("Failed to update grok-latest symlink: {e}");
}
}
// Also update /usr/local/bin/{grok,agent} if either points directly into
// ~/.grok/downloads/ (legacy layout — skips the grok-latest indirection).
// Permission errors ignored.
#[cfg(unix)]
for name in ["grok", "agent"] {
let system_link = std::path::PathBuf::from(format!("/usr/local/bin/{name}"));
if let Ok(existing_target) = tokio::fs::read_link(&system_link).await {
let target_str = existing_target.to_string_lossy();
if target_str.contains(".grok/downloads/") && !target_str.ends_with("grok-latest") {
// Try to update; ignore permission errors
let _ = atomic_symlink_swap(&binary_path, &system_link).await;
}
}
}
remove_stale_pager(&bin_dir).await;
eprintln!();
// Clean up old versioned binaries (keeps current + 1 previous).
cleanup_old_downloads(&download_dir, "grok", &version).await;
cleanup_old_downloads(&download_dir, "grok-pager", &version).await;
// Persist installer to config.toml so future runs auto-detect gh-release.
let _ = config::update_config(|st| {
st.cli.installer = Some("gh-release".to_string());
})
.await;
Ok(())
}
/// Creates a temporary .npmrc file with the NPM token if present.
/// Returns the path to the created file, or None if no token was set.
fn create_temp_npmrc(npm_registry: Option<&str>) -> Result<Option<std::path::PathBuf>> {
if let Ok(token) = std::env::var("NPM_TOKEN") {
let token = token.trim();
if !token.is_empty() {
let dir = std::env::temp_dir();
let npmrc_path = dir.join(format!(".npmrc-{}-install", std::process::id()));
let registry_host = npm_registry
.and_then(|r| reqwest::Url::parse(r).ok())
.map(|u| {
let host = u.host_str().unwrap_or("registry.npmjs.org");
let port_suffix = u.port().map(|p| format!(":{}", p)).unwrap_or_default();
format!("{}{}{}", host, port_suffix, u.path().trim_end_matches('/'))
})
.unwrap_or_else(|| "registry.npmjs.org".to_string());
let npmrc_content = format!("//{}/:_authToken={}\n", registry_host, token);
std::fs::write(&npmrc_path, npmrc_content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&npmrc_path, std::fs::Permissions::from_mode(0o600))?;
}
return Ok(Some(npmrc_path));
}
}
Ok(None)
}
/// Check if other grok processes are running (macOS only).
///
/// On macOS, `npm i -g` replaces the vendored binary in node_modules in-place.
/// Any grok process running from that vendored path will be SIGKILL'd by the
/// kernel because macOS (Apple Silicon in particular) can no longer verify
/// the code signature of the mmap'd executable pages once the backing file
/// inode is unlinked.
///
/// While our postinstall.js now uses versioned binaries under ~/.grok/bin/
/// (so processes launched from there are safe), older installations or npx
/// invocations may still be running the vendored binary directly.
#[cfg(target_os = "macos")]
fn warn_if_other_grok_processes_running() {
let my_pid = std::process::id().to_string();
let mut cmd = Command::new("pgrep");
cmd.args(["-f", "grok"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
xai_grok_tools::util::detach_std_command(&mut cmd);
if let Ok(output) = cmd.output() {
let stdout = String::from_utf8_lossy(&output.stdout);
let other_pids: Vec<&str> = stdout
.lines()
.map(|l| l.trim())
.filter(|pid| !pid.is_empty() && *pid != my_pid)
.collect();
if !other_pids.is_empty() {
eprintln!(
" ⚠ Warning: {} other grok process(es) detected.",
other_pids.len()
);
eprintln!(" Processes running from the npm vendored binary path may be");
eprintln!(" killed by macOS when npm replaces the package files.");
eprintln!(" Consider closing other grok sessions before updating.");
eprintln!();
}
}
}
/// Test-only entry point: invokes the private [`install_npm`] for tests
/// that swap in a fake `npm` via PATH.
#[doc(hidden)]
pub fn install_npm_for_test(
target: Option<&str>,
channel: &str,
npm_registry: Option<&str>,
) -> Result<()> {
install_npm(target, channel, npm_registry)
}
fn install_npm(target: Option<&str>, channel: &str, npm_registry: Option<&str>) -> Result<()> {
// Warn on macOS about potential impact on other running processes.
#[cfg(target_os = "macos")]
warn_if_other_grok_processes_running();
let version_arg = match target {
Some(ver) => format!("@xai-official/grok@{ver}"),
None => {
// All current callers resolve the version via get_latest_version
// (which applies max(stable, alpha) for the alpha channel) before
// reaching here. Falling back to a raw dist-tag would bypass that
// logic, so warn loudly if this path is ever hit.
tracing::warn!(
channel,
"install_npm called without a resolved version, falling back to dist-tag"
);
format!(
"@xai-official/grok@{}",
if channel == "alpha" {
"alpha"
} else {
"latest"
}
)
}
};
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template(" {spinner:.cyan} Installing via npm...")
.unwrap(),
);
pb.enable_steady_tick(Duration::from_millis(100));
let mut cmd = Command::new("npm");
cmd.args(["i", "-g", &version_arg]);
if let Some(registry) = npm_registry {
cmd.arg(format!("--registry={}", registry));
}
// Use a temporary .npmrc to avoid exposing the token in process lists or shell history.
let temp_npmrc = create_temp_npmrc(npm_registry)?;
if let Some(ref npmrc_path) = temp_npmrc {
cmd.arg(format!("--userconfig={}", npmrc_path.display()));
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
// inherit, not piped — same rationale as run_update_subcommand.
.stderr(Stdio::inherit());
xai_grok_tools::util::detach_std_command(&mut cmd);
let status = cmd.status()?;
if let Some(path) = temp_npmrc
&& let Err(e) = std::fs::remove_file(&path)
{
tracing::warn!("Failed to remove temp .npmrc file: {}", e);
}
pb.finish_and_clear();
if !status.success() {
anyhow::bail!("npm install failed. Please try again.");
}
eprintln!();
Ok(())
}
pub async fn apply_channel_switch(channel_switch: Option<&str>, update_config: &mut UpdateConfig) {
if let Some(ch) = channel_switch
&& update_config.channel != ch
{
let _ = config::update_config(|st| {
st.cli.channel = Some(ch.to_string());
})
.await;
update_config.channel = ch.to_string();
eprintln!("Switched to {} channel.", ch);
}
}
/// Run the `grok update` command. Returns `Ok(Some(version))` when the target
/// version is present on disk afterwards — either installed by this call or
/// found already installed (e.g. by a concurrent background download); returns
/// `Ok(None)` when there is no installer or no applicable target. Callers use
/// the returned version to signal a running leader to relaunch onto the new
/// binary (see the pager's post-update leader relaunch) — that signal must
/// fire even when the download itself was skipped, so a stale leader still
/// picks up a binary someone else installed.
pub async fn run_update(
force: bool,
pinned_version: Option<&str>,
channel_switch: Option<&str>,
update_config: &mut UpdateConfig,
) -> Result<Option<String>> {
apply_channel_switch(channel_switch, update_config).await;
let installer = match get_installer().await {
Some(i) => i,
None => {
eprintln!("Auto-update is not available for manual installations.");
return Ok(None);
}
};
// Persist installer if not already saved
let cfg = config::load_config().await;
if cfg.cli.installer.is_none() {
let _ = config::update_config(|st| {
st.cli.installer = Some(installer.to_string());
})
.await;
}
Synced from monorepo Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
2026-07-17 14:19:50 +01:00
heal_managed_install(installer).await;
let current_version = get_installed_grok_version();
// When --version is given, skip the latest-version check and install directly
if let Some(version) = pinned_version {
if let Err(e) = crate::minimum_version::check_install_target(version) {
anyhow::bail!("{e}");
}
eprintln!(
"Installing Grok {} (current: {})...",
version, current_version
);
eprintln!();
run_install_script(installer, Some(version), update_config).await?;
refresh_deployment_config().await;
if let Err(e) = config::update_config(|st| {
st.cli.auto_update = Some(false);
})
.await
{
tracing::warn!("Failed to persist auto_update=false for pinned install: {e}");
}
eprintln!(" ✓ grok v{} installed successfully!", version);
eprintln!(" Please restart Grok.");
return Ok(Some(version.to_string()));
}
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template(" {spinner:.cyan} Checking for updates...")
.unwrap(),
);
pb.enable_steady_tick(Duration::from_millis(100));
let latest_version = fetch_latest_version(installer, update_config).await?;
pb.finish_and_clear();
let install_target = match crate::minimum_version::apply_floor(&latest_version) {
Ok(t) => t,
Err(e) => anyhow::bail!("{e}"),
};
if install_target != latest_version {
eprintln!(
"Latest available is {} but the configured minimum is higher; \
installing {} instead.",
latest_version, install_target
);
}
// What's on disk wins over this process's compiled-in version: a
// concurrent or earlier updater (TUI background download, leader hourly
// checker) may already have installed the target, in which case there is
// nothing to download. Gated on the installer maintaining the managed
// symlink — for npm a leftover symlink would lie (see
// `disk_version_for_installer`).
let effective_current =
disk_version_for_installer(installer).unwrap_or_else(|| current_version.clone());
if !force {
match needs_update(
&effective_current,
&install_target,
&update_config.channel,
installer_allows_downgrade(installer),
) {
Some(true) => {}
Some(false) => {
// Explicit channel switch (--stable / --alpha) with a
// different target version: install even though the current
// version is "newer" by semver. This handles switching from
// alpha 0.2.X back to stable 0.1.220 where 0.2.X > 0.1.220.
if channel_switch.is_some() && effective_current != install_target {
// Fall through to install
} else {
let stable_ptr = try_fetch_stable_pointer().await;
write_version_cache(&install_target, stable_ptr.as_deref()).await;
eprintln!("Already up to date ({}).", effective_current);
// Retry if a prior sync failed.
refresh_deployment_config().await;
// The target is on disk even though this call installed
// nothing — report it so the caller still signals stale
// leaders to relaunch onto it (signalling is directional
// and skips leaders already at/after this version).
return Ok(Some(install_target));
}
}
None => {
// Distinguish parse failure from unsupported channel.
let parse_ok = semver::Version::parse(&effective_current).is_ok()
&& semver::Version::parse(&install_target).is_ok();
if parse_ok {
anyhow::bail!(
"Unsupported release channel '{}' (current={}, target={}). \
Supported channels: stable, alpha, enterprise. \
Use --stable or --alpha to override, or set [cli] channel in config.toml.",
update_config.channel,
effective_current,
install_target
);
} else {
anyhow::bail!(
"Failed to parse versions (current={}, target={})",
effective_current,
install_target
);
}
}
}
}
let target_version = if force
&& !needs_update(
&effective_current,
&install_target,
&update_config.channel,
installer_allows_downgrade(installer),
)
.unwrap_or(true)
{
eprintln!(
"Forcing reinstall of Grok {} (already up to date)",
effective_current
);
&effective_current
} else {
eprintln!("Updating Grok {}{}", effective_current, install_target);
&install_target
};
eprintln!();
run_install_script(installer, Some(target_version), update_config).await?;
// Fetch the stable pointer now so the new binary has it immediately
// for channel_label() display, rather than waiting for the next
// TTL-gated update check (~30 min).
let stable_ptr = try_fetch_stable_pointer().await;
write_version_cache(target_version, stable_ptr.as_deref()).await;
refresh_deployment_config().await;
eprintln!(" ✓ grok v{} installed successfully!", target_version);
if !force && std::env::var_os("GROK_AUTO_UPDATE").is_none() {
eprintln!(" Please restart Grok.");
}
Ok(Some(target_version.to_string()))
}
/// Refresh managed config post-update (best-effort, staleness-gated), for
/// deployment-key and team principals alike.
async fn refresh_deployment_config() {
if !xai_grok_shell::managed_config::has_principal() {
return;
}
if !xai_grok_shell::managed_config::is_fetch_enabled() {
return;
}
// Clear a logged-out team's files before deciding to fetch (mirrors the loop).
xai_grok_shell::managed_config::clear_orphan();
if !xai_grok_shell::config::is_managed_config_stale_for(
&xai_grok_shell::managed_config::current_serving_identity(),
) {
return;
}
match xai_grok_shell::managed_config::sync().await {
Ok(true) => eprintln!(" Applied managed configuration."),
Ok(false) => tracing::debug!("no managed configuration to apply"),
// Auth issues aren't actionable mid-update: quiet here, loud on `grok setup`.
Err(e) if e.is_auth_rejection() => tracing::debug!("managed config not applied: {e}"),
Err(e) if e.is_retryable() => {
tracing::debug!("managed config refresh failed: {e}");
eprintln!(" Couldn't apply managed configuration. Run `grok setup` to retry.");
}
Err(e) => eprintln!(" Couldn't apply managed configuration. {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tmp_download_path_is_unique_per_version_and_per_attempt() {
// The old `with_extension("tmp")` collapsed every 0.1.x versioned
// name onto a single `grok-0.1.tmp`; the helper must keep distinct
// versions distinct AND make repeated attempts (same process, e.g.
// concurrent tokio tasks) unique.
let dest_181 = std::path::Path::new("/home/u/.grok/downloads/grok-0.1.181-linux-x86_64");
let dest_182 = std::path::Path::new("/home/u/.grok/downloads/grok-0.1.182-linux-x86_64");
let a = tmp_download_path(dest_181);
let b = tmp_download_path(dest_182);
assert_ne!(a, b, "different versions must not share a temp file");
let a2 = tmp_download_path(dest_181);
assert_ne!(
a, a2,
"two attempts for the same dest must not share a temp file"
);
let name = a.file_name().unwrap().to_string_lossy().to_string();
assert!(
name.starts_with("grok-0.1.181-linux-x86_64."),
"full versioned name must be preserved: {name}"
);
assert!(
name.ends_with(".tmp") && name.contains(&std::process::id().to_string()),
"temp name must embed the PID and end in .tmp (cleanup sweeps *.tmp*): {name}"
);
assert_eq!(
a.parent(),
std::path::Path::new("/home/u/.grok/downloads").into(),
"temp file must stay in the destination directory for atomic rename"
);
}
#[test]
fn test_needs_update_same_version() {
assert_eq!(
needs_update("0.1.141", "0.1.141", "stable", false),
Some(false)
);
}
#[test]
fn test_needs_update_invalid_versions() {
assert_eq!(
needs_update("not-a-version", "0.1.141", "stable", false),
None
);
assert_eq!(needs_update("0.1.141", "garbage", "stable", false), None);
}
#[test]
fn test_needs_update_unknown_channel() {
assert_eq!(needs_update("0.1.140", "0.1.141", "beta", false), None);
}
#[test]
fn test_needs_update_enterprise_channel_behaves_like_stable() {
// Enterprise uses the same conservative pre-release rules as stable.
// Same version: no update.
assert_eq!(
needs_update("0.1.206", "0.1.206", "enterprise", false),
Some(false)
);
// Newer stable: update.
assert_eq!(
needs_update("0.1.205", "0.1.206", "enterprise", false),
Some(true)
);
// Older stable: no downgrade (allow_downgrade=false).
assert_eq!(
needs_update("0.1.207", "0.1.206", "enterprise", false),
Some(false)
);
// Pre-release candidate rejected on enterprise channel.
assert_eq!(
needs_update("0.1.205", "0.1.206-alpha.1", "enterprise", false),
Some(false)
);
// Current pre-release on enterprise forces upgrade (even to equal base).
assert_eq!(
needs_update("0.1.206-alpha.3", "0.1.206", "enterprise", false),
Some(true)
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_creates_new_symlink() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("binary-v1");
std::fs::write(&target, "v1").unwrap();
let link = dir.path().join("grok");
// No existing symlink — should create one.
atomic_symlink_swap(&target, &link).await.unwrap();
assert!(link.is_symlink());
assert_eq!(std::fs::read_link(&link).unwrap(), target);
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v1");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_replaces_existing() {
let dir = tempfile::tempdir().unwrap();
let target_v1 = dir.path().join("binary-v1");
std::fs::write(&target_v1, "v1").unwrap();
let target_v2 = dir.path().join("binary-v2");
std::fs::write(&target_v2, "v2").unwrap();
let link = dir.path().join("grok");
// Set up initial symlink to v1.
std::os::unix::fs::symlink(&target_v1, &link).unwrap();
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v1");
// Swap to v2.
atomic_symlink_swap(&target_v2, &link).await.unwrap();
assert!(link.is_symlink());
assert_eq!(std::fs::read_link(&link).unwrap(), target_v2);
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_preserves_old_target() {
let dir = tempfile::tempdir().unwrap();
let target_v1 = dir.path().join("binary-v1");
std::fs::write(&target_v1, "v1-content").unwrap();
let target_v2 = dir.path().join("binary-v2");
std::fs::write(&target_v2, "v2-content").unwrap();
let link = dir.path().join("grok");
std::os::unix::fs::symlink(&target_v1, &link).unwrap();
// Swap to v2.
atomic_symlink_swap(&target_v2, &link).await.unwrap();
// The old target file must still exist on disk — this is the key
// property that prevents SIGKILL on macOS. Running processes that
// have binary-v1 mmap'd can continue to page-fault from it.
assert!(target_v1.exists(), "old binary must not be deleted");
assert_eq!(std::fs::read_to_string(&target_v1).unwrap(), "v1-content");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_no_intermediate_missing_state() {
// Verify that the link path always exists (is never absent) during
// the swap. We can't truly test atomicity without threads, but we
// can at least verify the path exists before and after.
let dir = tempfile::tempdir().unwrap();
let target_v1 = dir.path().join("binary-v1");
std::fs::write(&target_v1, "v1").unwrap();
let target_v2 = dir.path().join("binary-v2");
std::fs::write(&target_v2, "v2").unwrap();
let link = dir.path().join("grok");
std::os::unix::fs::symlink(&target_v1, &link).unwrap();
assert!(link.exists(), "link should exist before swap");
atomic_symlink_swap(&target_v2, &link).await.unwrap();
assert!(link.exists(), "link should exist after swap");
// No tmp-link file should be left behind.
let tmp_link = link.with_extension("tmp-link");
assert!(!tmp_link.exists(), "temp link should be cleaned up");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_replaces_regular_file() {
// If the canonical path is a regular file (from an old non-symlink
// installation), the swap should still work by replacing it.
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("binary-v2");
std::fs::write(&target, "v2").unwrap();
let link = dir.path().join("grok");
// Simulate an old installation where grok is a regular file.
std::fs::write(&link, "old-binary").unwrap();
atomic_symlink_swap(&target, &link).await.unwrap();
assert!(link.is_symlink());
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_succeeds_despite_leftover_tmp_link() {
// A leftover .tmp-link from a crashed swap must not block a new swap:
// unique per-racer temp names mean no collision.
let dir = tempfile::tempdir().unwrap();
let target_v1 = dir.path().join("binary-v1");
std::fs::write(&target_v1, "v1").unwrap();
let target_v2 = dir.path().join("binary-v2");
std::fs::write(&target_v2, "v2").unwrap();
let link = dir.path().join("grok");
std::os::unix::fs::symlink(&target_v1, &link).unwrap();
std::os::unix::fs::symlink(&target_v1, link.with_extension("tmp-link")).unwrap();
atomic_symlink_swap(&target_v2, &link).await.unwrap();
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2");
}
Synced from monorepo Changes: - Classify clipboard delivery confidence - Add durable session update append - Scope the xAI session bearer to first-party memory embedding endpoints - Persist subagent outputs to disk and bound long-lived agent state - Add MiniSweAgent:bash for mini-swe-agent parity - Revert taking local sessions off the persistent shell - Contextual tip recommending grok wrap on SSH sessions - Voice STT bearer from model BYOK env_key/api_key - Define exact website policies for sandbox - Gate unsafe shell environments - Shared pin hoist; single require_sha gate for marketplace plugins - Server-signed is-managed claim (closes sidecar-removal downgrade) - Optional require_sha pin for remote plugin installs - Show session title and last exchange in the exit resume hint - Gate shell output redirects - Warn when fail_closed is present but not a boolean - Add canonical text editing core (ratatui-textarea) - Keep execution state out of goal scratch - Add acknowledged persistence primitives - Inherit child network restrictions in sandbox - Fail closed when hook matchers fail to recompile - Add MCP setup preferences for plugin MCPs - Gate sourced shell scripts - Gate file-typed project hooks - grok wrap: restore terminal modes on child death - Harden owner-only permissions on auth and MCP credentials - Create crash dump files with owner-only permissions - Write the agent_id cache owner-only (0600) - SessionMetrics mode skips Mixpanel profile sync - Dashboard: slim live-tail peek - Yank full queued prompt text, not (+N lines) - Defeat clock-rollback on the signed managed-config cache - Stop early session/cancel from overtaking the prompt and wedging the turn slot - Self-heal a diverged agent entrypoint on startup - Add matched inference expectations in test-support - Add AuthSingleFlight cancel/successor gap tests - Remove consumer from external OTEL allowlist and pin scrub coverage - Enable /copy in minimal mode - Surface capacity and API-key detail on 429 errors - Single-flight interactive auth - Fix PageUp/PageDown skipping lines behind sticky prompt header
2026-07-17 14:19:50 +01:00
#[cfg(unix)]
fn managed_layout() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("bin");
let downloads = dir.path().join("downloads");
std::fs::create_dir_all(&bin).unwrap();
std::fs::create_dir_all(&downloads).unwrap();
(dir, bin, downloads)
}
#[test]
fn test_installer_manages_bin_entrypoints_gate() {
assert!(installer_manages_bin_entrypoints("internal"));
assert!(installer_manages_bin_entrypoints("gh-release"));
assert!(!installer_manages_bin_entrypoints("npm"));
assert!(!installer_manages_bin_entrypoints("unknown"));
}
#[cfg(unix)]
#[tokio::test]
async fn test_reconcile_agent_repoints_diverged_agent() {
let (_dir, bin, downloads) = managed_layout();
std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap();
std::fs::write(downloads.join("grok-0.1.199-macos-aarch64"), "old").unwrap();
std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok"))
.unwrap();
std::os::unix::fs::symlink("../downloads/grok-0.1.199-macos-aarch64", bin.join("agent"))
.unwrap();
reconcile_agent_to_grok(&bin).await;
assert_eq!(
std::fs::read_link(bin.join("agent")).unwrap(),
std::path::PathBuf::from("../downloads/grok-0.2.101-macos-aarch64"),
);
assert_eq!(std::fs::read_to_string(bin.join("agent")).unwrap(), "new");
assert!(downloads.join("grok-0.1.199-macos-aarch64").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn test_reconcile_agent_heals_legacy_unversioned_agent() {
let (_dir, bin, downloads) = managed_layout();
std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap();
std::fs::write(downloads.join("grok-macos-aarch64"), "legacy").unwrap();
std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok"))
.unwrap();
std::os::unix::fs::symlink("../downloads/grok-macos-aarch64", bin.join("agent")).unwrap();
reconcile_agent_to_grok(&bin).await;
assert_eq!(
std::fs::read_link(bin.join("agent")).unwrap(),
std::path::PathBuf::from("../downloads/grok-0.2.101-macos-aarch64"),
);
assert_eq!(std::fs::read_to_string(bin.join("agent")).unwrap(), "new");
}
#[cfg(unix)]
#[tokio::test]
async fn test_reconcile_agent_creates_missing_agent() {
let (_dir, bin, downloads) = managed_layout();
std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap();
std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok"))
.unwrap();
reconcile_agent_to_grok(&bin).await;
assert!(bin.join("agent").is_symlink());
assert_eq!(std::fs::read_to_string(bin.join("agent")).unwrap(), "new");
}
#[cfg(unix)]
#[tokio::test]
async fn test_reconcile_agent_noop_when_consistent() {
let (_dir, bin, downloads) = managed_layout();
std::fs::write(downloads.join("grok-0.2.101-macos-aarch64"), "new").unwrap();
let target = "../downloads/grok-0.2.101-macos-aarch64";
std::os::unix::fs::symlink(target, bin.join("grok")).unwrap();
std::os::unix::fs::symlink(target, bin.join("agent")).unwrap();
reconcile_agent_to_grok(&bin).await;
assert_eq!(
std::fs::read_link(bin.join("agent")).unwrap(),
std::path::PathBuf::from(target),
);
let leftovers = std::fs::read_dir(&bin)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().contains(".tmp-link"))
.count();
assert_eq!(leftovers, 0, "no temp links from a no-op reconcile");
}
#[cfg(unix)]
#[tokio::test]
async fn test_reconcile_agent_skips_when_grok_dangling() {
let (_dir, bin, downloads) = managed_layout();
std::os::unix::fs::symlink("../downloads/grok-0.2.101-macos-aarch64", bin.join("grok"))
.unwrap();
std::fs::write(downloads.join("grok-0.1.199-macos-aarch64"), "old").unwrap();
std::os::unix::fs::symlink("../downloads/grok-0.1.199-macos-aarch64", bin.join("agent"))
.unwrap();
reconcile_agent_to_grok(&bin).await;
assert_eq!(
std::fs::read_link(bin.join("agent")).unwrap(),
std::path::PathBuf::from("../downloads/grok-0.1.199-macos-aarch64"),
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_reconcile_agent_skips_when_grok_not_symlink() {
let (_dir, bin, downloads) = managed_layout();
std::fs::write(bin.join("grok"), "copy-binary").unwrap();
std::fs::write(downloads.join("grok-0.1.199-macos-aarch64"), "old").unwrap();
std::os::unix::fs::symlink("../downloads/grok-0.1.199-macos-aarch64", bin.join("agent"))
.unwrap();
reconcile_agent_to_grok(&bin).await;
assert_eq!(
std::fs::read_link(bin.join("agent")).unwrap(),
std::path::PathBuf::from("../downloads/grok-0.1.199-macos-aarch64"),
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_sweep_stale_tmp_links_removes_stale_keeps_fresh_and_active() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("binary-v1");
std::fs::write(&target, "v1").unwrap();
let link = dir.path().join("grok");
std::os::unix::fs::symlink(&target, &link).unwrap();
// Old- and new-style leftover temp links.
let leftover_old = dir.path().join("grok.tmp-link");
let leftover_new = dir.path().join("grok.123-0.tmp-link");
std::os::unix::fs::symlink(&target, &leftover_old).unwrap();
std::os::unix::fs::symlink(&target, &leftover_new).unwrap();
// max_age = ZERO: every leftover is stale and removed; the active
// `grok` link (no `.tmp-link` suffix) is untouched.
sweep_stale_tmp_links(&link, Duration::ZERO).await;
assert!(!leftover_old.exists() && !leftover_new.exists());
assert!(link.is_symlink(), "active link must be preserved");
// A fresh leftover under a real max_age is preserved — it could be a
// concurrent racer's in-flight link.
let fresh = dir.path().join("grok.999-9.tmp-link");
std::os::unix::fs::symlink(&target, &fresh).unwrap();
sweep_stale_tmp_links(&link, Duration::from_secs(3600)).await;
assert!(fresh.exists(), "fresh tmp-link must be preserved");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_multiple_sequential_swaps() {
// Simulate v1 -> v2 -> v3 -> v4 sequential swaps.
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("grok");
for i in 1..=4 {
let target = dir.path().join(format!("binary-v{}", i));
std::fs::write(&target, format!("content-v{}", i)).unwrap();
atomic_symlink_swap(&target, &link).await.unwrap();
assert!(link.is_symlink());
assert_eq!(
std::fs::read_to_string(&link).unwrap(),
format!("content-v{}", i)
);
}
// All old binaries should still be on disk.
for i in 1..=4 {
let target = dir.path().join(format!("binary-v{}", i));
assert!(target.exists(), "binary-v{} should still exist", i);
}
// No temp files should remain.
let tmp_link = link.with_extension("tmp-link");
assert!(!tmp_link.exists(), "no temp link should remain");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_with_absolute_target() {
// atomic_symlink_swap stores whatever path is given — if absolute,
// readlink returns the absolute path.
let dir = tempfile::tempdir().unwrap();
let binary = dir.path().join("grok-0.1.141");
std::fs::write(&binary, "v141").unwrap();
let link = dir.path().join("grok");
atomic_symlink_swap(&binary, &link).await.unwrap();
assert!(link.is_symlink());
// readlink returns the absolute path we passed.
assert_eq!(std::fs::read_link(&link).unwrap(), binary);
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v141");
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_with_relative_target() {
// When given a relative path, the symlink stores a relative target.
let dir = tempfile::tempdir().unwrap();
let downloads = dir.path().join("downloads");
let bin = dir.path().join("bin");
std::fs::create_dir_all(&downloads).unwrap();
std::fs::create_dir_all(&bin).unwrap();
std::fs::write(downloads.join("grok-0.1.203"), "v203").unwrap();
let rel_target = std::path::Path::new("../downloads/grok-0.1.203");
let link = bin.join("grok");
atomic_symlink_swap(rel_target, &link).await.unwrap();
assert!(link.is_symlink());
assert_eq!(
std::fs::read_link(&link).unwrap(),
std::path::PathBuf::from("../downloads/grok-0.1.203")
);
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v203");
}
#[cfg(unix)]
#[test]
fn test_relative_symlink_target_sibling_dirs() {
// bin/grok -> ../downloads/grok-0.1.203
let target = std::path::Path::new("/home/alice/.grok/downloads/grok-0.1.203");
let link = std::path::Path::new("/home/alice/.grok/bin/grok");
let result = relative_symlink_target(target, link);
assert_eq!(
result,
std::path::PathBuf::from("../downloads/grok-0.1.203")
);
}
#[cfg(unix)]
#[test]
fn test_relative_symlink_target_same_dir() {
// downloads/grok-latest -> grok-0.1.203 (same directory)
let target = std::path::Path::new("/home/alice/.grok/downloads/grok-0.1.203");
let link = std::path::Path::new("/home/alice/.grok/downloads/grok-latest");
let result = relative_symlink_target(target, link);
assert_eq!(result, std::path::PathBuf::from("grok-0.1.203"));
}
#[cfg(unix)]
#[test]
fn test_relative_symlink_target_cross_tree_stays_absolute() {
// /usr/local/bin/grok -> /home/alice/.grok/downloads/grok-0.1.203
// Different grandparents — should stay absolute.
let target = std::path::Path::new("/home/alice/.grok/downloads/grok-0.1.203");
let link = std::path::Path::new("/usr/local/bin/grok");
let result = relative_symlink_target(target, link);
assert_eq!(
result,
std::path::PathBuf::from("/home/alice/.grok/downloads/grok-0.1.203")
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_relative_symlink_survives_directory_move() {
// Simulates Docker bind-mount: create ~/.grok/ layout at path A,
// then move it to path B and verify the symlink still resolves.
let dir = tempfile::tempdir().unwrap();
// Create alice's layout
let alice = dir.path().join("alice").join(".grok");
let alice_downloads = alice.join("downloads");
let alice_bin = alice.join("bin");
std::fs::create_dir_all(&alice_downloads).unwrap();
std::fs::create_dir_all(&alice_bin).unwrap();
std::fs::write(alice_downloads.join("grok-0.1.203"), "binary-content").unwrap();
// Create a relative symlink (what the fix produces)
let rel_target = std::path::Path::new("../downloads/grok-0.1.203");
let link = alice_bin.join("grok");
atomic_symlink_swap(rel_target, &link).await.unwrap();
// Verify it works at the original location
assert_eq!(std::fs::read_to_string(&link).unwrap(), "binary-content");
// "Bind-mount" to bob: copy the entire .grok tree
let bob_home = dir.path().join("bob");
std::fs::create_dir_all(&bob_home).unwrap();
let bob = bob_home.join(".grok");
let copy_status = std::process::Command::new("cp")
.args(["-a", alice.to_str().unwrap(), bob.to_str().unwrap()])
.status()
.unwrap();
assert!(copy_status.success());
// Verify the symlink resolves at bob's path too
let bob_link = bob.join("bin").join("grok");
assert!(bob_link.is_symlink());
assert_eq!(
std::fs::read_link(&bob_link).unwrap(),
std::path::PathBuf::from("../downloads/grok-0.1.203"),
"symlink target should be relative"
);
assert_eq!(
std::fs::read_to_string(&bob_link).unwrap(),
"binary-content",
"relative symlink should resolve at the new path"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_atomic_symlink_swap_broken_symlink_target() {
// If the current symlink is broken (target deleted externally),
// the swap should still succeed.
let dir = tempfile::tempdir().unwrap();
let link = dir.path().join("grok");
// Create a broken symlink — points to a file that doesn't exist.
std::os::unix::fs::symlink(dir.path().join("deleted-binary"), &link).unwrap();
assert!(link.is_symlink());
assert!(!link.exists(), "broken symlink should not 'exist'");
// New target to swap to.
let target = dir.path().join("binary-v2");
std::fs::write(&target, "v2").unwrap();
atomic_symlink_swap(&target, &link).await.unwrap();
assert!(link.is_symlink());
assert!(link.exists(), "symlink should now resolve");
assert_eq!(std::fs::read_to_string(&link).unwrap(), "v2");
}
#[test]
fn test_needs_update_prerelease_to_stable_forces_install() {
// Inadmissible current (pre-release on stable channel) → install even
// if the candidate is semver-lower.
assert_eq!(
needs_update("0.1.149-alpha.1", "0.1.148", "stable", false),
Some(true)
);
assert_eq!(
needs_update("0.1.148-alpha.3", "0.1.148", "stable", false),
Some(true)
);
}
#[test]
fn test_needs_update_stable_to_alpha_no_install_when_candidate_equal() {
// Server returns max(stable, alpha) for alpha channel. When the user's
// stable version already IS the candidate, no install needed.
assert_eq!(
needs_update("0.1.148", "0.1.148", "alpha", false),
Some(false)
);
}
#[test]
fn test_needs_update_stable_channel_never_gets_prerelease() {
assert_eq!(
needs_update("0.1.139", "0.1.140-alpha.1", "stable", false),
Some(false)
);
assert_eq!(
needs_update("0.1.0", "0.1.1-beta.1", "stable", false),
Some(false)
);
}
#[test]
fn test_needs_update_valid_current_only_upgrades() {
// Admissible current on the target channel → pure semver (allow_downgrade=false).
assert_eq!(
needs_update("0.1.140", "0.1.141", "stable", false),
Some(true)
);
assert_eq!(
needs_update("0.1.141", "0.1.140", "stable", false),
Some(false)
);
assert_eq!(
needs_update("0.1.140-alpha.8", "0.1.140", "alpha", false),
Some(true)
);
assert_eq!(
needs_update("0.1.140", "0.1.139-alpha.5", "alpha", false),
Some(false)
);
// Alpha → newer alpha: upgrade.
assert_eq!(
needs_update("0.1.148-alpha.1", "0.1.148-alpha.3", "alpha", false),
Some(true)
);
// Alpha → older alpha: no downgrade (allow_downgrade=false).
assert_eq!(
needs_update("0.1.148-alpha.3", "0.1.148-alpha.2", "alpha", false),
Some(false)
);
}
#[test]
fn test_needs_update_large_version_numbers() {
// Ensure no overflow on realistic version numbers
assert_eq!(
needs_update("0.1.140", "0.1.999", "stable", false),
Some(true)
);
assert_eq!(
needs_update("0.1.999", "0.2.0", "stable", false),
Some(true)
);
assert_eq!(
needs_update("99.99.99", "100.0.0", "stable", false),
Some(true)
);
}
#[tokio::test]
async fn test_cleanup_old_downloads_keeps_current_plus_one() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Simulate 5 old grok binaries in downloads dir.
for v in ["0.1.140", "0.1.141", "0.1.142", "0.1.143", "0.1.144"] {
std::fs::write(d.join(format!("grok-{}-macos-aarch64", v)), v).unwrap();
}
// Current version.
std::fs::write(d.join("grok-0.1.145-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.145").await;
// Current must survive.
assert!(d.join("grok-0.1.145-macos-aarch64").exists(), "current");
// Newest old version (0.1.144) must survive.
assert!(d.join("grok-0.1.144-macos-aarch64").exists(), "N-1");
// Everything else should be deleted.
assert!(
!d.join("grok-0.1.143-macos-aarch64").exists(),
"0.1.143 should be deleted"
);
assert!(
!d.join("grok-0.1.142-macos-aarch64").exists(),
"0.1.142 should be deleted"
);
assert!(
!d.join("grok-0.1.141-macos-aarch64").exists(),
"0.1.141 should be deleted"
);
assert!(
!d.join("grok-0.1.140-macos-aarch64").exists(),
"0.1.140 should be deleted"
);
}
#[tokio::test]
async fn test_cleanup_old_downloads_does_not_touch_other_binaries() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// grok and grok-pager should not interfere with each other.
std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "old-grok").unwrap();
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current-grok").unwrap();
std::fs::write(d.join("grok-pager-0.1.140-macos-aarch64"), "old-pager").unwrap();
std::fs::write(d.join("grok-pager-0.1.141-macos-aarch64"), "current-pager").unwrap();
// Cleanup only grok — pager files must be untouched.
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(d.join("grok-0.1.141-macos-aarch64").exists());
assert!(d.join("grok-0.1.140-macos-aarch64").exists()); // only old, kept as N-1
assert!(
d.join("grok-pager-0.1.140-macos-aarch64").exists(),
"pager untouched"
);
assert!(
d.join("grok-pager-0.1.141-macos-aarch64").exists(),
"pager untouched"
);
}
/// Backdate a file's mtime past [`STALE_TMP_AGE`] so cleanup treats it
/// as an abandoned download / genuinely old binary.
fn make_stale(path: &std::path::Path) {
let old = std::time::SystemTime::now() - (STALE_TMP_AGE + Duration::from_secs(60));
let f = std::fs::File::options().write(true).open(path).unwrap();
f.set_times(std::fs::FileTimes::new().set_modified(old))
.unwrap();
}
/// Backdate every file in `dir`. Cleanup deliberately never deletes a
/// freshly-written binary or temp file (it may belong to a concurrent
/// in-flight install), so retention-policy tests must age their fixtures
/// to look like real leftovers from previous releases.
fn make_all_stale(dir: &std::path::Path) {
for entry in std::fs::read_dir(dir).unwrap() {
let p = entry.unwrap().path();
if p.is_file() {
make_stale(&p);
}
}
}
#[tokio::test]
async fn test_cleanup_old_downloads_removes_stale_tmp_keeps_fresh_tmp() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Stale tmp: abandoned by a crashed updater — swept.
std::fs::write(d.join("grok-0.1.140-macos-aarch64.tmp"), "partial").unwrap();
make_stale(&d.join("grok-0.1.140-macos-aarch64.tmp"));
// Fresh tmp: a concurrent updater's in-flight download — kept, or
// its atomic rename would fail with ENOENT.
std::fs::write(d.join("grok-0.1.142-macos-aarch64.77-0.tmp"), "inflight").unwrap();
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap();
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(
!d.join("grok-0.1.140-macos-aarch64.tmp").exists(),
"stale tmp cleaned up"
);
assert!(
d.join("grok-0.1.142-macos-aarch64.77-0.tmp").exists(),
"fresh in-flight tmp must NOT be swept"
);
assert!(
d.join("grok-0.1.141-macos-aarch64").exists(),
"current kept"
);
}
#[tokio::test]
async fn test_cleanup_old_downloads_keeps_fresh_versioned_binary() {
// A versioned binary written moments ago may be a concurrent
// installer's just-renamed download whose symlink swap hasn't
// happened yet — even when the retention policy would otherwise
// delete it, it must survive until it ages.
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Three old versions + current: policy would delete .138 and .139.
for v in ["0.1.138", "0.1.139", "0.1.140"] {
std::fs::write(d.join(format!("grok-{v}-macos-aarch64")), v).unwrap();
}
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap();
make_all_stale(d);
// .138 is re-written NOW — simulating a racer that just renamed its
// download into place (e.g. a rollback install racing an upgrade).
std::fs::write(d.join("grok-0.1.138-macos-aarch64"), "in-flight").unwrap();
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(d.join("grok-0.1.141-macos-aarch64").exists(), "current");
assert!(d.join("grok-0.1.140-macos-aarch64").exists(), "N-1 kept");
assert!(
d.join("grok-0.1.138-macos-aarch64").exists(),
"fresh just-renamed binary must NOT be deleted"
);
assert!(
!d.join("grok-0.1.139-macos-aarch64").exists(),
"genuinely old binary still swept"
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_cleanup_old_downloads_skips_symlinks() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// grok-latest is a symlink — must be skipped.
let target = d.join("grok-0.1.141-macos-aarch64");
std::fs::write(&target, "current").unwrap();
std::os::unix::fs::symlink(&target, d.join("grok-latest")).unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(
d.join("grok-latest").exists(),
"symlink must not be deleted"
);
assert!(target.exists(), "current must not be deleted");
}
#[tokio::test]
async fn test_cleanup_old_downloads_empty_dir() {
let dir = tempfile::tempdir().unwrap();
// Should not panic or error on empty directory.
make_all_stale(dir.path());
cleanup_old_downloads(dir.path(), "grok", "0.1.141").await;
}
#[tokio::test]
async fn test_cleanup_old_downloads_version_prefix_collision() {
// Regression test: version "0.1.14" must not protect "0.1.140", "0.1.141", etc.
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("grok-0.1.14-macos-aarch64"), "current").unwrap();
std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "old-140").unwrap();
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "old-141").unwrap();
std::fs::write(d.join("grok-0.1.13-macos-aarch64"), "old-13").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.14").await;
// Current must survive.
assert!(
d.join("grok-0.1.14-macos-aarch64").exists(),
"current 0.1.14"
);
// Newest old version (0.1.141) must survive as N-1.
assert!(
d.join("grok-0.1.141-macos-aarch64").exists(),
"N-1 is 0.1.141"
);
// 0.1.140 and 0.1.13 should be deleted.
assert!(
!d.join("grok-0.1.140-macos-aarch64").exists(),
"0.1.140 should be deleted"
);
assert!(
!d.join("grok-0.1.13-macos-aarch64").exists(),
"0.1.13 should be deleted"
);
}
#[tokio::test]
async fn test_cleanup_old_downloads_pager_multi_version() {
// Verify cleanup works for grok-pager with multiple old versions.
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
for v in ["0.1.148", "0.1.149", "0.1.150"] {
std::fs::write(d.join(format!("grok-pager-{}-linux-x64", v)), v).unwrap();
}
std::fs::write(d.join("grok-pager-0.1.151-linux-x64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok-pager", "0.1.151").await;
assert!(d.join("grok-pager-0.1.151-linux-x64").exists(), "current");
assert!(d.join("grok-pager-0.1.150-linux-x64").exists(), "N-1 kept");
assert!(
!d.join("grok-pager-0.1.149-linux-x64").exists(),
"0.1.149 deleted"
);
assert!(
!d.join("grok-pager-0.1.148-linux-x64").exists(),
"0.1.148 deleted"
);
}
#[tokio::test]
async fn test_cleanup_old_downloads_npm_layout() {
// npm layout: files are just `grok-{version}` (no platform suffix).
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
for v in ["0.1.138", "0.1.139", "0.1.140"] {
std::fs::write(d.join(format!("grok-{}", v)), v).unwrap();
}
std::fs::write(d.join("grok-0.1.141"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(d.join("grok-0.1.141").exists(), "current");
assert!(d.join("grok-0.1.140").exists(), "N-1 kept");
assert!(!d.join("grok-0.1.139").exists(), "0.1.139 deleted");
assert!(!d.join("grok-0.1.138").exists(), "0.1.138 deleted");
}
#[tokio::test]
async fn test_cleanup_old_downloads_alpha_versions() {
// Alpha version filenames include pre-release tags:
// grok-0.1.150-alpha.1-macos-aarch64
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("grok-0.1.148-alpha.1-macos-aarch64"), "alpha-148-1").unwrap();
std::fs::write(d.join("grok-0.1.148-alpha.2-macos-aarch64"), "alpha-148-2").unwrap();
std::fs::write(d.join("grok-0.1.149-alpha.1-macos-aarch64"), "alpha-149-1").unwrap();
// Current version is the newest alpha.
std::fs::write(d.join("grok-0.1.150-alpha.1-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.150-alpha.1").await;
// Current must survive.
assert!(
d.join("grok-0.1.150-alpha.1-macos-aarch64").exists(),
"current alpha"
);
// Newest old (0.1.149-alpha.1) kept as N-1.
assert!(
d.join("grok-0.1.149-alpha.1-macos-aarch64").exists(),
"N-1 alpha"
);
// Older alphas deleted.
assert!(
!d.join("grok-0.1.148-alpha.2-macos-aarch64").exists(),
"0.1.148-alpha.2 deleted"
);
assert!(
!d.join("grok-0.1.148-alpha.1-macos-aarch64").exists(),
"0.1.148-alpha.1 deleted"
);
}
#[tokio::test]
async fn test_cleanup_old_downloads_mixed_stable_and_alpha() {
// Mix of stable and alpha binaries in the same directory.
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("grok-0.1.148-macos-aarch64"), "stable-148").unwrap();
std::fs::write(d.join("grok-0.1.149-alpha.1-macos-aarch64"), "alpha-149").unwrap();
std::fs::write(d.join("grok-0.1.149-macos-aarch64"), "stable-149").unwrap();
// Current is a stable release.
std::fs::write(d.join("grok-0.1.150-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.150").await;
// Current must survive.
assert!(d.join("grok-0.1.150-macos-aarch64").exists(), "current");
// Newest old is 0.1.149 stable (semver: 0.1.149 > 0.1.149-alpha.1).
assert!(
d.join("grok-0.1.149-macos-aarch64").exists(),
"N-1 is stable 0.1.149"
);
// The rest should be deleted.
assert!(
!d.join("grok-0.1.149-alpha.1-macos-aarch64").exists(),
"alpha 0.1.149-alpha.1 deleted"
);
assert!(
!d.join("grok-0.1.148-macos-aarch64").exists(),
"stable 0.1.148 deleted"
);
}
// ──────────────────────────────────────────────────────────────────────
// reinstall_hint
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_reinstall_hint_npm_mentions_npm_command() {
let hint = reinstall_hint("npm");
assert!(hint.contains("npm i -g"), "should suggest npm i -g: {hint}");
assert!(
hint.contains("@xai-official/grok"),
"should name the package: {hint}"
);
}
#[test]
fn test_reinstall_hint_gh_release_mentions_gh_command() {
let hint = reinstall_hint("gh-release");
assert!(
hint.contains("gh release download"),
"should suggest gh release download: {hint}"
);
assert!(
hint.contains("xai-org-shared/grok-build"),
"should name the repo: {hint}"
);
}
#[test]
fn test_reinstall_hint_internal_mentions_platform_installer() {
let hint = reinstall_hint("internal");
if cfg!(windows) {
assert!(hint.contains("irm"), "should suggest irm install: {hint}");
assert!(
hint.contains("install.ps1"),
"should reference install.ps1: {hint}"
);
} else {
assert!(hint.contains("curl"), "should suggest curl install: {hint}");
assert!(
hint.contains("install.sh"),
"should reference install.sh: {hint}"
);
}
}
#[test]
fn test_reinstall_hint_unknown_falls_back_to_internal() {
// Unknown installer falls back to the same hint as "internal".
let unknown = reinstall_hint("homebrew");
let internal = reinstall_hint("internal");
assert_eq!(unknown, internal);
}
#[test]
fn test_reinstall_hint_empty_falls_back_to_internal() {
let hint = reinstall_hint("");
assert_eq!(hint, reinstall_hint("internal"));
}
// ──────────────────────────────────────────────────────────────────────
// UpdateStatus serialization (camelCase contract for --json clients)
// ──────────────────────────────────────────────────────────────────────
fn make_status() -> UpdateStatus {
UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: Some("0.1.151".to_string()),
update_available: true,
installer: Some("npm".to_string()),
channel: "stable".to_string(),
auto_update: Some(true),
error: None,
}
}
#[test]
fn test_update_status_serializes_camel_case_keys() {
let s = make_status();
let v = serde_json::to_value(&s).unwrap();
let obj = v.as_object().unwrap();
assert!(obj.contains_key("currentVersion"));
assert!(obj.contains_key("latestVersion"));
assert!(obj.contains_key("updateAvailable"));
assert!(obj.contains_key("installer"));
assert!(obj.contains_key("channel"));
assert!(obj.contains_key("autoUpdate"));
assert!(obj.contains_key("error"));
// Snake-case names must NOT leak.
assert!(!obj.contains_key("current_version"));
assert!(!obj.contains_key("latest_version"));
assert!(!obj.contains_key("update_available"));
assert!(!obj.contains_key("auto_update"));
}
#[test]
fn test_update_status_field_values_round_trip_through_json() {
let s = make_status();
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["currentVersion"], "0.1.150");
assert_eq!(v["latestVersion"], "0.1.151");
assert_eq!(v["updateAvailable"], true);
assert_eq!(v["installer"], "npm");
assert_eq!(v["channel"], "stable");
assert_eq!(v["autoUpdate"], true);
assert!(v["error"].is_null());
}
#[test]
fn test_update_status_optional_none_serializes_to_null() {
let s = UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: None,
update_available: false,
installer: None,
channel: "stable".to_string(),
auto_update: None,
error: None,
};
let v = serde_json::to_value(&s).unwrap();
assert!(v["latestVersion"].is_null());
assert!(v["installer"].is_null());
assert!(v["autoUpdate"].is_null());
assert!(v["error"].is_null());
assert_eq!(v["updateAvailable"], false);
}
#[test]
fn test_update_status_with_error_field_serialized() {
let s = UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: None,
update_available: false,
installer: Some("npm".to_string()),
channel: "stable".to_string(),
auto_update: Some(true),
error: Some("npm view failed: ENETUNREACH".to_string()),
};
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["error"], "npm view failed: ENETUNREACH");
}
#[test]
fn test_update_status_alpha_channel_serialized() {
let s = UpdateStatus {
current_version: "0.1.150-alpha.1".to_string(),
latest_version: Some("0.1.150-alpha.2".to_string()),
update_available: true,
installer: Some("npm".to_string()),
channel: "alpha".to_string(),
auto_update: Some(true),
error: None,
};
let v = serde_json::to_value(&s).unwrap();
assert_eq!(v["channel"], "alpha");
assert_eq!(v["currentVersion"], "0.1.150-alpha.1");
assert_eq!(v["latestVersion"], "0.1.150-alpha.2");
}
#[test]
fn test_update_status_json_is_valid_single_object() {
// Whatever we add to UpdateStatus in the future, the serialization
// must remain a single JSON object (not an array, primitive, etc.).
let s = make_status();
let json = serde_json::to_string(&s).unwrap();
assert!(json.starts_with('{'), "must be a JSON object: {json}");
assert!(json.ends_with('}'), "must be a JSON object: {json}");
// Single line: no embedded newlines (the wire format is one line).
assert!(!json.contains('\n'), "must be single line: {json}");
}
// ──────────────────────────────────────────────────────────────────────
// print_update_status — exercise both code paths via JSON serialization
// (the human path writes to stdout/stderr which is hard to capture
// without altering the function signature).
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_print_update_status_json_returns_ok() {
let s = make_status();
// We can't easily capture stdout, but we can confirm the function
// doesn't panic or return Err on a well-formed status.
print_update_status(&s, true).unwrap();
}
#[test]
fn test_print_update_status_human_returns_ok_when_update_available() {
let s = make_status();
print_update_status(&s, false).unwrap();
}
#[test]
fn test_print_update_status_human_returns_ok_when_no_installer() {
let s = UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: None,
update_available: false,
installer: None,
channel: "stable".to_string(),
auto_update: None,
error: None,
};
print_update_status(&s, false).unwrap();
}
#[test]
fn test_print_update_status_human_returns_ok_with_error() {
let s = UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: None,
update_available: false,
installer: Some("npm".to_string()),
channel: "stable".to_string(),
auto_update: Some(true),
error: Some("network down".to_string()),
};
print_update_status(&s, false).unwrap();
}
#[test]
fn test_print_update_status_human_returns_ok_when_up_to_date() {
let s = UpdateStatus {
current_version: "0.1.150".to_string(),
latest_version: Some("0.1.150".to_string()),
update_available: false,
installer: Some("npm".to_string()),
channel: "stable".to_string(),
auto_update: Some(true),
error: None,
};
print_update_status(&s, false).unwrap();
}
// ──────────────────────────────────────────────────────────────────────
// needs_update — additional edge cases
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_needs_update_empty_current_returns_none() {
assert_eq!(needs_update("", "0.1.141", "stable", false), None);
}
#[test]
fn test_needs_update_empty_latest_returns_none() {
assert_eq!(needs_update("0.1.141", "", "stable", false), None);
}
#[test]
fn test_needs_update_whitespace_returns_none() {
// Leading/trailing whitespace is not stripped — semver::parse rejects.
assert_eq!(needs_update(" 0.1.141", "0.1.142", "stable", false), None);
assert_eq!(needs_update("0.1.141", "0.1.142 ", "stable", false), None);
}
#[test]
fn test_needs_update_channel_is_case_sensitive() {
// "STABLE", "Stable", "ENTERPRISE" etc. are not recognized — must be exact lowercase.
assert_eq!(needs_update("0.1.140", "0.1.141", "STABLE", false), None);
assert_eq!(needs_update("0.1.140", "0.1.141", "Stable", false), None);
assert_eq!(needs_update("0.1.140", "0.1.141", "ALPHA", false), None);
assert_eq!(
needs_update("0.1.140", "0.1.141", "ENTERPRISE", false),
None
);
}
#[test]
fn test_needs_update_unknown_channels_return_none() {
// Unknown channels (not stable/alpha/enterprise) return None.
assert_eq!(needs_update("0.1.140", "0.1.141", "beta", false), None);
assert_eq!(needs_update("0.1.140", "0.1.141", "nightly", false), None);
assert_eq!(needs_update("0.1.140", "0.1.141", "", false), None);
assert_eq!(needs_update("0.1.140", "0.1.141", "rc", false), None);
// Enterprise is explicitly supported (behaves like stable).
assert_eq!(
needs_update("0.1.140", "0.1.141", "enterprise", false),
Some(true)
);
// Unknown channels return None regardless of allow_downgrade.
assert_eq!(needs_update("0.1.140", "0.1.141", "beta", true), None);
assert_eq!(needs_update("0.1.140", "0.1.141", "", true), None);
}
#[test]
fn test_needs_update_zero_versions() {
assert_eq!(needs_update("0.0.0", "0.0.1", "stable", false), Some(true));
assert_eq!(needs_update("0.0.0", "0.0.0", "stable", false), Some(false));
}
#[test]
fn test_needs_update_major_version_jump() {
assert_eq!(needs_update("0.9.99", "1.0.0", "stable", false), Some(true));
assert_eq!(
needs_update("1.99.99", "2.0.0", "stable", false),
Some(true)
);
// Major downgrade: not an upgrade (allow_downgrade=false).
assert_eq!(
needs_update("2.0.0", "1.99.99", "stable", false),
Some(false)
);
}
#[test]
fn test_needs_update_alpha_to_alpha_same_version_not_upgrade() {
assert_eq!(
needs_update("0.1.150-alpha.5", "0.1.150-alpha.5", "alpha", false),
Some(false)
);
}
#[test]
fn test_needs_update_alpha_to_beta_same_base_is_upgrade_per_semver() {
// semver: alpha.5 < beta.1 (lexicographic on identifiers per spec)
assert_eq!(
needs_update("0.1.150-alpha.5", "0.1.150-beta.1", "alpha", false),
Some(true)
);
}
#[test]
fn test_needs_update_with_build_metadata_uses_semver_crate_ordering() {
// SUBTLE: per the semver SPEC, build metadata (after `+`) MUST be
// ignored when determining version precedence. However the `semver`
// crate's `PartialOrd` impl compares build metadata lexicographically
// for differing values. So `0.1.141+xyz > 0.1.141+abc` returns true
// here even though spec-wise they are equal.
//
// This means CI publishers MUST NOT publish multiple builds of the
// same version differing only in build metadata, or auto-update will
// bounce users between them. Today our pipeline doesn't, so this is
// latent — but the test locks in the surprising behavior so it can't
// change silently.
assert_eq!(
needs_update("0.1.141+abc", "0.1.141+xyz", "stable", false),
Some(true),
"semver crate orders by build metadata lexicographically (contra spec)"
);
// No build metadata vs with build metadata: semver crate treats
// a version with build > the same version without it.
assert_eq!(
needs_update("0.1.141", "0.1.141+abc", "stable", false),
Some(true)
);
}
#[test]
fn test_needs_update_partial_versions_rejected() {
assert_eq!(needs_update("0.1", "0.1.141", "stable", false), None);
assert_eq!(needs_update("0", "0.1.141", "stable", false), None);
assert_eq!(needs_update("0.1.141", "1", "stable", false), None);
}
#[test]
fn test_needs_update_alpha_channel_with_invalid_versions_returns_none() {
// Same parse-failure behavior on alpha as stable.
assert_eq!(needs_update("garbage", "0.1.141", "alpha", false), None);
assert_eq!(needs_update("0.1.141", "garbage", "alpha", false), None);
}
#[test]
fn test_needs_update_alpha_channel_treats_release_as_higher_than_prerelease() {
// On alpha channel, a release version is semver-higher than its
// matching pre-release: 0.1.150 > 0.1.150-alpha.99.
assert_eq!(
needs_update("0.1.150-alpha.99", "0.1.150", "alpha", false),
Some(true)
);
}
#[test]
fn test_needs_update_stable_does_not_install_when_pre_and_pre() {
// current is pre-release, latest is also pre-release on stable channel:
// latest is rejected as pre-release, so no install.
assert_eq!(
needs_update("0.1.150-alpha.1", "0.1.151-alpha.1", "stable", false),
Some(false)
);
}
// ──────────────────────────────────────────────────────────────────────
// needs_update — allow_downgrade=true (rollback support)
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_needs_update_downgrade_stable_when_allowed() {
// Rollback scenario: stable pointer moved from 0.2.7 → 0.2.5.
// GCS/internal installer: allow_downgrade=true → triggers update.
assert_eq!(needs_update("0.2.7", "0.2.5", "stable", true), Some(true));
}
#[test]
fn test_needs_update_downgrade_stable_blocked_when_disallowed() {
// Same rollback scenario but npm installer: allow_downgrade=false → no update.
assert_eq!(needs_update("0.2.7", "0.2.5", "stable", false), Some(false));
}
#[test]
fn test_needs_update_downgrade_alpha_when_allowed() {
// Alpha rollback: pointer moved backward.
assert_eq!(needs_update("0.2.7", "0.2.5", "alpha", true), Some(true));
// Alpha pre-release downgrade.
assert_eq!(
needs_update("0.1.148-alpha.3", "0.1.148-alpha.2", "alpha", true),
Some(true)
);
}
#[test]
fn test_needs_update_downgrade_enterprise_when_allowed() {
assert_eq!(
needs_update("0.1.207", "0.1.206", "enterprise", true),
Some(true)
);
}
#[test]
fn test_needs_update_same_version_unaffected_by_allow_downgrade() {
// Same version → no update regardless of allow_downgrade setting.
assert_eq!(needs_update("0.2.5", "0.2.5", "stable", true), Some(false));
assert_eq!(needs_update("0.2.5", "0.2.5", "stable", false), Some(false));
assert_eq!(needs_update("0.2.5", "0.2.5", "alpha", true), Some(false));
}
#[test]
fn test_needs_update_upgrade_unaffected_by_allow_downgrade() {
// Upgrade works regardless of allow_downgrade setting.
assert_eq!(needs_update("0.2.5", "0.2.7", "stable", true), Some(true));
assert_eq!(needs_update("0.2.5", "0.2.7", "stable", false), Some(true));
assert_eq!(needs_update("0.2.5", "0.2.7", "alpha", true), Some(true));
assert_eq!(needs_update("0.2.5", "0.2.7", "alpha", false), Some(true));
}
#[test]
fn test_needs_update_downgrade_major_version_when_allowed() {
// Major version downgrade (e.g. v2 → v1 rollback).
assert_eq!(needs_update("2.0.0", "1.99.99", "stable", true), Some(true));
}
#[test]
fn test_needs_update_downgrade_prerelease_still_rejected_on_stable() {
// Even with allow_downgrade=true, pre-release targets are rejected on
// stable/enterprise channels (safety net).
assert_eq!(
needs_update("0.2.7", "0.2.5-alpha.1", "stable", true),
Some(false)
);
assert_eq!(
needs_update("0.2.7", "0.2.5-alpha.1", "enterprise", true),
Some(false)
);
}
#[test]
fn test_needs_update_prerelease_current_forces_install_regardless_of_allow_downgrade() {
// Pre-release current on stable channel → force-install, independent
// of allow_downgrade.
assert_eq!(
needs_update("0.1.149-alpha.1", "0.1.148", "stable", true),
Some(true)
);
assert_eq!(
needs_update("0.1.149-alpha.1", "0.1.148", "stable", false),
Some(true)
);
}
// ──────────────────────────────────────────────────────────────────────
// installer_allows_downgrade
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_installer_allows_downgrade_internal() {
assert!(installer_allows_downgrade("internal"));
}
#[test]
fn test_installer_allows_downgrade_gh_release() {
assert!(installer_allows_downgrade("gh-release"));
}
#[test]
fn test_installer_allows_downgrade_npm_blocked() {
// npm registries can return stale/misconfigured versions — no downgrade.
assert!(!installer_allows_downgrade("npm"));
}
#[test]
fn test_installer_allows_downgrade_unknown_blocked() {
assert!(!installer_allows_downgrade("unknown"));
assert!(!installer_allows_downgrade(""));
assert!(!installer_allows_downgrade("homebrew"));
}
// ──────────────────────────────────────────────────────────────────────
// detect_platform
// ──────────────────────────────────────────────────────────────────────
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
#[test]
fn test_detect_platform_returns_known_os() {
let (os, arch) = detect_platform().unwrap();
assert!(
os == "macos" || os == "linux" || os == "windows",
"got os={os}"
);
assert!(arch == "x86_64" || arch == "aarch64", "got arch={arch}");
}
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
#[test]
fn test_detect_platform_matches_compile_time_cfg() {
let (os, arch) = detect_platform().unwrap();
if cfg!(target_os = "macos") {
assert_eq!(os, "macos");
}
if cfg!(target_os = "linux") {
assert_eq!(os, "linux");
}
if cfg!(target_os = "windows") {
assert_eq!(os, "windows");
}
if cfg!(target_arch = "x86_64") {
assert_eq!(arch, "x86_64");
}
if cfg!(target_arch = "aarch64") {
assert_eq!(arch, "aarch64");
}
}
// ──────────────────────────────────────────────────────────────────────
// cleanup_old_downloads — additional edge cases
// ──────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_cleanup_old_downloads_invalid_current_version_is_no_op() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "v140").unwrap();
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "v141").unwrap();
// Invalid version string → cleanup must early-return without deleting.
make_all_stale(d);
cleanup_old_downloads(d, "grok", "not-a-version").await;
assert!(d.join("grok-0.1.140-macos-aarch64").exists());
assert!(d.join("grok-0.1.141-macos-aarch64").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_missing_dir_no_panic() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
// Must not panic when the directory doesn't exist.
cleanup_old_downloads(&missing, "grok", "0.1.141").await;
}
#[tokio::test]
async fn test_cleanup_old_downloads_files_with_non_digit_suffix_skipped() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Files matching prefix but with a non-digit-leading suffix must be
// ignored (e.g. grok-latest, grok-pager-* when prefix is grok).
std::fs::write(d.join("grok-latest"), "alias").unwrap();
std::fs::write(d.join("grok-pager-0.1.141-macos-aarch64"), "pager").unwrap();
std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "v140").unwrap();
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
// grok-latest and grok-pager-* must be untouched.
assert!(d.join("grok-latest").exists());
assert!(d.join("grok-pager-0.1.141-macos-aarch64").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_unparseable_version_skipped() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Files with prefix + digit but unparseable as semver are ignored
// (not deleted, not counted).
std::fs::write(d.join("grok-9garbage-macos-aarch64"), "junk").unwrap();
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(
d.join("grok-9garbage-macos-aarch64").exists(),
"unparseable file must be ignored, not deleted"
);
assert!(d.join("grok-0.1.141-macos-aarch64").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_only_current_present_no_op() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(d.join("grok-0.1.141-macos-aarch64").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_only_one_old_keeps_it() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "v140").unwrap();
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
// Only one old version → keep it as N-1.
assert!(d.join("grok-0.1.140-macos-aarch64").exists(), "N-1 kept");
assert!(d.join("grok-0.1.141-macos-aarch64").exists(), "current");
}
#[tokio::test]
async fn test_cleanup_old_downloads_unrelated_files_untouched() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Files that don't start with the prefix must never be touched.
std::fs::write(d.join("README.md"), "readme").unwrap();
std::fs::write(d.join("config.toml"), "config").unwrap();
std::fs::write(d.join("other-tool-0.1.0"), "other").unwrap();
std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "v140").unwrap();
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(d.join("README.md").exists());
assert!(d.join("config.toml").exists());
assert!(d.join("other-tool-0.1.0").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_multiplatform_in_same_dir() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Same version, multiple platforms (uncommon, but possible).
// Both should be considered "current" via the version equality check.
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "mac").unwrap();
std::fs::write(d.join("grok-0.1.141-linux-x86_64"), "linux").unwrap();
std::fs::write(d.join("grok-0.1.140-macos-aarch64"), "old-mac").unwrap();
std::fs::write(d.join("grok-0.1.139-macos-aarch64"), "older-mac").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
// Both platform variants of current must survive.
assert!(d.join("grok-0.1.141-macos-aarch64").exists());
assert!(d.join("grok-0.1.141-linux-x86_64").exists());
// N-1 (0.1.140) kept, older deleted.
assert!(d.join("grok-0.1.140-macos-aarch64").exists());
assert!(!d.join("grok-0.1.139-macos-aarch64").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_tmp_files_deleted_even_when_unparseable() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Stale tmp files are deleted regardless of version-parseability.
std::fs::write(d.join("grok-junk.tmp"), "partial").unwrap();
make_stale(&d.join("grok-junk.tmp"));
std::fs::write(d.join("grok-0.1.140-macos-aarch64.tmp"), "partial2").unwrap();
make_stale(&d.join("grok-0.1.140-macos-aarch64.tmp"));
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(!d.join("grok-junk.tmp").exists(), "junk tmp deleted");
assert!(
!d.join("grok-0.1.140-macos-aarch64.tmp").exists(),
"versioned tmp deleted"
);
assert!(d.join("grok-0.1.141-macos-aarch64").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_three_olds_keeps_only_newest() {
// Regression: keep exactly N-1, not N-2 or older.
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
for v in ["0.1.138", "0.1.139", "0.1.140"] {
std::fs::write(d.join(format!("grok-{}-macos-aarch64", v)), v).unwrap();
}
std::fs::write(d.join("grok-0.1.141-macos-aarch64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(d.join("grok-0.1.141-macos-aarch64").exists(), "current");
assert!(d.join("grok-0.1.140-macos-aarch64").exists(), "N-1 only");
assert!(!d.join("grok-0.1.139-macos-aarch64").exists());
assert!(!d.join("grok-0.1.138-macos-aarch64").exists());
}
#[tokio::test]
async fn test_cleanup_old_downloads_darwin_platform_recognized() {
// The `darwin` alias for macOS is in PLATFORM_OS — versions on
// grok-X.Y.Z-darwin-* layouts must split correctly.
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
std::fs::write(d.join("grok-0.1.140-darwin-arm64"), "v140").unwrap();
std::fs::write(d.join("grok-0.1.141-darwin-arm64"), "current").unwrap();
make_all_stale(d);
cleanup_old_downloads(d, "grok", "0.1.141").await;
assert!(d.join("grok-0.1.141-darwin-arm64").exists(), "current");
assert!(d.join("grok-0.1.140-darwin-arm64").exists(), "N-1");
}
// ──────────────────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────────────────
// UpdateRunMode
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_update_run_mode_is_copy_clone_debug() {
// The ergonomic Copy/Clone/Debug derives must not regress: we pass
// `run_mode` by value through several layers.
let m1 = UpdateRunMode::Blocking;
let m2 = m1; // Copy
let m3 = m1; // Copy again, m1 not moved
assert!(matches!(m1, UpdateRunMode::Blocking));
assert!(matches!(m2, UpdateRunMode::Blocking));
assert!(matches!(m3, UpdateRunMode::Blocking));
// Debug exists.
let _ = format!("{:?}", UpdateRunMode::NonBlocking);
}
// ──────────────────────────────────────────────────────────────────────
// Constants — lock them in so silent renames are caught.
// ──────────────────────────────────────────────────────────────────────
#[test]
fn test_user_facing_constants_are_stable() {
assert_eq!(PROMPT_UPDATE_NOW, "Update now? [Y/n/d]");
assert_eq!(
MSG_AUTO_UPDATE_BACKGROUND,
"Auto-update running in background."
);
assert_eq!(
MSG_RUN_UPDATE_MANUAL,
"Run `grok update` to get the latest version."
);
}
// ──────────────────────────────────────────────────────────────────────
// env_installer — env-var based, must run serially.
//
// Resolution order (matches function body):
// 1. GROK_INSTALLER (npm | internal | gh-release | gh)
// 2. GROK_MANAGED_BY_NPM → npm
// 3. GROK_MANAGED_BY_INTERNAL → internal
// 4. npm_config_user_agent → npm
// 5. None
// ──────────────────────────────────────────────────────────────────────
/// Snapshot every installer-related env var so the test can clear them
/// at start and restore them at end. Without this, a parent shell that
/// sets e.g. `npm_config_user_agent` (which happens whenever you run via
/// `npm run`) silently makes every "no env vars" test misbehave.
struct InstallerEnvGuard {
prev: Vec<(&'static str, Option<std::ffi::OsString>)>,
}
impl InstallerEnvGuard {
fn isolate() -> Self {
const VARS: &[&str] = &[
"GROK_INSTALLER",
"GROK_MANAGED_BY_NPM",
"GROK_MANAGED_BY_INTERNAL",
"npm_config_user_agent",
"NPM_TOKEN",
];
let prev: Vec<_> = VARS.iter().map(|k| (*k, std::env::var_os(k))).collect();
unsafe {
for k in VARS {
std::env::remove_var(k);
}
}
Self { prev }
}
}
impl Drop for InstallerEnvGuard {
fn drop(&mut self) {
unsafe {
for (k, v) in &self.prev {
match v {
Some(val) => std::env::set_var(k, val),
None => std::env::remove_var(k),
}
}
}
}
}
#[test]
#[serial_test::serial]
fn test_env_installer_no_vars_returns_none() {
let _g = InstallerEnvGuard::isolate();
assert_eq!(env_installer(), None);
}
#[test]
#[serial_test::serial]
fn test_env_installer_explicit_npm() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("GROK_INSTALLER", "npm") };
assert_eq!(env_installer(), Some("npm"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_explicit_internal() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("GROK_INSTALLER", "internal") };
assert_eq!(env_installer(), Some("internal"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_explicit_gh_release() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("GROK_INSTALLER", "gh-release") };
assert_eq!(env_installer(), Some("gh-release"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_explicit_gh_alias() {
// `gh` is shorthand for `gh-release`.
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("GROK_INSTALLER", "gh") };
assert_eq!(env_installer(), Some("gh-release"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_explicit_uppercase_normalized() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("GROK_INSTALLER", "NPM") };
assert_eq!(env_installer(), Some("npm"));
unsafe { std::env::set_var("GROK_INSTALLER", "Gh-Release") };
assert_eq!(env_installer(), Some("gh-release"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_explicit_unknown_value_returns_none() {
// CRITICAL: when the explicit env var is set to something we don't
// recognize, we early-return None. This means we do NOT fall through
// to the other env vars or to config. So `GROK_INSTALLER=brew`
// disables the env-installer detection entirely.
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("GROK_INSTALLER", "brew") };
// Even if MANAGED_BY_NPM is also set, the explicit var wins (and rejects).
unsafe { std::env::set_var("GROK_MANAGED_BY_NPM", "1") };
assert_eq!(
env_installer(),
None,
"explicit GROK_INSTALLER=brew must early-return None, not fall through"
);
}
#[test]
#[serial_test::serial]
fn test_env_installer_explicit_empty_returns_none() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("GROK_INSTALLER", "") };
assert_eq!(env_installer(), None);
}
#[test]
#[serial_test::serial]
fn test_env_installer_managed_by_npm() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("GROK_MANAGED_BY_NPM", "1") };
assert_eq!(env_installer(), Some("npm"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_managed_by_npm_any_value() {
// The check is `is_some` — any value (including empty) wins.
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("GROK_MANAGED_BY_NPM", "") };
assert_eq!(env_installer(), Some("npm"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_managed_by_internal() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("GROK_MANAGED_BY_INTERNAL", "1") };
assert_eq!(env_installer(), Some("internal"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_npm_config_user_agent_implies_npm() {
// npm sets npm_config_user_agent in the env of any process it spawns.
// The trampoline relies on this fallback when MANAGED_BY_NPM was lost.
let _g = InstallerEnvGuard::isolate();
unsafe {
std::env::set_var(
"npm_config_user_agent",
"npm/10.2.0 node/v20.11.0 darwin arm64 workspaces/false",
)
};
assert_eq!(env_installer(), Some("npm"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_managed_by_npm_wins_over_npm_config_user_agent() {
// Both set: the order in env_installer is MANAGED_BY_NPM checked first,
// so MANAGED_BY_NPM wins. (Result is the same — both → npm — but the
// resolution path matters for future maintainers.)
let _g = InstallerEnvGuard::isolate();
unsafe {
std::env::set_var("GROK_MANAGED_BY_NPM", "1");
std::env::set_var("npm_config_user_agent", "npm/10");
}
assert_eq!(env_installer(), Some("npm"));
}
#[test]
#[serial_test::serial]
fn test_env_installer_explicit_internal_wins_over_npm_managed() {
// GROK_INSTALLER=internal must override an inherited MANAGED_BY_NPM.
let _g = InstallerEnvGuard::isolate();
unsafe {
std::env::set_var("GROK_INSTALLER", "internal");
std::env::set_var("GROK_MANAGED_BY_NPM", "1");
}
assert_eq!(env_installer(), Some("internal"));
}
// ──────────────────────────────────────────────────────────────────────
// create_temp_npmrc — also env-var based (NPM_TOKEN), must run serially.
// ──────────────────────────────────────────────────────────────────────
#[test]
#[serial_test::serial]
fn test_create_temp_npmrc_no_token_returns_none() {
let _g = InstallerEnvGuard::isolate();
let result = create_temp_npmrc(None).unwrap();
assert!(result.is_none(), "no NPM_TOKEN must yield None");
}
#[test]
#[serial_test::serial]
fn test_create_temp_npmrc_empty_token_returns_none() {
// An empty token is not a real token — must not write a file.
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("NPM_TOKEN", "") };
let result = create_temp_npmrc(None).unwrap();
assert!(result.is_none(), "empty NPM_TOKEN must yield None");
}
#[test]
#[serial_test::serial]
fn test_create_temp_npmrc_whitespace_only_token_returns_none() {
// Whitespace-only is treated as empty after trim.
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("NPM_TOKEN", " \t\n ") };
let result = create_temp_npmrc(None).unwrap();
assert!(result.is_none(), "whitespace NPM_TOKEN must yield None");
}
#[test]
#[serial_test::serial]
fn test_create_temp_npmrc_default_registry() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("NPM_TOKEN", "secret123") };
let path = create_temp_npmrc(None).unwrap().expect("file written");
let body = std::fs::read_to_string(&path).unwrap();
assert!(
body.contains("registry.npmjs.org"),
"default registry: {body}"
);
assert!(body.contains("_authToken=secret123"), "token: {body}");
assert!(body.starts_with("//"), "must be // prefix: {body}");
assert!(body.ends_with('\n'), "must end with newline: {body}");
let _ = std::fs::remove_file(&path);
}
#[test]
#[serial_test::serial]
fn test_create_temp_npmrc_token_trimmed() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("NPM_TOKEN", " padded-token ") };
let path = create_temp_npmrc(None).unwrap().expect("file written");
let body = std::fs::read_to_string(&path).unwrap();
assert!(
body.contains("_authToken=padded-token"),
"token must be trimmed: {body}"
);
assert!(
!body.contains("padded-token "),
"trailing whitespace must be stripped: {body}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
#[serial_test::serial]
fn test_create_temp_npmrc_custom_registry_extracts_host_and_path() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("NPM_TOKEN", "tok") };
let path = create_temp_npmrc(Some("https://npm.example.com/repository/npm/"))
.unwrap()
.expect("file written");
let body = std::fs::read_to_string(&path).unwrap();
// Host + path must be preserved (trailing slash stripped per impl).
assert!(
body.contains("npm.example.com/repository/npm"),
"registry host+path: {body}"
);
assert!(body.contains("_authToken=tok"));
let _ = std::fs::remove_file(&path);
}
#[test]
#[serial_test::serial]
fn test_create_temp_npmrc_custom_registry_with_port() {
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("NPM_TOKEN", "tok") };
let path = create_temp_npmrc(Some("https://npm.example.com:8443/"))
.unwrap()
.expect("file written");
let body = std::fs::read_to_string(&path).unwrap();
assert!(
body.contains("npm.example.com:8443"),
"port must be preserved: {body}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
#[serial_test::serial]
fn test_create_temp_npmrc_invalid_registry_url_falls_back_to_default() {
// If the registry string doesn't parse as a URL, fall back to the
// public npm host so the auth token isn't silently lost.
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("NPM_TOKEN", "tok") };
let path = create_temp_npmrc(Some("not a url"))
.unwrap()
.expect("file written");
let body = std::fs::read_to_string(&path).unwrap();
assert!(
body.contains("registry.npmjs.org"),
"invalid URL falls back: {body}"
);
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn test_create_temp_npmrc_file_perms_are_0600() {
// The file contains an auth token — must be readable only by owner.
use std::os::unix::fs::PermissionsExt;
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("NPM_TOKEN", "secret") };
let path = create_temp_npmrc(None).unwrap().expect("file written");
let perms = std::fs::metadata(&path).unwrap().permissions();
let mode = perms.mode() & 0o777;
assert_eq!(
mode, 0o600,
"npmrc must be 0600 to protect the auth token, got {mode:o}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
#[serial_test::serial]
fn test_create_temp_npmrc_unique_path_per_pid() {
// Two parallel installs would clobber each other if the path didn't
// include the PID. Verify the filename includes the current PID.
let _g = InstallerEnvGuard::isolate();
unsafe { std::env::set_var("NPM_TOKEN", "tok") };
let path = create_temp_npmrc(None).unwrap().expect("file written");
let pid = std::process::id().to_string();
let name = path.file_name().unwrap().to_string_lossy().to_string();
assert!(
name.contains(&pid),
"filename should include PID: {name} (pid={pid})"
);
let _ = std::fs::remove_file(&path);
}
// ──────────────────────────────────────────────────────────────────────
// windows_replace_exe — runs only on Windows CI
// ──────────────────────────────────────────────────────────────────────
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_creates_dest_when_missing() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new-binary.exe");
std::fs::write(&src, "new content").unwrap();
let dest = dir.path().join("grok.exe");
windows_replace_exe(&src, &dest).await.unwrap();
assert!(dest.exists());
assert_eq!(std::fs::read(&dest).unwrap(), b"new content");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_overwrites_unlocked_dest() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new-binary.exe");
std::fs::write(&src, "new content").unwrap();
let dest = dir.path().join("grok.exe");
std::fs::write(&dest, "old content").unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), b"new content");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_preserves_binary_bytes() {
let dir = tempfile::tempdir().unwrap();
let body: Vec<u8> = (0u8..=255).cycle().take(4096).collect();
let src = dir.path().join("binary.exe");
std::fs::write(&src, &body).unwrap();
let dest = dir.path().join("grok.exe");
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), body);
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_cleans_stale_old_backup() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "new").unwrap();
let dest = dir.path().join("grok.exe");
std::fs::write(&dest, "current").unwrap();
let old = dir.path().join("grok.exe.old");
std::fs::write(&old, "stale-from-prior-update").unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "new");
assert!(!old.exists(), "stale .old must be removed");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_no_filename_returns_err() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.exe");
std::fs::write(&src, "data").unwrap();
let bad_dest = dir.path().join("..");
let err = windows_replace_exe(&src, &bad_dest).await.unwrap_err();
assert!(format!("{err:#}").contains("no filename"), "error: {err:#}");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_locked_file_renames_aside() {
// Simulate a running .exe: blocks writes but allows rename.
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x00000001;
const FILE_SHARE_DELETE: u32 = 0x00000004;
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "updated binary").unwrap();
let dest = dir.path().join("grok.exe");
std::fs::write(&dest, "running binary").unwrap();
let _lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
.open(&dest)
.unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "updated binary");
let old = dir.path().join("grok.exe.old");
assert!(old.exists(), ".old must exist after rename fallback");
drop(_lock);
assert_eq!(std::fs::read_to_string(&old).unwrap(), "running binary");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_rollback_on_copy_failure() {
// No stale .old: the aside IS grok.exe.old, so this pins the
// non-diverted rollback branch (rename .old back onto dest).
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x00000001;
const FILE_SHARE_DELETE: u32 = 0x00000004;
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "updated binary").unwrap();
let dest = dir.path().join("grok.exe");
std::fs::write(&dest, "original").unwrap();
// Dest locked like a running exe: blocks writes but allows rename.
let _dest_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
.open(&dest)
.unwrap();
// Exclusive src lock: both copies fail with a sharing violation, so
// the rename runs and the second copy triggers the rollback.
let _src_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(0)
.open(&src)
.unwrap();
let result = windows_replace_exe(&src, &dest).await;
drop(_src_lock);
drop(_dest_lock);
assert!(result.is_err());
assert_eq!(
std::fs::read_to_string(&dest).unwrap(),
"original",
"rollback must restore the original binary"
);
let old = dir.path().join("grok.exe.old");
assert!(!old.exists(), "rollback must consume the .old aside");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_idempotent_same_content() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("binary.exe");
std::fs::write(&src, "same content").unwrap();
let dest = dir.path().join("grok.exe");
std::fs::write(&dest, "same content").unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "same content");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_empty_binary() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("empty.exe");
std::fs::write(&src, b"").unwrap();
let dest = dir.path().join("grok.exe");
std::fs::write(&dest, "non-empty").unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::metadata(&dest).unwrap().len(), 0);
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_locked_stale_old_does_not_block_update() {
// A leftover .old can still be a running image (the session live
// during the previous update): undeletable, so the rename must
// divert to a unique aside instead of failing on the locked name.
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x00000001;
const FILE_SHARE_DELETE: u32 = 0x00000004;
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "updated binary").unwrap();
let dest = dir.path().join("grok.exe");
std::fs::write(&dest, "running binary").unwrap();
let old = dir.path().join("grok.exe.old");
std::fs::write(&old, "previous binary").unwrap();
// No FILE_SHARE_DELETE: .old cannot be deleted or rename-replaced.
let _old_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ)
.open(&old)
.unwrap();
// Dest locked like a running exe: blocks writes but allows rename.
let _dest_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
.open(&dest)
.unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "updated binary");
assert_eq!(
std::fs::read_to_string(&old).unwrap(),
"previous binary",
"locked .old must be left in place"
);
let asides: Vec<std::path::PathBuf> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("grok.exe.old.") && n.ends_with(".old"))
})
.collect();
assert_eq!(
asides.len(),
1,
"dest must be renamed to a unique aside: {asides:?}"
);
assert_eq!(
std::fs::read_to_string(&asides[0]).unwrap(),
"running binary"
);
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_rollback_restores_from_diverted_aside() {
// Copy failure after a divert must roll dest back from the unique
// aside, not the hardcoded .old (which still holds the locked image).
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 0x00000001;
const FILE_SHARE_DELETE: u32 = 0x00000004;
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "updated binary").unwrap();
let dest = dir.path().join("grok.exe");
std::fs::write(&dest, "running binary").unwrap();
let old = dir.path().join("grok.exe.old");
std::fs::write(&old, "previous binary").unwrap();
// No FILE_SHARE_DELETE: .old survives the sweep and forces a divert.
let _old_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ)
.open(&old)
.unwrap();
// Dest locked like a running exe: blocks writes but allows rename.
let _dest_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE)
.open(&dest)
.unwrap();
// Exclusive src lock: both copies fail with a sharing violation, so
// the rename dance runs and the second copy triggers the rollback.
let _src_lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(0)
.open(&src)
.unwrap();
let result = windows_replace_exe(&src, &dest).await;
assert!(result.is_err());
assert_eq!(
std::fs::read_to_string(&dest).unwrap(),
"running binary",
"rollback must restore dest from the diverted aside"
);
assert_eq!(std::fs::read_to_string(&old).unwrap(), "previous binary");
let leftover_asides = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name.starts_with("grok.exe.old.") && name.ends_with(".old")
})
.count();
assert_eq!(leftover_asides, 0, "rollback must consume the aside");
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_replace_exe_sweeps_accumulated_asides() {
// Asides pile up while superseded sessions keep running; a later
// update must collect the no-longer-locked ones — but never another
// executable's leftovers.
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.exe");
std::fs::write(&src, "new").unwrap();
let dest = dir.path().join("grok.exe");
std::fs::write(&dest, "current").unwrap();
let old = dir.path().join("grok.exe.old");
std::fs::write(&old, "stale").unwrap();
let aside_a = dir.path().join("grok.exe.old.1234-0.old");
let aside_b = dir.path().join("grok.exe.old.1234-1.old");
std::fs::write(&aside_a, "aside-a").unwrap();
std::fs::write(&aside_b, "aside-b").unwrap();
let agent_old = dir.path().join("agent.exe.old");
std::fs::write(&agent_old, "agent-old").unwrap();
windows_replace_exe(&src, &dest).await.unwrap();
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "new");
assert!(!old.exists(), "legacy .old must be swept");
assert!(!aside_a.exists(), "aside must be swept");
assert!(!aside_b.exists(), "aside must be swept");
assert!(
agent_old.exists(),
"other executables' leftovers must be untouched"
);
}
}