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:
grokkybara[bot] 2026-07-23 17:12:33 +00:00
commit 69f0ba880a
286 changed files with 22939 additions and 9624 deletions

View file

@ -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(&current_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(
&current_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(&current_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(
&current,
&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(
&current_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(
&current_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."
);
}

View file

@ -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;

View file

@ -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(&current_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") },
}
}
}

View 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(&current, &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()
);
}
}