Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
385 lines
14 KiB
Rust
385 lines
14 KiB
Rust
//! 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 `KIGI_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 kigi_shell::util::config;
|
|
use tracing::{info, warn};
|
|
|
|
/// 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`.
|
|
///
|
|
/// `KIGI_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("KIGI_TEST_VERSION").ok();
|
|
|
|
// SAFETY: #[serial] excludes other env-touching tests.
|
|
unsafe { std::env::set_var("KIGI_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("KIGI_TEST_VERSION", v) },
|
|
None => unsafe { std::env::remove_var("KIGI_TEST_VERSION") },
|
|
}
|
|
}
|
|
}
|