Synced from monorepo
Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
|
|
@ -119,43 +119,56 @@ pub async fn check_update_status(update_config: &UpdateConfig) -> UpdateStatus {
|
|||
};
|
||||
|
||||
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(¤t_version, &latest_version, &channel, allow_downgrade) {
|
||||
// --check shares the updater's decision, so it never advertises a version
|
||||
// the policy would skip, clamp away, or can't satisfy.
|
||||
Ok(latest) => match plan_for(&config::VersionPolicy::resolve(), latest) {
|
||||
UpdatePlan::Install { target, .. } => {
|
||||
let mut error = None;
|
||||
let update_available = match needs_update(
|
||||
¤t_version,
|
||||
&target,
|
||||
&channel,
|
||||
false,
|
||||
) {
|
||||
Some(value) => value,
|
||||
None => {
|
||||
// Distinguish parse failure from unsupported channel for clearer diagnostics.
|
||||
// Distinguish parse failure from unsupported channel.
|
||||
let parse_ok = semver::Version::parse(¤t_version).is_ok()
|
||||
&& semver::Version::parse(&latest_version).is_ok();
|
||||
&& semver::Version::parse(&target).is_ok();
|
||||
error = Some(if parse_ok {
|
||||
format!(
|
||||
"Unsupported release channel '{}' (current={}, latest={}). \
|
||||
Supported channels: stable, alpha, enterprise.",
|
||||
channel, current_version, latest_version
|
||||
"Unsupported release channel '{channel}' (current={current_version}, latest={target}). \
|
||||
Supported channels: stable, alpha, enterprise."
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Failed to parse versions (current={}, latest={})",
|
||||
current_version, latest_version
|
||||
"Failed to parse versions (current={current_version}, latest={target})"
|
||||
)
|
||||
});
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
UpdateStatus {
|
||||
UpdateStatus {
|
||||
current_version,
|
||||
latest_version: Some(target),
|
||||
update_available,
|
||||
installer,
|
||||
channel,
|
||||
auto_update,
|
||||
error,
|
||||
}
|
||||
}
|
||||
// Policy skips (anti-downgrade) or can't satisfy the floor: no upgrade.
|
||||
UpdatePlan::Skip { latest } | UpdatePlan::Unavailable { latest, .. } => UpdateStatus {
|
||||
current_version,
|
||||
latest_version: Some(latest_version),
|
||||
update_available,
|
||||
latest_version: Some(latest),
|
||||
update_available: false,
|
||||
installer,
|
||||
channel,
|
||||
auto_update,
|
||||
error,
|
||||
}
|
||||
}
|
||||
error: None,
|
||||
},
|
||||
},
|
||||
Err(err) => UpdateStatus {
|
||||
current_version,
|
||||
latest_version: None,
|
||||
|
|
@ -168,6 +181,49 @@ pub async fn check_update_status(update_config: &UpdateConfig) -> UpdateStatus {
|
|||
}
|
||||
}
|
||||
|
||||
enum UpdatePlan {
|
||||
/// Anti-downgrade skip; `latest` is reported to the user.
|
||||
Skip {
|
||||
latest: String,
|
||||
},
|
||||
/// A hard `required_minimum` exceeds the latest release, so nothing satisfies it.
|
||||
Unavailable {
|
||||
latest: String,
|
||||
target: String,
|
||||
},
|
||||
Install {
|
||||
latest: String,
|
||||
target: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Classify a fetched `latest` release under `policy`. Pure; `fetch_update_plan`
|
||||
/// is the IO wrapper. `--check` shares this so it can't diverge from the updater.
|
||||
fn plan_for(policy: &config::VersionPolicy, latest: String) -> UpdatePlan {
|
||||
let Some(target) = policy.resolve_target(&latest) else {
|
||||
return UpdatePlan::Skip { latest };
|
||||
};
|
||||
// A hard `required_minimum` can clamp above the latest release; that version
|
||||
// doesn't exist.
|
||||
if matches!(
|
||||
(semver::Version::parse(&target), semver::Version::parse(&latest)),
|
||||
(Ok(t), Ok(l)) if t > l
|
||||
) {
|
||||
UpdatePlan::Unavailable { latest, target }
|
||||
} else {
|
||||
UpdatePlan::Install { latest, target }
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_update_plan(
|
||||
installer: &str,
|
||||
update_config: &UpdateConfig,
|
||||
policy: &config::VersionPolicy,
|
||||
) -> Result<UpdatePlan> {
|
||||
let latest = fetch_latest_version(installer, update_config).await?;
|
||||
Ok(plan_for(policy, latest))
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -175,15 +231,21 @@ pub async fn check_update_status(update_config: &UpdateConfig) -> UpdateStatus {
|
|||
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()?;
|
||||
let policy = config::VersionPolicy::resolve();
|
||||
let UpdatePlan::Install { target, .. } = fetch_update_plan(installer, update_config, &policy)
|
||||
.await
|
||||
.ok()?
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
needs_update(
|
||||
¤t,
|
||||
&latest,
|
||||
&target,
|
||||
&update_config.channel,
|
||||
installer_allows_downgrade(installer),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
.then_some((installer, latest))
|
||||
.then_some((installer, target))
|
||||
}
|
||||
|
||||
/// Outcome of [`ensure_latest_on_disk`].
|
||||
|
|
@ -224,20 +286,25 @@ pub async fn ensure_latest_on_disk(update_config: &UpdateConfig) -> Result<Ensur
|
|||
};
|
||||
heal_managed_install(installer).await;
|
||||
let allow_downgrade = installer_allows_downgrade(installer);
|
||||
let latest = fetch_latest_version(installer, update_config).await?;
|
||||
let policy = config::VersionPolicy::resolve();
|
||||
let UpdatePlan::Install { target, .. } =
|
||||
fetch_update_plan(installer, update_config, &policy).await?
|
||||
else {
|
||||
return Ok(outcome);
|
||||
};
|
||||
|
||||
let effective_current =
|
||||
disk_version_for_installer(installer).unwrap_or_else(get_installed_grok_version);
|
||||
if needs_update(
|
||||
&effective_current,
|
||||
&latest,
|
||||
&target,
|
||||
&update_config.channel,
|
||||
allow_downgrade,
|
||||
)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
run_install_script(installer, Some(&latest), update_config).await?;
|
||||
outcome.installed = Some(latest.clone());
|
||||
run_install_script(installer, Some(&target), update_config).await?;
|
||||
outcome.installed = Some(target.clone());
|
||||
}
|
||||
|
||||
// Relaunch when the running binary differs from what's on disk in the
|
||||
|
|
@ -403,22 +470,25 @@ pub async fn check_update_background(update_config: &UpdateConfig) -> Background
|
|||
}
|
||||
|
||||
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 policy = config::VersionPolicy::resolve();
|
||||
let target_version = match fetch_update_plan(installer, update_config, &policy).await {
|
||||
Ok(UpdatePlan::Install { target, .. }) => target,
|
||||
Ok(UpdatePlan::Skip { .. } | UpdatePlan::Unavailable { .. }) | Err(_) => {
|
||||
return BackgroundUpdateCheck::none();
|
||||
}
|
||||
};
|
||||
|
||||
let allow_downgrade = installer_allows_downgrade(installer);
|
||||
if !needs_update(
|
||||
¤t_version,
|
||||
&latest_version,
|
||||
&target_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;
|
||||
write_version_cache(&target_version, stable_ptr.as_deref()).await;
|
||||
return BackgroundUpdateCheck::none();
|
||||
}
|
||||
|
||||
|
|
@ -431,7 +501,7 @@ pub async fn check_update_background(update_config: &UpdateConfig) -> Background
|
|||
let disk_needs_download = match disk_version_for_installer(installer) {
|
||||
Some(disk) => needs_update(
|
||||
&disk,
|
||||
&latest_version,
|
||||
&target_version,
|
||||
&update_config.channel,
|
||||
allow_downgrade,
|
||||
)
|
||||
|
|
@ -451,14 +521,16 @@ pub async fn check_update_background(update_config: &UpdateConfig) -> Background
|
|||
}
|
||||
} else {
|
||||
tracing::info!(
|
||||
latest_version = %latest_version,
|
||||
target_version = %target_version,
|
||||
"Background update: target already on disk, skipping download"
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
BackgroundUpdateCheck {
|
||||
update: Some(UpdateAvailable { latest_version }),
|
||||
update: Some(UpdateAvailable {
|
||||
latest_version: target_version,
|
||||
}),
|
||||
download,
|
||||
}
|
||||
}
|
||||
|
|
@ -502,12 +574,13 @@ pub async fn run_update_if_available(
|
|||
}
|
||||
|
||||
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),
|
||||
let policy = config::VersionPolicy::resolve();
|
||||
// Don't write version.json here; only cache after confirming no update is
|
||||
// needed or after a successful install, so a failed background download
|
||||
// doesn't suppress retries for the TTL window.
|
||||
let latest_version = match fetch_update_plan(inst, update_config, &policy).await {
|
||||
Ok(UpdatePlan::Install { target, .. }) => target,
|
||||
Ok(UpdatePlan::Skip { .. } | UpdatePlan::Unavailable { .. }) | Err(_) => return Ok(false),
|
||||
};
|
||||
if !needs_update(
|
||||
¤t_version,
|
||||
|
|
@ -2283,10 +2356,11 @@ pub async fn run_update(
|
|||
heal_managed_install(installer).await;
|
||||
|
||||
let current_version = get_installed_grok_version();
|
||||
let policy = config::VersionPolicy::resolve();
|
||||
|
||||
// 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) {
|
||||
if let Err(e) = crate::version_policy::check_install_target(&policy, version) {
|
||||
anyhow::bail!("{e}");
|
||||
}
|
||||
eprintln!(
|
||||
|
|
@ -2315,18 +2389,33 @@ pub async fn run_update(
|
|||
.unwrap(),
|
||||
);
|
||||
pb.enable_steady_tick(Duration::from_millis(100));
|
||||
let latest_version = fetch_latest_version(installer, update_config).await?;
|
||||
let plan = fetch_update_plan(installer, update_config, &policy).await?;
|
||||
pb.finish_and_clear();
|
||||
|
||||
let install_target = match crate::minimum_version::apply_floor(&latest_version) {
|
||||
Ok(t) => t,
|
||||
Err(e) => anyhow::bail!("{e}"),
|
||||
let (latest_version, install_target) = match plan {
|
||||
UpdatePlan::Skip { latest } => {
|
||||
// Cache so an explicit `grok update` doesn't re-prompt every run.
|
||||
let stable_ptr = try_fetch_stable_pointer().await;
|
||||
write_version_cache(&latest, stable_ptr.as_deref()).await;
|
||||
eprintln!(
|
||||
"The latest release ({latest}) is not an allowed update; \
|
||||
keeping the current version ({current_version})."
|
||||
);
|
||||
refresh_deployment_config().await;
|
||||
return Ok(None);
|
||||
}
|
||||
UpdatePlan::Unavailable { latest, target } => {
|
||||
anyhow::bail!(
|
||||
"The required minimum version ({target}) is newer than the latest \
|
||||
available release ({latest}). Contact your administrator."
|
||||
);
|
||||
}
|
||||
UpdatePlan::Install { latest, target } => (latest, target),
|
||||
};
|
||||
if install_target != latest_version {
|
||||
eprintln!(
|
||||
"Latest available is {} but the configured minimum is higher; \
|
||||
installing {} instead.",
|
||||
latest_version, install_target
|
||||
"Latest available is {latest_version}, but your configured version range \
|
||||
allows {install_target}; installing that instead."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
pub mod auto_update;
|
||||
mod minimum_version;
|
||||
pub mod version;
|
||||
mod version_policy;
|
||||
|
||||
pub use auto_update::UpdateStatus;
|
||||
pub use minimum_version::enforce_minimum_version_or_exit;
|
||||
pub use version::{UpdateConfig, channel_label, channel_name, write_version_cache};
|
||||
pub use version_policy::enforce_version_policy_or_exit;
|
||||
|
|
|
|||
|
|
@ -1,384 +0,0 @@
|
|||
//! Minimum-version enforcement.
|
||||
//!
|
||||
//! When `cli.minimum_version` is set in any config layer, Grok refuses to
|
||||
//! start below that floor. With auto-update on, we install
|
||||
//! `max(latest, minimum)`; otherwise the user is asked to run `grok update`.
|
||||
//!
|
||||
//! Set `GROK_TEST_VERSION` to manually exercise either path without producing
|
||||
//! a real out-of-date build.
|
||||
|
||||
use crate::auto_update::{get_installer, run_install_script};
|
||||
use crate::version::{
|
||||
UpdateConfig, fetch_latest_version, get_installed_grok_version, write_version_cache,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
use xai_grok_shell::util::config;
|
||||
|
||||
/// Result of comparing the running binary against a configured floor.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum MinimumVersionDecision {
|
||||
Allow,
|
||||
BelowMinimum { current: String, minimum: String },
|
||||
}
|
||||
|
||||
/// Outcome of a successful enforcement pass.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum EnforcementOutcome {
|
||||
Allowed,
|
||||
/// New binary on disk; caller MUST restart — running process is still old.
|
||||
Upgraded,
|
||||
}
|
||||
|
||||
/// User-facing enforcement failures; `Display` is printed to stderr.
|
||||
/// `AutoUpdateDisabled` and `NoInstaller` share copy but stay separate so
|
||||
/// telemetry can distinguish them.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum MinimumVersionError {
|
||||
/// `source` chains via `Error::source()`; omitted from `Display`.
|
||||
#[error(
|
||||
"The minimum version \"{value}\" in your Grok configuration \
|
||||
isn't a valid version number. Update `cli.minimum_version` and try again."
|
||||
)]
|
||||
InvalidMinimum {
|
||||
value: String,
|
||||
#[source]
|
||||
source: semver::Error,
|
||||
},
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
Run `grok update` to install version {minimum} or later."
|
||||
)]
|
||||
AutoUpdateDisabled { current: String, minimum: String },
|
||||
/// `npm` / `gh` / `internal` GCS — none detected.
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
Run `grok update` to install version {minimum} or later."
|
||||
)]
|
||||
NoInstaller { current: String, minimum: String },
|
||||
/// `detail` is telemetry-only; omitted from `Display` to avoid stacking
|
||||
/// the installer's own action language.
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported, \
|
||||
and the update to version {minimum} didn't complete.\n\n\
|
||||
Run `grok update` to try again."
|
||||
)]
|
||||
UpgradeFailed {
|
||||
current: String,
|
||||
minimum: String,
|
||||
detail: String,
|
||||
},
|
||||
/// Latest release is known but still below the floor (vs `NoReleaseFound`,
|
||||
/// which couldn't probe at all).
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
Version {minimum} or later is required, but the most recent release is {latest}. \
|
||||
Contact your administrator."
|
||||
)]
|
||||
NoSatisfyingVersion {
|
||||
current: String,
|
||||
minimum: String,
|
||||
latest: String,
|
||||
},
|
||||
/// Couldn't probe the registry — likely transient.
|
||||
#[error(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
Version {minimum} or later is required, but no release was found. \
|
||||
Check your network connection, or contact your administrator."
|
||||
)]
|
||||
NoReleaseFound { current: String, minimum: String },
|
||||
/// `grok update --version X` requested a version below the floor.
|
||||
#[error(
|
||||
"Cannot install Grok {target}: the configured minimum is {minimum}. \
|
||||
Run `grok update` to install the latest allowed version."
|
||||
)]
|
||||
TargetBelowFloor { target: String, minimum: String },
|
||||
}
|
||||
|
||||
/// Pure check against the configured floor. Empty / whitespace-only
|
||||
/// minimums are treated as unset.
|
||||
fn evaluate_minimum_version(
|
||||
current_version: &str,
|
||||
minimum_version: Option<&str>,
|
||||
) -> Result<MinimumVersionDecision, MinimumVersionError> {
|
||||
let Some(minimum) = minimum_version.map(str::trim).filter(|s| !s.is_empty()) else {
|
||||
return Ok(MinimumVersionDecision::Allow);
|
||||
};
|
||||
|
||||
let parsed_min =
|
||||
semver::Version::parse(minimum).map_err(|source| MinimumVersionError::InvalidMinimum {
|
||||
value: minimum.to_string(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
// Unparseable current (e.g. funky dev build): block rather than let an
|
||||
// unverifiable binary through.
|
||||
let parsed_cur = match semver::Version::parse(current_version) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return Ok(MinimumVersionDecision::BelowMinimum {
|
||||
current: current_version.to_string(),
|
||||
minimum: parsed_min.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if parsed_cur >= parsed_min {
|
||||
Ok(MinimumVersionDecision::Allow)
|
||||
} else {
|
||||
Ok(MinimumVersionDecision::BelowMinimum {
|
||||
current: parsed_cur.to_string(),
|
||||
minimum: parsed_min.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse an explicit install target below the configured floor.
|
||||
/// Used by `grok update --version X`.
|
||||
pub(crate) fn check_install_target(target: &str) -> Result<(), MinimumVersionError> {
|
||||
let floor = resolve_floor_or_error()?;
|
||||
check_install_target_inner(target, floor.as_deref())
|
||||
}
|
||||
|
||||
fn check_install_target_inner(
|
||||
target: &str,
|
||||
floor: Option<&str>,
|
||||
) -> Result<(), MinimumVersionError> {
|
||||
let Some(min) = floor else { return Ok(()) };
|
||||
match evaluate_minimum_version(target, Some(min))? {
|
||||
MinimumVersionDecision::Allow => Ok(()),
|
||||
MinimumVersionDecision::BelowMinimum {
|
||||
current: target,
|
||||
minimum,
|
||||
} => Err(MinimumVersionError::TargetBelowFloor { target, minimum }),
|
||||
}
|
||||
}
|
||||
|
||||
/// `max(target, configured_floor)`; passthrough when no floor is set.
|
||||
/// Used by `grok update` to keep the install target at or above the pin.
|
||||
pub(crate) fn apply_floor(target: &str) -> Result<String, MinimumVersionError> {
|
||||
let floor = resolve_floor_or_error()?;
|
||||
apply_floor_inner(target, floor.as_deref())
|
||||
}
|
||||
|
||||
/// Adapts `config::resolve_minimum_version`'s error shape into ours.
|
||||
fn resolve_floor_or_error() -> Result<Option<String>, MinimumVersionError> {
|
||||
config::resolve_minimum_version()
|
||||
.map_err(|(value, source)| MinimumVersionError::InvalidMinimum { value, source })
|
||||
}
|
||||
|
||||
fn apply_floor_inner(target: &str, floor: Option<&str>) -> Result<String, MinimumVersionError> {
|
||||
let Some(min) = floor else {
|
||||
return Ok(target.to_string());
|
||||
};
|
||||
match evaluate_minimum_version(target, Some(min))? {
|
||||
MinimumVersionDecision::Allow => Ok(target.to_string()),
|
||||
MinimumVersionDecision::BelowMinimum { minimum, .. } => Ok(minimum),
|
||||
}
|
||||
}
|
||||
|
||||
/// `max(latest, minimum)`; falls back to `minimum` if `latest` is missing or unparseable.
|
||||
fn pick_target_version(latest: Option<&str>, minimum: &str) -> String {
|
||||
match latest.and_then(|v| semver::Version::parse(v).ok()) {
|
||||
Some(latest_v) => match semver::Version::parse(minimum) {
|
||||
Ok(min_v) if latest_v >= min_v => latest_v.to_string(),
|
||||
_ => minimum.to_string(),
|
||||
},
|
||||
None => minimum.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Call once at startup, before any user-facing UI. On `Ok(Upgraded)` the
|
||||
/// caller MUST restart. On `Err`, print and exit non-zero.
|
||||
async fn enforce_minimum_version(
|
||||
minimum_version: Option<&str>,
|
||||
update_config: &UpdateConfig,
|
||||
) -> Result<EnforcementOutcome, MinimumVersionError> {
|
||||
let current_version = get_installed_grok_version();
|
||||
let decision = evaluate_minimum_version(¤t_version, minimum_version)?;
|
||||
let MinimumVersionDecision::BelowMinimum { current, minimum } = decision else {
|
||||
info!(current = %current_version, "minimum_version: floor satisfied");
|
||||
return Ok(EnforcementOutcome::Allowed);
|
||||
};
|
||||
|
||||
info!(%current, %minimum, "minimum_version: below floor; attempting auto-update");
|
||||
|
||||
// `None` is "default on"; only explicit `false` opts out.
|
||||
let cfg = config::load_config().await;
|
||||
if cfg.cli.auto_update == Some(false) {
|
||||
warn!(%current, %minimum, "minimum_version: auto-update disabled by config");
|
||||
return Err(MinimumVersionError::AutoUpdateDisabled { current, minimum });
|
||||
}
|
||||
|
||||
let Some(installer) = get_installer().await else {
|
||||
warn!(%current, %minimum, "minimum_version: no installer detected");
|
||||
return Err(MinimumVersionError::NoInstaller { current, minimum });
|
||||
};
|
||||
|
||||
let latest = fetch_latest_version(installer, update_config).await.ok();
|
||||
let target = pick_target_version(latest.as_deref(), &minimum);
|
||||
|
||||
info!(%current, %target, installer, "minimum_version: installing upgrade");
|
||||
eprintln!(
|
||||
"This version of Grok ({current}) is no longer supported. \
|
||||
Updating to {target}…"
|
||||
);
|
||||
|
||||
if let Err(e) = run_install_script(installer, Some(&target), update_config).await {
|
||||
let detail = format!("{e:#}");
|
||||
warn!(%current, %target, %detail, "minimum_version: upgrade failed");
|
||||
return Err(MinimumVersionError::UpgradeFailed {
|
||||
current,
|
||||
minimum,
|
||||
detail,
|
||||
});
|
||||
}
|
||||
|
||||
// Post-install: pass None for stable_version (same rationale as run_update).
|
||||
write_version_cache(&target, None).await;
|
||||
|
||||
// Stale channel pointer or partial install can leave us below the floor;
|
||||
// surface that rather than starting an out-of-policy binary.
|
||||
if let MinimumVersionDecision::BelowMinimum { .. } =
|
||||
evaluate_minimum_version(&target, Some(&minimum))?
|
||||
{
|
||||
warn!(%target, %minimum, ?latest, "minimum_version: post-install still below floor");
|
||||
return Err(match latest {
|
||||
Some(latest) => MinimumVersionError::NoSatisfyingVersion {
|
||||
current: target,
|
||||
minimum,
|
||||
latest,
|
||||
},
|
||||
None => MinimumVersionError::NoReleaseFound {
|
||||
current: target,
|
||||
minimum,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
info!(%target, "minimum_version: upgrade installed successfully");
|
||||
Ok(EnforcementOutcome::Upgraded)
|
||||
}
|
||||
|
||||
/// Single chokepoint for the pager + tui startup paths. Re-execs after a
|
||||
/// floor-driven install. Prints + exits non-zero on `Err`.
|
||||
///
|
||||
/// `GROK_TEST_VERSION` lets devs override the running version to skip
|
||||
/// enforcement on a `cargo run` build.
|
||||
pub async fn enforce_minimum_version_or_exit(update_config: &UpdateConfig) {
|
||||
let min = match resolve_floor_or_error() {
|
||||
Ok(None) => return,
|
||||
Ok(Some(m)) => m,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
match enforce_minimum_version(Some(&min), update_config).await {
|
||||
Ok(EnforcementOutcome::Allowed) => {}
|
||||
Ok(EnforcementOutcome::Upgraded) => {
|
||||
// TODO: restart_grok uses exec() which carries the same
|
||||
// SIGABRT risk as the old piped-stderr update path if the
|
||||
// child process ever writes to a broken pipe. For now this
|
||||
// path is rare (only fires when the server pushes a minimum
|
||||
// version bump), so print a relaunch message instead.
|
||||
eprintln!("Update installed. Run `grok` to start.");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn evaluate_minimum_version_decisions() {
|
||||
use MinimumVersionDecision::{Allow, BelowMinimum};
|
||||
|
||||
// Allow: floor unset (None / empty / whitespace) or satisfied (equal / above).
|
||||
assert_eq!(evaluate_minimum_version("0.1.100", None).unwrap(), Allow);
|
||||
assert_eq!(
|
||||
evaluate_minimum_version("0.1.100", Some("")).unwrap(),
|
||||
Allow
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_minimum_version("0.1.100", Some(" ")).unwrap(),
|
||||
Allow
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_minimum_version("0.1.100", Some("0.1.100")).unwrap(),
|
||||
Allow
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_minimum_version("0.2.0", Some("0.1.100")).unwrap(),
|
||||
Allow
|
||||
);
|
||||
|
||||
// BelowMinimum: current < floor.
|
||||
assert!(matches!(
|
||||
evaluate_minimum_version("0.1.99", Some("0.1.100")).unwrap(),
|
||||
BelowMinimum { .. }
|
||||
));
|
||||
|
||||
// InvalidMinimum: unparseable floor (admin typo).
|
||||
assert!(matches!(
|
||||
evaluate_minimum_version("0.1.100", Some("not-a-version")),
|
||||
Err(MinimumVersionError::InvalidMinimum { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_target_returns_max_of_latest_and_minimum() {
|
||||
// The `None` branch is only reachable here — apply_floor always
|
||||
// passes `Some(target)`. Production hits it on fetch failure.
|
||||
assert_eq!(pick_target_version(Some("0.1.200"), "0.1.150"), "0.1.200");
|
||||
assert_eq!(pick_target_version(Some("0.1.140"), "0.1.150"), "0.1.150");
|
||||
assert_eq!(pick_target_version(None, "0.1.150"), "0.1.150");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_target_helpers_consult_floor() {
|
||||
// check_install_target rejects below-floor targets.
|
||||
assert!(check_install_target_inner("0.1.50", None).is_ok());
|
||||
assert!(check_install_target_inner("0.1.150", Some("0.1.100")).is_ok());
|
||||
assert!(matches!(
|
||||
check_install_target_inner("0.1.50", Some("0.1.100")).unwrap_err(),
|
||||
MinimumVersionError::TargetBelowFloor { .. }
|
||||
));
|
||||
|
||||
// apply_floor bumps below-floor targets up.
|
||||
assert_eq!(apply_floor_inner("0.1.50", None).unwrap(), "0.1.50");
|
||||
assert_eq!(
|
||||
apply_floor_inner("0.1.200", Some("0.1.100")).unwrap(),
|
||||
"0.1.200"
|
||||
);
|
||||
assert_eq!(
|
||||
apply_floor_inner("0.1.50", Some("0.1.100")).unwrap(),
|
||||
"0.1.100"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn version_env_var_flows_through_to_decision() {
|
||||
let saved = std::env::var("GROK_TEST_VERSION").ok();
|
||||
|
||||
// SAFETY: #[serial] excludes other env-touching tests.
|
||||
unsafe { std::env::set_var("GROK_TEST_VERSION", "0.1.50") };
|
||||
let decision =
|
||||
evaluate_minimum_version(&get_installed_grok_version(), Some("0.1.100")).unwrap();
|
||||
assert!(matches!(
|
||||
decision,
|
||||
MinimumVersionDecision::BelowMinimum { .. }
|
||||
));
|
||||
|
||||
match saved {
|
||||
Some(v) => unsafe { std::env::set_var("GROK_TEST_VERSION", v) },
|
||||
None => unsafe { std::env::remove_var("GROK_TEST_VERSION") },
|
||||
}
|
||||
}
|
||||
}
|
||||
208
crates/codegen/xai-grok-update/src/version_policy.rs
Normal file
208
crates/codegen/xai-grok-update/src/version_policy.rs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
//! Startup enforcement of the version policy. The hard `required_*` bounds gate
|
||||
//! startup; `minimum`/`maximum` are updater-only. Every knob fails open.
|
||||
|
||||
use crate::version::get_installed_grok_version;
|
||||
use semver::Version;
|
||||
use tracing::warn;
|
||||
use xai_grok_shell::util::config::VersionPolicy;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum RequiredRangeDecision {
|
||||
InRange,
|
||||
Below { current: String, minimum: String },
|
||||
Above { current: String, maximum: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum VersionPolicyError {
|
||||
#[error(
|
||||
"Cannot install Grok {target}: the minimum allowed version is {minimum}. \
|
||||
Run `grok update` to install the latest allowed version."
|
||||
)]
|
||||
TargetBelowFloor { target: String, minimum: String },
|
||||
}
|
||||
|
||||
/// Fails open: a contradictory range or an unparseable running version yields
|
||||
/// `InRange`.
|
||||
fn evaluate_required_range(current_version: &str, policy: &VersionPolicy) -> RequiredRangeDecision {
|
||||
if policy.has_contradictory_required_range() {
|
||||
warn!(
|
||||
required_min = ?policy.required_minimum,
|
||||
required_max = ?policy.required_maximum,
|
||||
"required version range is contradictory (min > max); ignoring"
|
||||
);
|
||||
return RequiredRangeDecision::InRange;
|
||||
}
|
||||
|
||||
let Ok(cur) = Version::parse(current_version) else {
|
||||
return RequiredRangeDecision::InRange;
|
||||
};
|
||||
|
||||
if let Some(mn) = &policy.required_minimum
|
||||
&& cur < *mn
|
||||
{
|
||||
return RequiredRangeDecision::Below {
|
||||
current: cur.to_string(),
|
||||
minimum: mn.to_string(),
|
||||
};
|
||||
}
|
||||
if let Some(mx) = &policy.required_maximum
|
||||
&& cur > *mx
|
||||
{
|
||||
return RequiredRangeDecision::Above {
|
||||
current: cur.to_string(),
|
||||
maximum: mx.to_string(),
|
||||
};
|
||||
}
|
||||
RequiredRangeDecision::InRange
|
||||
}
|
||||
|
||||
/// Reject an explicit `--version` pin below the hard floor. A pin above the
|
||||
/// ceiling is allowed so a too-new install can recover.
|
||||
pub(crate) fn check_install_target(
|
||||
policy: &VersionPolicy,
|
||||
target: &str,
|
||||
) -> Result<(), VersionPolicyError> {
|
||||
let Some(min) = policy.installable_floor() else {
|
||||
return Ok(());
|
||||
};
|
||||
if !matches!(Version::parse(target), Ok(t) if t >= min) {
|
||||
return Err(VersionPolicyError::TargetBelowFloor {
|
||||
target: target.to_string(),
|
||||
minimum: min.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn required_range_message(decision: &RequiredRangeDecision) -> Option<String> {
|
||||
match decision {
|
||||
RequiredRangeDecision::InRange => None,
|
||||
RequiredRangeDecision::Below { current, minimum } => Some(format!(
|
||||
"This version of Grok ({current}) is older than the minimum required \
|
||||
by your organization ({minimum}).\n\n\
|
||||
Update to an approved version through your organization's approved \
|
||||
method (for example, run `grok update`)."
|
||||
)),
|
||||
RequiredRangeDecision::Above { current, maximum } => Some(format!(
|
||||
"This version of Grok ({current}) is newer than the maximum allowed \
|
||||
by your organization ({maximum}).\n\n\
|
||||
Install an approved version through your organization's approved \
|
||||
method (for example, run `grok update --version {maximum}`)."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse to start when the running version is outside the required range.
|
||||
/// Recovery subcommands return before this, so they stay usable.
|
||||
pub fn enforce_version_policy_or_exit() {
|
||||
let policy = VersionPolicy::resolve();
|
||||
let current = get_installed_grok_version();
|
||||
let decision = evaluate_required_range(¤t, &policy);
|
||||
if let Some(message) = required_range_message(&decision) {
|
||||
warn!(?decision, "required version range: refusing to start");
|
||||
eprintln!("{message}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn v(s: &str) -> Version {
|
||||
Version::parse(s).unwrap()
|
||||
}
|
||||
|
||||
fn pol(
|
||||
min: Option<&str>,
|
||||
max: Option<&str>,
|
||||
rmin: Option<&str>,
|
||||
rmax: Option<&str>,
|
||||
) -> VersionPolicy {
|
||||
VersionPolicy {
|
||||
minimum: min.map(v),
|
||||
maximum: max.map(v),
|
||||
required_minimum: rmin.map(v),
|
||||
required_maximum: rmax.map(v),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_install_target_enforces_only_the_hard_floor() {
|
||||
assert!(check_install_target(&pol(Some("0.2.100"), None, None, None), "0.2.50").is_ok());
|
||||
let hard = pol(None, None, Some("0.1.100"), None);
|
||||
assert!(check_install_target(&hard, "0.1.150").is_ok());
|
||||
assert!(matches!(
|
||||
check_install_target(&hard, "0.1.50").unwrap_err(),
|
||||
VersionPolicyError::TargetBelowFloor { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
check_install_target(&hard, "garbage").unwrap_err(),
|
||||
VersionPolicyError::TargetBelowFloor { .. }
|
||||
));
|
||||
assert!(check_install_target(&pol(None, None, None, None), "garbage").is_ok());
|
||||
assert!(
|
||||
check_install_target(&pol(None, None, Some("0.3.0"), Some("0.2.0")), "0.1.0").is_ok()
|
||||
);
|
||||
assert!(
|
||||
check_install_target(
|
||||
&pol(None, None, Some("0.2.100"), Some("0.2.150")),
|
||||
"0.2.200"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_required_range_gates_and_fails_open() {
|
||||
use RequiredRangeDecision::{Above, Below, InRange};
|
||||
|
||||
assert_eq!(
|
||||
evaluate_required_range(
|
||||
"0.2.100",
|
||||
&pol(None, None, Some("0.2.100"), Some("0.2.150"))
|
||||
),
|
||||
InRange
|
||||
);
|
||||
assert!(matches!(
|
||||
evaluate_required_range("0.2.99", &pol(None, None, Some("0.2.100"), None)),
|
||||
Below { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
evaluate_required_range("0.2.200", &pol(None, None, None, Some("0.2.150"))),
|
||||
Above { .. }
|
||||
));
|
||||
assert_eq!(
|
||||
evaluate_required_range("0.2.50", &pol(None, None, Some("0.3.0"), Some("0.2.0"))),
|
||||
InRange
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_required_range("dev-build", &pol(None, None, Some("0.2.100"), None)),
|
||||
InRange
|
||||
);
|
||||
assert_eq!(
|
||||
evaluate_required_range("0.2.50", &pol(Some("0.2.100"), None, None, None)),
|
||||
InRange
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_range_message_is_none_only_when_in_range() {
|
||||
assert!(required_range_message(&RequiredRangeDecision::InRange).is_none());
|
||||
assert!(
|
||||
required_range_message(&RequiredRangeDecision::Below {
|
||||
current: "0.2.99".into(),
|
||||
minimum: "0.2.100".into(),
|
||||
})
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
required_range_message(&RequiredRangeDecision::Above {
|
||||
current: "0.2.200".into(),
|
||||
maximum: "0.2.150".into(),
|
||||
})
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue