Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
47
crates/codegen/xai-grok-shell-base/Cargo.toml
Normal file
47
crates/codegen/xai-grok-shell-base/Cargo.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
[package]
|
||||
license = "Apache-2.0"
|
||||
name = "xai-grok-shell-base"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Foundation modules for the grok shell crate family: environment presets, CPU profiling, and process/filesystem utilities."
|
||||
|
||||
[features]
|
||||
# CI default set: builds a single shared rlib per crate, so downstream test
|
||||
# targets need the test-only helper surface compiled in.
|
||||
default-bazel = []
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["blocking"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tracing = { workspace = true }
|
||||
url = { workspace = true }
|
||||
xai-grok-config = { workspace = true }
|
||||
xai-grok-env = { workspace = true }
|
||||
xai-grok-shared = { workspace = true }
|
||||
xai-grok-version = { workspace = true }
|
||||
xai-tty-utils = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
nix = { workspace = true }
|
||||
# No `flamegraph` feature: that pulls in inferno (CDDL-1.0), which we keep out
|
||||
# of shipped binaries. stop() emits folded stacks via pprof's public Report
|
||||
# API instead; render externally with speedscope or inferno-flamegraph.
|
||||
pprof = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
# `cfg(test)` in this crate does not enable features on dependencies, so the
|
||||
# tests' `EnvVarGuard` re-export needs the feature turned on explicitly.
|
||||
xai-grok-env = { workspace = true, features = [] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
1171
crates/codegen/xai-grok-shell-base/src/cpu_profile.rs
Normal file
1171
crates/codegen/xai-grok-shell-base/src/cpu_profile.rs
Normal file
File diff suppressed because it is too large
Load diff
120
crates/codegen/xai-grok-shell-base/src/env.rs
Normal file
120
crates/codegen/xai-grok-shell-base/src/env.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
//! GrokBuildEnvironment configuration for the shell crate family.
|
||||
//!
|
||||
//! The environment presets (per-environment endpoint URLs, the staging
|
||||
//! trust check, `EnvVarGuard`) live in the [`xai_grok_env`] leaf crate so
|
||||
//! sibling crates (telemetry, tools, workspace) can share them without
|
||||
//! depending on this crate. This module re-exports them and hosts the
|
||||
//! shell-specific gateway-bridge env vars.
|
||||
//!
|
||||
//! # Gateway-bridge mode (env-only)
|
||||
//! - `GROK_GATEWAY_URL` — when set to a valid URL, `MvpAgent` spawns a
|
||||
//! per-session gateway bridge actor and routes prompts through
|
||||
//! it. Unset → falls back to [`GrokBuildEnvironment::gateway_ws_url`] for
|
||||
//! sessions created in gateway mode; otherwise local-mode (unchanged).
|
||||
#[cfg(test)]
|
||||
pub use xai_grok_env::EnvVarGuard;
|
||||
pub use xai_grok_env::{
|
||||
GrokBuildEnvironment, PROD_ASSET_SERVER_URL, PROD_CLI_CHAT_PROXY_BASE_URL, PROD_GATEWAY_WS_URL,
|
||||
PROD_RELAY_WS_URL, PROD_WS_ORIGIN,
|
||||
};
|
||||
/// Env var that opts a process into gateway-bridge mode. When set to
|
||||
/// a parseable URL, `session/new` / `session/load` spawns a per-session
|
||||
/// `gateway_bridge` actor in the shell; unset → local-mode (unchanged).
|
||||
pub const GROK_GATEWAY_URL_ENV: &str = "GROK_GATEWAY_URL";
|
||||
/// Client kill switch for the gateway-bridge custom-method passthrough.
|
||||
/// Set to `1` / `true` to force every `custom_method` call back onto
|
||||
/// agent-local dispatch regardless of the routing table or negotiated
|
||||
/// capability — an instant revert without a redeploy if the channel
|
||||
/// misbehaves. Unset/`0`/`false` → normal routing.
|
||||
pub const GROK_DISABLE_CUSTOM_BRIDGE_ENV: &str = "GROK_DISABLE_CUSTOM_BRIDGE";
|
||||
/// `true` when the custom-method bridge passthrough is force-disabled via
|
||||
/// [`GROK_DISABLE_CUSTOM_BRIDGE_ENV`]. Accepts `1`/`true` (case-insensitive).
|
||||
pub fn custom_bridge_disabled() -> bool {
|
||||
std::env::var(GROK_DISABLE_CUSTOM_BRIDGE_ENV)
|
||||
.map(|v| {
|
||||
let v = v.trim();
|
||||
v == "1" || v.eq_ignore_ascii_case("true")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// Parse `GROK_GATEWAY_URL` into a [`url::Url`]. Unset, empty, or
|
||||
/// malformed → `None` (malformed is warned and falls back to local
|
||||
/// mode so the shell doesn't refuse to start).
|
||||
///
|
||||
/// The malformed-URL warning intentionally does **not** log the raw
|
||||
/// env-var value — a mistyped credential URL of the form
|
||||
/// `wss://user:pass@host` would leak `pass` if the parse failed. The
|
||||
/// operator can inspect their own env var directly.
|
||||
///
|
||||
/// Hard-off (always `None`) without the `chat` feature so release
|
||||
/// builds can't activate the bridge via env.
|
||||
pub fn parse_gateway_url() -> Option<url::Url> {
|
||||
let raw = std::env::var(GROK_GATEWAY_URL_ENV).ok()?;
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if true {
|
||||
tracing::warn!(
|
||||
env = GROK_GATEWAY_URL_ENV,
|
||||
"GROK_GATEWAY_URL is set but this build lacks the `chat` feature; staying in local mode"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
match url::Url::parse(&raw) {
|
||||
Ok(url) => Some(url),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
env = GROK_GATEWAY_URL_ENV, error = % err,
|
||||
"GROK_GATEWAY_URL is not a valid URL; falling back to local mode (raw value omitted to avoid leaking userinfo)"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn parse_gateway_url_returns_none_when_unset() {
|
||||
let _env = EnvVarGuard::remove(GROK_GATEWAY_URL_ENV);
|
||||
assert!(parse_gateway_url().is_none());
|
||||
}
|
||||
#[test]
|
||||
fn parse_gateway_url_returns_none_when_empty() {
|
||||
let _env = EnvVarGuard::set(GROK_GATEWAY_URL_ENV, "");
|
||||
assert!(parse_gateway_url().is_none());
|
||||
}
|
||||
#[test]
|
||||
fn parse_gateway_url_returns_none_for_malformed_url() {
|
||||
let _env = EnvVarGuard::set(GROK_GATEWAY_URL_ENV, "not a url");
|
||||
assert!(
|
||||
parse_gateway_url().is_none(),
|
||||
"malformed URL falls back to None"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn custom_bridge_disabled_defaults_false_when_unset() {
|
||||
let _env = EnvVarGuard::remove(GROK_DISABLE_CUSTOM_BRIDGE_ENV);
|
||||
assert!(!custom_bridge_disabled());
|
||||
}
|
||||
#[test]
|
||||
fn custom_bridge_disabled_true_for_one_and_true() {
|
||||
for v in ["1", "true", "TRUE", " true "] {
|
||||
let _env = EnvVarGuard::set(GROK_DISABLE_CUSTOM_BRIDGE_ENV, v);
|
||||
assert!(
|
||||
custom_bridge_disabled(),
|
||||
"{v:?} must disable the custom bridge"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn custom_bridge_disabled_false_for_zero_and_garbage() {
|
||||
for v in ["0", "false", "", "no"] {
|
||||
let _env = EnvVarGuard::set(GROK_DISABLE_CUSTOM_BRIDGE_ENV, v);
|
||||
assert!(
|
||||
!custom_bridge_disabled(),
|
||||
"{v:?} must leave the custom bridge enabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
7
crates/codegen/xai-grok-shell-base/src/lib.rs
Normal file
7
crates/codegen/xai-grok-shell-base/src/lib.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
//! Foundation modules shared by the grok shell crate family. Extracted from
|
||||
//! `xai-grok-shell` (which re-exports them at their original paths) so they
|
||||
//! build in parallel and stop rebuilding on shell edits.
|
||||
|
||||
pub mod cpu_profile;
|
||||
pub mod env;
|
||||
pub mod util;
|
||||
359
crates/codegen/xai-grok-shell-base/src/util/changelog.rs
Normal file
359
crates/codegen/xai-grok-shell-base/src/util/changelog.rs
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
//! Changelog fetching from CDN with local disk cache.
|
||||
//!
|
||||
//! Both markdown (`*.external.md`) and JSON (`*.external.json`) changelogs
|
||||
//! are published per-version to the CDN at `x.ai/cli/changelogs/`.
|
||||
//!
|
||||
//! `ChangelogManager::fetch()` retrieves both formats in parallel and
|
||||
//! returns a `Changelog` with optional markdown + structured entries.
|
||||
//! Consumers pick the format they need:
|
||||
//! - `/release-notes` uses `changelog.markdown` for rich scrollback display
|
||||
//! - Welcome screen uses `changelog.entries` for bullet rendering
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// CDN base for all changelogs (proxies to GCS, cache-friendly).
|
||||
const CHANGELOG_BASE: &str = "https://x.ai/cli/changelogs";
|
||||
const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
/// A single structured changelog entry from the published JSON changelog.
|
||||
///
|
||||
/// Shape must match the output of `render_external_json` in `changelog.sh`:
|
||||
/// `{category, description, breaking_change}`
|
||||
/// If you change fields here, update `changelog.sh:render_external_json` too.
|
||||
///
|
||||
/// All fields use `#[serde(default)]` so a single malformed entry doesn't
|
||||
/// kill the entire array parse. Entries with an empty description are
|
||||
/// filtered out by `bullets_from_entries`.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct ChangelogEntry {
|
||||
/// Category label (e.g. "features", "fixes", "breaking", "performance").
|
||||
#[serde(default)]
|
||||
pub category: String,
|
||||
/// Human-readable description (may contain `**bold**` or backticks).
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
/// Whether this entry represents a breaking change.
|
||||
#[serde(default)]
|
||||
pub breaking_change: bool,
|
||||
}
|
||||
|
||||
/// Both formats of a version's changelog, fetched together.
|
||||
pub struct Changelog {
|
||||
/// Rendered markdown (for `/release-notes` display).
|
||||
pub markdown: Option<String>,
|
||||
/// Structured entries (for welcome screen bullets).
|
||||
pub entries: Option<Vec<ChangelogEntry>>,
|
||||
}
|
||||
|
||||
/// Manages changelog retrieval from CDN with local disk caching.
|
||||
///
|
||||
/// Single entry point: `fetch()` returns both markdown and JSON in one
|
||||
/// `Changelog` struct. Each format is fetched independently with its own
|
||||
/// cache file, so a failure in one doesn't block the other.
|
||||
pub struct ChangelogManager {
|
||||
md_cache: PathBuf,
|
||||
json_cache: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for ChangelogManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ChangelogManager {
|
||||
pub fn new() -> Self {
|
||||
// Prefer live `$GROK_HOME` so harness-injected homes (PTY e2e) always
|
||||
// win over a OnceLock that may have been initialised earlier with a
|
||||
// different path in the same process graph.
|
||||
Self::from_env_home()
|
||||
}
|
||||
|
||||
/// Resolve cache paths from the live process environment (not the
|
||||
/// `grok_home()` OnceLock). A seeded `$GROK_HOME` set on the pager
|
||||
/// process is always honoured even if some earlier init path cached a
|
||||
/// different home.
|
||||
fn from_env_home() -> Self {
|
||||
let home = std::env::var_os("GROK_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or_else(crate::util::grok_home::grok_home);
|
||||
Self {
|
||||
md_cache: home.join("CHANGELOG.md"),
|
||||
json_cache: home.join("CHANGELOG.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch both markdown and JSON changelogs for the current version.
|
||||
///
|
||||
/// Each format is fetched independently (CDN, 3 s timeout) and cached
|
||||
/// to disk. On failure, falls back to the cached copy. Either field
|
||||
/// may be `None` if offline with no cache.
|
||||
///
|
||||
/// When `GROK_CHANGELOG_OFFLINE` is set (PTY / integration tests), skip
|
||||
/// the CDN entirely and read only the disk cache so seeded fixtures win
|
||||
/// deterministically without network races. Paths are re-resolved from
|
||||
/// `$GROK_HOME` so harness-injected env always applies.
|
||||
///
|
||||
/// JSON is only cached after a successful parse to avoid poisoning the
|
||||
/// disk cache with malformed content (the markdown cache is write-through
|
||||
/// since it's consumed as raw text).
|
||||
pub fn fetch(&self) -> Changelog {
|
||||
// Always re-resolve from env so a caller holding an older manager
|
||||
// (or OnceLock lag) still reads the live harness home.
|
||||
Self::from_env_home().fetch_with(changelog_offline(), CHANGELOG_BASE)
|
||||
}
|
||||
|
||||
/// Fetch using this manager's already-resolved cache paths, an explicit
|
||||
/// offline flag, and an explicit CDN base.
|
||||
///
|
||||
/// Split out of [`fetch`] so unit tests can drive it against a temp home
|
||||
/// without mutating process-global env (`GROK_HOME` /
|
||||
/// `GROK_CHANGELOG_OFFLINE`), which races across the parallel test
|
||||
/// harness. Passing an unreachable `base` lets a test force a
|
||||
/// deterministic CDN miss instead of depending on whether the sandbox
|
||||
/// happens to block network. Production callers always go through
|
||||
/// [`fetch`], so behaviour is unchanged.
|
||||
fn fetch_with(&self, offline: bool, base: &str) -> Changelog {
|
||||
if offline {
|
||||
return Changelog {
|
||||
markdown: read_cache(&self.md_cache),
|
||||
entries: self.read_json_cache(),
|
||||
};
|
||||
}
|
||||
|
||||
let version = xai_grok_version::VERSION;
|
||||
let md_url = format!("{}/{}.external.md", base, version);
|
||||
|
||||
// Fetch both formats in parallel (3s timeout each → 3s total, not 6s).
|
||||
let mut markdown = None;
|
||||
let mut entries = None;
|
||||
std::thread::scope(|s| {
|
||||
let md_handle = s.spawn(|| self.fetch_and_cache(&md_url, &self.md_cache));
|
||||
let json_handle = s.spawn(|| self.fetch_json(base, version));
|
||||
markdown = md_handle.join().ok().flatten();
|
||||
entries = json_handle.join().ok().flatten();
|
||||
});
|
||||
|
||||
// If CDN is unreachable (CI sandboxes, airplane mode), fall back to
|
||||
// any on-disk seed under `$GROK_HOME` even when offline mode was not
|
||||
// explicitly requested — keeps PTY/integration tests deterministic.
|
||||
if markdown.is_none() {
|
||||
markdown = read_cache(&self.md_cache);
|
||||
}
|
||||
if entries.is_none() {
|
||||
entries = self.read_json_cache();
|
||||
}
|
||||
|
||||
Changelog { markdown, entries }
|
||||
}
|
||||
|
||||
/// Fetch and parse JSON changelog, caching only after successful parse.
|
||||
fn fetch_json(&self, base: &str, version: &str) -> Option<Vec<ChangelogEntry>> {
|
||||
let url = format!("{}/{}.external.json", base, version);
|
||||
|
||||
// Try remote first — only cache after successful parse.
|
||||
if let Ok(raw) = fetch_blocking(&url)
|
||||
&& !raw.trim().is_empty()
|
||||
{
|
||||
match serde_json::from_str::<Vec<ChangelogEntry>>(&raw) {
|
||||
Ok(entries) => {
|
||||
if let Err(e) = std::fs::write(&self.json_cache, &raw) {
|
||||
tracing::debug!(error = %e, "JSON changelog cache write failed");
|
||||
}
|
||||
return Some(entries);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "failed to parse JSON changelog from CDN");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.read_json_cache()
|
||||
}
|
||||
|
||||
fn read_json_cache(&self) -> Option<Vec<ChangelogEntry>> {
|
||||
let cached = read_cache(&self.json_cache)?;
|
||||
match serde_json::from_str(&cached) {
|
||||
Ok(entries) => Some(entries),
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "failed to parse cached JSON changelog");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared fetch-and-cache: try remote (3 s timeout), cache on success,
|
||||
/// fall back to disk cache on failure.
|
||||
fn fetch_and_cache(&self, url: &str, cache_path: &std::path::Path) -> Option<String> {
|
||||
if let Ok(content) = fetch_blocking(url)
|
||||
&& !content.trim().is_empty()
|
||||
{
|
||||
if let Err(e) = std::fs::write(cache_path, &content) {
|
||||
tracing::debug!(error = %e, path = %cache_path.display(), "cache write failed");
|
||||
}
|
||||
return Some(content);
|
||||
}
|
||||
read_cache(cache_path)
|
||||
}
|
||||
}
|
||||
|
||||
/// When set, `ChangelogManager::fetch` skips the CDN and only reads disk cache.
|
||||
/// Used by PTY harness tests that seed `CHANGELOG.{md,json}` under a temp home.
|
||||
fn changelog_offline() -> bool {
|
||||
std::env::var_os("GROK_CHANGELOG_OFFLINE").is_some_and(|v| !v.is_empty() && v != "0")
|
||||
}
|
||||
|
||||
fn read_cache(path: &std::path::Path) -> Option<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Strip `**bold**` markers and backticks from a description string.
|
||||
fn strip_markdown_inline(s: &str) -> String {
|
||||
s.replace("**", "").replace('`', "")
|
||||
}
|
||||
|
||||
/// Convert changelog entries to plain-text bullet strings.
|
||||
///
|
||||
/// Strips `**bold**` and backtick formatting from each description,
|
||||
/// skips entries with empty descriptions (from tolerant deserialization),
|
||||
/// and returns at most `max` entries.
|
||||
pub fn bullets_from_entries(entries: &[ChangelogEntry], max: usize) -> Vec<String> {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|e| !e.description.is_empty())
|
||||
.take(max)
|
||||
.map(|e| strip_markdown_inline(&e.description))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Blocking HTTP fetch. Callers (`std::thread::scope` threads) are already
|
||||
/// off the tokio runtime, so no extra thread spawn is needed.
|
||||
fn fetch_blocking(url: &str) -> anyhow::Result<String> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.build()?;
|
||||
let resp = client.get(url).send()?;
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("HTTP {}", resp.status());
|
||||
}
|
||||
Ok(resp.text()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build a manager pointing at `home` directly, bypassing the global
|
||||
/// `$GROK_HOME` env so tests never race the parallel harness.
|
||||
fn manager_for(home: &std::path::Path) -> ChangelogManager {
|
||||
ChangelogManager {
|
||||
md_cache: home.join("CHANGELOG.md"),
|
||||
json_cache: home.join("CHANGELOG.json"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_mode_reads_seeded_disk_cache_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("grok-home");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::write(home.join("CHANGELOG.md"), "# seeded offline md\n").unwrap();
|
||||
std::fs::write(
|
||||
home.join("CHANGELOG.json"),
|
||||
r#"[{"category":"features","description":"seeded entry","breaking_change":false}]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Offline path: read only the seeded disk cache, no network.
|
||||
let changelog = manager_for(&home).fetch_with(true, CHANGELOG_BASE);
|
||||
assert_eq!(
|
||||
changelog.markdown.as_deref(),
|
||||
Some("# seeded offline md\n"),
|
||||
"offline mode must return seeded markdown"
|
||||
);
|
||||
let entries = changelog.entries.expect("seeded json entries");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].description, "seeded entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cdn_miss_falls_back_to_env_home_disk_cache() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("grok-home-fallback");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::write(home.join("CHANGELOG.md"), "# fallback md\n").unwrap();
|
||||
|
||||
// Non-offline path with an unreachable CDN base: the remote fetch
|
||||
// fails deterministically (no dependency on the sandbox blocking
|
||||
// network), so the on-disk cache must win.
|
||||
let changelog = manager_for(&home).fetch_with(false, "http://127.0.0.1:1");
|
||||
assert_eq!(
|
||||
changelog.markdown.as_deref(),
|
||||
Some("# fallback md\n"),
|
||||
"CDN miss must fall back to the seeded CHANGELOG.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullets_strips_markdown_and_respects_max() {
|
||||
let entries = vec![
|
||||
ChangelogEntry {
|
||||
category: "features".into(),
|
||||
description: "Added **dark mode** support".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: "fixes".into(),
|
||||
description: "Fixed `crash` on startup".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: "performance".into(),
|
||||
description: "Faster **rendering** of `code` blocks".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
];
|
||||
|
||||
let bullets = bullets_from_entries(&entries, 2);
|
||||
assert_eq!(bullets.len(), 2);
|
||||
assert_eq!(bullets[0], "Added dark mode support");
|
||||
assert_eq!(bullets[1], "Fixed crash on startup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullets_skips_empty_descriptions() {
|
||||
let entries = vec![
|
||||
ChangelogEntry {
|
||||
category: "features".into(),
|
||||
description: "Good entry".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: String::new(),
|
||||
description: String::new(), // bad entry from tolerant deser
|
||||
breaking_change: false,
|
||||
},
|
||||
ChangelogEntry {
|
||||
category: "fixes".into(),
|
||||
description: "Another good one".into(),
|
||||
breaking_change: false,
|
||||
},
|
||||
];
|
||||
let bullets = bullets_from_entries(&entries, 10);
|
||||
assert_eq!(bullets, vec!["Good entry", "Another good one"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerant_deserialization_partial_entry() {
|
||||
// Missing description field → defaults to empty string, not a parse error
|
||||
let json = r#"[{"category":"features"},{"description":"ok"}]"#;
|
||||
let entries: Vec<ChangelogEntry> = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].description, "");
|
||||
assert_eq!(entries[1].category, "");
|
||||
assert_eq!(entries[1].description, "ok");
|
||||
}
|
||||
}
|
||||
180
crates/codegen/xai-grok-shell-base/src/util/event_id.rs
Normal file
180
crates/codegen/xai-grok-shell-base/src/util/event_id.rs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
//! Event ID generation for session notifications.
|
||||
//!
|
||||
//! Provides a globally unique event ID format `{session_id}-{counter}` that is
|
||||
//! used for deduplication in the relay. The counter is monotonically increasing
|
||||
//! across the entire agent process, ensuring event IDs are always comparable.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Global counter for event ID generation.
|
||||
/// Shared across all sessions to ensure monotonically increasing IDs.
|
||||
static EVENT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Generates a unique event ID for correlation across agent/relay/client.
|
||||
///
|
||||
/// Format: `{session_id}-{counter}` where counter is a monotonically increasing
|
||||
/// global counter. This format allows the relay to compare event IDs numerically
|
||||
/// by extracting the counter suffix.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `session_id` - The session ID to include in the event ID
|
||||
///
|
||||
/// # Returns
|
||||
/// A unique event ID string in the format `{session_id}-{counter}`
|
||||
pub fn generate_event_id(session_id: &str) -> String {
|
||||
let count = EVENT_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||
format!("{}-{}", session_id, count)
|
||||
}
|
||||
|
||||
/// Stamp `_meta.eventId` (+ `agentTimestampMs`) onto a notification's meta
|
||||
/// unless an `eventId` is already present, preserving any other meta fields.
|
||||
///
|
||||
/// Every PERSISTED notification should carry an `eventId`: the reconnect
|
||||
/// cursor (`session/load` `_meta.cursor`) can only bound the replay tail when
|
||||
/// each persisted line is identifiable, and the same id must ride the live
|
||||
/// broadcast so clients advance their cursor to ids that exist on disk.
|
||||
/// Broadcast-only notifications are deliberately left unstamped — a cursor
|
||||
/// pointing at an id absent from `updates.jsonl` never resolves and forces a
|
||||
/// full replay on every reconnect.
|
||||
///
|
||||
/// Stamping chokepoints (stamp BEFORE the persist/broadcast fork, so both
|
||||
/// copies share one id): `SessionActor::emit_notification_direct` (all actor
|
||||
/// ACP notifications, incl. the buffered pipeline), `send_xai_notification` /
|
||||
/// `persist_xai_update_only` / `handle_xai_session_notification` (actor xAI),
|
||||
/// `notification_bridge::stamp_event_id` (bridge), `emit_subagent_notification`
|
||||
/// (subagent), `GoalNotifySender::send_update` (goal mode), plus the inline
|
||||
/// `build_notification_meta` user-echo persists. An emitter outside these is
|
||||
/// not a correctness bug — `prepare_replay_lines` refuses cursors over id-less
|
||||
/// tails (full replay, safe) — but it silently disables incremental reconnect
|
||||
/// for affected sessions.
|
||||
pub fn ensure_event_id_meta(
|
||||
session_id: &str,
|
||||
meta: &mut Option<serde_json::Map<String, serde_json::Value>>,
|
||||
) {
|
||||
if meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("eventId"))
|
||||
.is_some_and(|v| !v.is_null())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let event_id = generate_event_id(session_id);
|
||||
let timestamp_ms = chrono::Utc::now().timestamp_millis();
|
||||
let obj = meta.get_or_insert_with(serde_json::Map::new);
|
||||
obj.insert("eventId".into(), event_id.into());
|
||||
obj.entry("agentTimestampMs")
|
||||
.or_insert_with(|| timestamp_ms.into());
|
||||
}
|
||||
|
||||
/// Raise the global event counter so the next generated id is at least `next`.
|
||||
///
|
||||
/// The counter is process-global and starts at 0 on every launch, but the
|
||||
/// monotonic-`eventId` invariant the client dedup relies on
|
||||
/// (`acp::meta::NotificationMeta::event_seq`) spans a *session's whole history*,
|
||||
/// not a single process. On `--resume` (or any reload into a fresh process) the
|
||||
/// replayed transcript carries the ORIGINAL process's high counters; without
|
||||
/// re-seeding, this process would mint LOWER ids for new live events and the
|
||||
/// client would dedup-drop every one of them (frozen token counter, missing
|
||||
/// turns). Call this once on session load with `persisted_max + 1`.
|
||||
///
|
||||
/// Uses `fetch_max`, so it only ever raises the counter — safe to call from
|
||||
/// multiple concurrently-loading sessions sharing the process-global counter.
|
||||
pub fn ensure_event_counter_at_least(next: u64) {
|
||||
EVENT_COUNTER.fetch_max(next, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generate_event_id_format() {
|
||||
let id = generate_event_id("test-session-123");
|
||||
assert!(id.starts_with("test-session-123-"));
|
||||
// Should end with a valid number
|
||||
let _counter: u64 = id.rsplit('-').next().unwrap().parse().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_event_counter_at_least_only_raises() {
|
||||
// Re-seeding to a high floor makes the next id continue past it — this
|
||||
// is what keeps `--resume` from minting ids below the replayed maximum.
|
||||
// Uses a very high floor so concurrent tests (which only ever raise the
|
||||
// shared counter via fetch_add/fetch_max) cannot push it back down.
|
||||
ensure_event_counter_at_least(5_000_000);
|
||||
let counter1: u64 = generate_event_id("sess")
|
||||
.rsplit('-')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(
|
||||
counter1 >= 5_000_000,
|
||||
"next id must be at/above the seeded floor, got {counter1}"
|
||||
);
|
||||
|
||||
// A lower floor is a no-op (fetch_max never decreases the counter).
|
||||
ensure_event_counter_at_least(1);
|
||||
let counter2: u64 = generate_event_id("sess")
|
||||
.rsplit('-')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(
|
||||
counter2 > counter1,
|
||||
"a lower floor must not reset the counter: {counter2} !> {counter1}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_event_id_meta_stamps_none_and_merges_existing() {
|
||||
// None meta: a fresh object with eventId + timestamp is created.
|
||||
let mut meta = None;
|
||||
ensure_event_id_meta("sess-x", &mut meta);
|
||||
let obj = meta.as_ref().unwrap();
|
||||
assert!(
|
||||
obj["eventId"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.starts_with("sess-x-"))
|
||||
);
|
||||
assert!(obj["agentTimestampMs"].is_i64());
|
||||
|
||||
// Existing meta without eventId: fields are merged, not replaced.
|
||||
let mut meta = serde_json::json!({ "custom": true }).as_object().cloned();
|
||||
ensure_event_id_meta("sess-x", &mut meta);
|
||||
let obj = meta.as_ref().unwrap();
|
||||
assert_eq!(obj["custom"], serde_json::json!(true));
|
||||
assert!(obj.contains_key("eventId"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_event_id_meta_keeps_existing_id() {
|
||||
// An already-stamped id (e.g. emit site stamped before the persist
|
||||
// chokepoint re-checks) must survive so the persisted line matches
|
||||
// the live broadcast copy.
|
||||
let mut meta = serde_json::json!({ "eventId": "sess-x-42" })
|
||||
.as_object()
|
||||
.cloned();
|
||||
ensure_event_id_meta("sess-x", &mut meta);
|
||||
assert_eq!(
|
||||
meta.as_ref().and_then(|m| m.get("eventId")),
|
||||
Some(&serde_json::json!("sess-x-42"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_event_id_incrementing() {
|
||||
let id1 = generate_event_id("session-a");
|
||||
let id2 = generate_event_id("session-b");
|
||||
let id3 = generate_event_id("session-a");
|
||||
|
||||
let counter1: u64 = id1.rsplit('-').next().unwrap().parse().unwrap();
|
||||
let counter2: u64 = id2.rsplit('-').next().unwrap().parse().unwrap();
|
||||
let counter3: u64 = id3.rsplit('-').next().unwrap().parse().unwrap();
|
||||
|
||||
// Counters should be monotonically increasing
|
||||
assert!(counter2 > counter1);
|
||||
assert!(counter3 > counter2);
|
||||
}
|
||||
}
|
||||
5
crates/codegen/xai-grok-shell-base/src/util/grok_home.rs
Normal file
5
crates/codegen/xai-grok-shell-base/src/util/grok_home.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Re-exported from the defining crate so this crate stays off the tool stack.
|
||||
pub use xai_grok_config::{
|
||||
decode_cwd_from_dirname, encode_cwd_dirname, ensure_sessions_cwd_dir, grok_application,
|
||||
grok_home, sessions_cwd_dir,
|
||||
};
|
||||
296
crates/codegen/xai-grok-shell-base/src/util/mod.rs
Normal file
296
crates/codegen/xai-grok-shell-base/src/util/mod.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
pub mod changelog;
|
||||
pub mod event_id;
|
||||
pub mod grok_home;
|
||||
pub mod secure_file;
|
||||
pub mod tips;
|
||||
pub mod uname;
|
||||
pub use xai_grok_shared::clipboard;
|
||||
pub use xai_grok_shared::stderr::{stderr_lock, with_locked_stderr};
|
||||
/// Generate a pseudo-random f64 in [0.0, 1.0).
|
||||
///
|
||||
/// Uses `RandomState::new()` which is OS-seeded (via `getrandom`) on each
|
||||
/// instantiation, producing a unique hasher state per call. A fixed sentinel
|
||||
/// is hashed to extract the random bits — the entropy comes entirely from
|
||||
/// the OS-seeded `RandomState`, not from any clock source.
|
||||
///
|
||||
/// # Precision
|
||||
/// The result uses all 53 bits of `f64` mantissa for a uniform distribution
|
||||
/// over `[0.0, 1.0)`. We shift the 64-bit hash right by 11 bits to get a
|
||||
/// 53-bit integer, then divide by `2^53`. This avoids the subtle bias that
|
||||
/// occurs when casting a full `u64` to `f64` (which has only 52 bits of
|
||||
/// mantissa, causing multiple `u64` values to map to the same `f64` for
|
||||
/// values > 2^52).
|
||||
///
|
||||
/// Not cryptographically secure — suitable for sampling and feature
|
||||
/// rollouts, not for security-sensitive randomness.
|
||||
pub fn random_f64() -> f64 {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let random_state = RandomState::new();
|
||||
let mut hasher = random_state.build_hasher();
|
||||
hasher.write_u64(0x517cc1b727220a95);
|
||||
(hasher.finish() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
/// Probabilistic sampling. Returns `true` with probability `rate` (0.0–1.0).
|
||||
pub fn probabilistic_sample(rate: f64) -> bool {
|
||||
random_f64() < rate
|
||||
}
|
||||
fn matches_trusted_base_url(candidate: &str, trusted_base: &str) -> bool {
|
||||
let Ok(candidate) = reqwest::Url::parse(candidate) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(trusted) = reqwest::Url::parse(trusted_base) else {
|
||||
return false;
|
||||
};
|
||||
let trusted_path = trusted.path();
|
||||
let candidate_path = candidate.path();
|
||||
let path_matches = candidate_path == trusted_path
|
||||
|| candidate_path
|
||||
.strip_prefix(trusted_path)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'));
|
||||
candidate.scheme() == trusted.scheme()
|
||||
&& candidate.host_str() == trusted.host_str()
|
||||
&& candidate.port_or_known_default() == trusted.port_or_known_default()
|
||||
&& path_matches
|
||||
}
|
||||
/// True for cli-chat-proxy URLs (production, plus local-dev hosts when the
|
||||
/// optional non-production feature is enabled). When that feature is on,
|
||||
/// runtime env overrides can extend this trust set.
|
||||
pub fn is_cli_chat_proxy_url(url: &str) -> bool {
|
||||
if matches_trusted_base_url(url, crate::env::PROD_CLI_CHAT_PROXY_BASE_URL) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// True for first-party xAI endpoints (`*.x.ai`, cli-chat-proxy, and optional
|
||||
/// non-production first-party hosts when that feature is enabled).
|
||||
/// `disable_api_key_auth` refuses keys only for these; other hosts are BYOK and
|
||||
/// exempt. Safe against invalid URLs and suffix attacks (`evil-x.ai.example`).
|
||||
pub fn is_first_party_xai_url(url: &str) -> bool {
|
||||
if is_cli_chat_proxy_url(url) {
|
||||
return true;
|
||||
}
|
||||
reqwest::Url::parse(url)
|
||||
.ok()
|
||||
.and_then(|u| u.host_str().map(|h| h.to_owned()))
|
||||
.is_some_and(|host| host == "x.ai" || host.ends_with(".x.ai"))
|
||||
}
|
||||
/// Truncate a string to at most `max_chars` characters.
|
||||
/// Slices at char boundaries so multi-byte UTF-8 never panics.
|
||||
pub fn truncate(s: &str, max_chars: usize) -> &str {
|
||||
if s.len() <= max_chars {
|
||||
return s;
|
||||
}
|
||||
let end = s
|
||||
.char_indices()
|
||||
.nth(max_chars)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(s.len());
|
||||
&s[..end]
|
||||
}
|
||||
/// Check if a process is still alive.
|
||||
///
|
||||
/// - Unix: `kill(pid, 0)` via `nix`. True if the process exists (even
|
||||
/// under a different UID); false only on ESRCH.
|
||||
/// - Windows: `OpenProcess(SYNCHRONIZE)` + `WaitForSingleObject(0)`. True
|
||||
/// while running; false on exit, absence, or open failure.
|
||||
#[cfg(unix)]
|
||||
pub fn is_process_alive(pid: u32) -> bool {
|
||||
use nix::errno::Errno;
|
||||
use nix::sys::signal::kill;
|
||||
use nix::unistd::Pid;
|
||||
match kill(Pid::from_raw(pid as i32), None) {
|
||||
Ok(()) => true,
|
||||
Err(Errno::ESRCH) => false,
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
pub fn is_process_alive(pid: u32) -> bool {
|
||||
use windows::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
};
|
||||
let Ok(handle) = (unsafe { OpenProcess(PROCESS_SYNCHRONIZE, false, pid) }) else {
|
||||
return false;
|
||||
};
|
||||
let wait_result = unsafe { WaitForSingleObject(handle, 0) };
|
||||
let _ = unsafe { CloseHandle(handle) };
|
||||
wait_result == WAIT_TIMEOUT
|
||||
}
|
||||
/// Terminate a process by PID. Idempotent: already-dead is `Ok`.
|
||||
///
|
||||
/// - Unix: `SIGTERM` via `nix::sys::signal::kill`; ESRCH maps to `Ok`.
|
||||
/// - Windows: `OpenProcess(PROCESS_TERMINATE)` + `TerminateProcess`;
|
||||
/// ERROR_INVALID_PARAMETER (Windows' "no such process") maps to `Ok`.
|
||||
pub fn kill_process_by_pid(pid: u32) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use nix::errno::Errno;
|
||||
use nix::sys::signal::{Signal, kill};
|
||||
use nix::unistd::Pid;
|
||||
match kill(Pid::from_raw(pid as i32), Signal::SIGTERM) {
|
||||
Ok(()) | Err(Errno::ESRCH) => Ok(()),
|
||||
Err(e) => Err(std::io::Error::from_raw_os_error(e as i32)),
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::Foundation::{CloseHandle, ERROR_INVALID_PARAMETER};
|
||||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess};
|
||||
use windows::core::HRESULT;
|
||||
let no_such_process = HRESULT::from_win32(ERROR_INVALID_PARAMETER.0);
|
||||
let handle = match unsafe { OpenProcess(PROCESS_TERMINATE, false, pid) } {
|
||||
Ok(h) => h,
|
||||
Err(e) if e.code() == no_such_process => return Ok(()),
|
||||
Err(e) => {
|
||||
return Err(std::io::Error::other(format!("OpenProcess({pid}): {e}")));
|
||||
}
|
||||
};
|
||||
let terminate = unsafe { TerminateProcess(handle, 0) };
|
||||
let _ = unsafe { CloseHandle(handle) };
|
||||
terminate.map_err(|e| std::io::Error::other(format!("TerminateProcess({pid}): {e}")))
|
||||
}
|
||||
}
|
||||
/// True if `pid` is a grok process; pairs with [`kill_process_by_pid`] to avoid killing a recycled PID.
|
||||
/// Best-effort on macOS/BSD (liveness-only via `kill -0`), exact on Linux (/proc cmdline) and Windows (image path).
|
||||
pub fn is_grok_process(pid: u32) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let cmdline_path = format!("/proc/{pid}/cmdline");
|
||||
match std::fs::read(&cmdline_path) {
|
||||
Ok(data) => String::from_utf8_lossy(&data).contains("grok"),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
QueryFullProcessImageNameW,
|
||||
};
|
||||
use windows::core::PWSTR;
|
||||
let Ok(handle) = (unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) })
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut buf: Vec<u16> = vec![0; 1024];
|
||||
let mut size: u32 = buf.len() as u32;
|
||||
let result = unsafe {
|
||||
QueryFullProcessImageNameW(
|
||||
handle,
|
||||
PROCESS_NAME_WIN32,
|
||||
PWSTR(buf.as_mut_ptr()),
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
let _ = unsafe { CloseHandle(handle) };
|
||||
if result.is_err() {
|
||||
return false;
|
||||
}
|
||||
String::from_utf16_lossy(&buf[..size as usize])
|
||||
.to_ascii_lowercase()
|
||||
.contains("grok")
|
||||
}
|
||||
#[cfg(all(not(target_os = "linux"), not(windows)))]
|
||||
{
|
||||
let mut cmd = std::process::Command::new("kill");
|
||||
cmd.args(["-0", &pid.to_string()])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
cmd.status().is_ok_and(|s| s.success())
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_is_cli_chat_proxy_url_accepts_proxy_subpath() {
|
||||
assert!(is_cli_chat_proxy_url(
|
||||
"https://cli-chat-proxy.grok.com/v1/chat/completions"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_is_cli_chat_proxy_url_rejects_public_api() {
|
||||
assert!(!is_cli_chat_proxy_url("https://api.x.ai/v1"));
|
||||
}
|
||||
#[test]
|
||||
fn test_is_cli_chat_proxy_url_rejects_spoofed_hostname() {
|
||||
assert!(!is_cli_chat_proxy_url(
|
||||
"https://cli-chat-proxy.grok.com.evil.example/v1"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_is_cli_chat_proxy_url_rejects_v11_prefix_confusion() {
|
||||
assert!(!is_cli_chat_proxy_url(
|
||||
"https://cli-chat-proxy.grok.com/v11/chat/completions"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_is_first_party_xai_url() {
|
||||
assert!(is_first_party_xai_url("https://api.x.ai/v1"));
|
||||
assert!(is_first_party_xai_url(
|
||||
"https://api.x.ai/v1/chat/completions"
|
||||
));
|
||||
assert!(is_first_party_xai_url("https://x.ai"));
|
||||
assert!(is_first_party_xai_url(
|
||||
"https://cli-chat-proxy.grok.com/v1/chat/completions"
|
||||
));
|
||||
assert!(!is_first_party_xai_url("https://api.openai.com/v1"));
|
||||
assert!(!is_first_party_xai_url("https://api.anthropic.com/v1"));
|
||||
assert!(!is_first_party_xai_url(
|
||||
"https://generativelanguage.googleapis.com"
|
||||
));
|
||||
assert!(!is_first_party_xai_url("https://api.x.ai.evil.example/v1"));
|
||||
assert!(!is_first_party_xai_url("https://evil-x.ai.attacker.com/v1"));
|
||||
assert!(!is_first_party_xai_url("https://prefixx.ai/v1"));
|
||||
assert!(!is_first_party_xai_url("not-a-url"));
|
||||
assert!(!is_first_party_xai_url(""));
|
||||
}
|
||||
#[test]
|
||||
fn test_truncate() {
|
||||
assert_eq!(truncate("hello", 5), "hello");
|
||||
assert_eq!(truncate("hello world", 5), "hello");
|
||||
assert_eq!(truncate("abc🎉🎉def", 5), "abc🎉🎉");
|
||||
}
|
||||
#[test]
|
||||
fn is_process_alive_current_process() {
|
||||
assert!(is_process_alive(std::process::id()));
|
||||
}
|
||||
#[test]
|
||||
fn is_process_alive_dead_pid() {
|
||||
assert!(!is_process_alive(4_000_000_000));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn is_process_alive_init_process() {
|
||||
assert!(is_process_alive(1));
|
||||
}
|
||||
#[test]
|
||||
fn kill_process_by_pid_already_dead_is_ok() {
|
||||
assert!(kill_process_by_pid(4_000_000_000).is_ok());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn kill_process_by_pid_terminates_live_child() {
|
||||
let mut child = std::process::Command::new("sleep")
|
||||
.arg("60")
|
||||
.spawn()
|
||||
.expect("spawn sleep");
|
||||
let pid = child.id();
|
||||
kill_process_by_pid(pid).expect("kill should succeed");
|
||||
let status = child.wait().expect("wait child");
|
||||
assert!(
|
||||
!status.success(),
|
||||
"sleep was terminated, not exited cleanly"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn is_grok_process_self_true_impossible_pid_false() {
|
||||
assert!(is_grok_process(std::process::id()));
|
||||
assert!(!is_grok_process(u32::MAX));
|
||||
}
|
||||
}
|
||||
231
crates/codegen/xai-grok-shell-base/src/util/secure_file.rs
Normal file
231
crates/codegen/xai-grok-shell-base/src/util/secure_file.rs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
//! Cross-platform secure file operations.
|
||||
//!
|
||||
//! This module provides utilities for creating files with restrictive permissions
|
||||
//! that limit access to the current user only. This is critical for storing
|
||||
//! sensitive data like authentication tokens.
|
||||
//!
|
||||
//! ## Security Model
|
||||
//!
|
||||
//! - **Unix**: Files are created with mode 0o600 (owner read/write only)
|
||||
//! - **Windows**: Files are created with ACLs that grant access only to the current user
|
||||
//!
|
||||
//! ## Encryption Consideration
|
||||
//!
|
||||
//! While this module restricts file access at the OS level, the token is stored in
|
||||
//! plaintext. For additional security in high-risk environments, consider:
|
||||
//! - Using the operating system's keychain/credential manager (e.g., macOS Keychain,
|
||||
//! Windows Credential Manager, Linux Secret Service)
|
||||
//! - Encrypting the token with a key derived from system-specific entropy
|
||||
//!
|
||||
//! The current approach balances security with simplicity - OS file permissions
|
||||
//! provide reasonable protection for most use cases, and the token is already
|
||||
//! short-lived (7-30 days TTL with automatic refresh).
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
|
||||
/// Creates or opens a file with secure permissions (owner read/write only).
|
||||
///
|
||||
/// On Unix, this sets mode 0o600. On Windows, this restricts the file's ACL
|
||||
/// to grant access only to the current user.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - The path to the file to create/open
|
||||
/// * `contents` - The data to write to the file
|
||||
///
|
||||
/// # Returns
|
||||
/// An `io::Result<()>` indicating success or failure.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// use xai_grok_shell_base::util::secure_file::write_secure_file;
|
||||
///
|
||||
/// let token = "secret_token";
|
||||
/// write_secure_file("/path/to/auth.json", token.as_bytes())?;
|
||||
/// ```
|
||||
pub fn write_secure_file(path: &Path, contents: &[u8]) -> io::Result<()> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Create the file with secure permissions
|
||||
let mut file = open_secure_file(path)?;
|
||||
file.write_all(contents)?;
|
||||
file.flush()?;
|
||||
|
||||
// On Windows, we need to set permissions after file creation
|
||||
#[cfg(windows)]
|
||||
{
|
||||
set_windows_secure_permissions(path)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens a file for writing with secure permissions set during creation (Unix)
|
||||
/// or prepares it for permission setting after creation (Windows).
|
||||
pub fn open_secure_file(path: &Path) -> io::Result<File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.truncate(true).write(true).create(true);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Set file mode to 0o600 (owner read/write only) during creation
|
||||
options.mode(0o600);
|
||||
}
|
||||
|
||||
options.open(path)
|
||||
}
|
||||
|
||||
/// Sets Windows-specific secure permissions on a file.
|
||||
///
|
||||
/// This function modifies the file's ACL to:
|
||||
/// 1. Remove inherited permissions
|
||||
/// 2. Grant full control only to the current user
|
||||
///
|
||||
/// This is equivalent to Unix mode 0o600.
|
||||
#[cfg(windows)]
|
||||
pub fn set_windows_secure_permissions(path: &Path) -> io::Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::Win32::Foundation::{CloseHandle, HLOCAL, LocalFree};
|
||||
use windows::Win32::Security::Authorization::{
|
||||
EXPLICIT_ACCESS_W, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, SetNamedSecurityInfoW,
|
||||
TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
|
||||
};
|
||||
use windows::Win32::Security::{
|
||||
ACE_FLAGS, ACL, DACL_SECURITY_INFORMATION, GetTokenInformation,
|
||||
PROTECTED_DACL_SECURITY_INFORMATION, TOKEN_QUERY, TOKEN_USER, TokenUser,
|
||||
};
|
||||
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
unsafe {
|
||||
// Get current process token
|
||||
let mut token_handle = windows::Win32::Foundation::HANDLE::default();
|
||||
OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e))?;
|
||||
|
||||
// Get token user size
|
||||
let mut return_length = 0u32;
|
||||
let _ = GetTokenInformation(token_handle, TokenUser, None, 0, &mut return_length);
|
||||
|
||||
// Get token user (current user's SID)
|
||||
let mut token_user_buffer = vec![0u8; return_length as usize];
|
||||
GetTokenInformation(
|
||||
token_handle,
|
||||
TokenUser,
|
||||
Some(token_user_buffer.as_mut_ptr() as *mut _),
|
||||
return_length,
|
||||
&mut return_length,
|
||||
)
|
||||
.map_err(|e| {
|
||||
let _ = CloseHandle(token_handle);
|
||||
io::Error::new(io::ErrorKind::PermissionDenied, e)
|
||||
})?;
|
||||
|
||||
// The TOKEN_USER structure starts with a SID_AND_ATTRIBUTES which has PSID as first field
|
||||
let token_user = &*(token_user_buffer.as_ptr() as *const TOKEN_USER);
|
||||
let user_sid = token_user.User.Sid;
|
||||
|
||||
// Create explicit access entry for current user only
|
||||
// GENERIC_ALL = 0x10000000
|
||||
let explicit_access = EXPLICIT_ACCESS_W {
|
||||
grfAccessPermissions: 0x10000000, // GENERIC_ALL
|
||||
grfAccessMode: SET_ACCESS,
|
||||
grfInheritance: ACE_FLAGS(0), // No inheritance for files
|
||||
Trustee: TRUSTEE_W {
|
||||
pMultipleTrustee: std::ptr::null_mut(),
|
||||
MultipleTrusteeOperation:
|
||||
windows::Win32::Security::Authorization::NO_MULTIPLE_TRUSTEE,
|
||||
TrusteeForm: TRUSTEE_IS_SID,
|
||||
TrusteeType: TRUSTEE_IS_USER,
|
||||
ptstrName: windows::core::PWSTR(user_sid.0 as *mut u16),
|
||||
},
|
||||
};
|
||||
|
||||
// Create new ACL with only this entry
|
||||
let mut new_acl: *mut ACL = std::ptr::null_mut();
|
||||
let result = SetEntriesInAclW(Some(&[explicit_access]), None, &mut new_acl);
|
||||
if result.0 != 0 {
|
||||
let _ = CloseHandle(token_handle);
|
||||
return Err(io::Error::from_raw_os_error(result.0 as i32));
|
||||
}
|
||||
|
||||
// Convert path to wide string for Windows API
|
||||
let wide_path: Vec<u16> = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
// Set the new DACL on the file, removing inherited permissions
|
||||
let result = SetNamedSecurityInfoW(
|
||||
PCWSTR::from_raw(wide_path.as_ptr()),
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
None, // psidOwner: not changing the owner
|
||||
None, // psidGroup: not changing the primary group
|
||||
Some(new_acl),
|
||||
None,
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = LocalFree(Some(HLOCAL(new_acl as *mut _)));
|
||||
let _ = CloseHandle(token_handle);
|
||||
|
||||
if result.0 != 0 {
|
||||
return Err(io::Error::from_raw_os_error(result.0 as i32));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_write_secure_file_creates_file() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("test_secure.txt");
|
||||
|
||||
write_secure_file(&file_path, b"test content").unwrap();
|
||||
|
||||
assert!(file_path.exists());
|
||||
let content = fs::read_to_string(&file_path).unwrap();
|
||||
assert_eq!(content, "test content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_secure_file_creates_parent_dirs() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("nested").join("dir").join("test.txt");
|
||||
|
||||
write_secure_file(&file_path, b"nested content").unwrap();
|
||||
|
||||
assert!(file_path.exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn test_unix_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let file_path = temp_dir.path().join("test_perms.txt");
|
||||
|
||||
write_secure_file(&file_path, b"secure content").unwrap();
|
||||
|
||||
let metadata = fs::metadata(&file_path).unwrap();
|
||||
let mode = metadata.permissions().mode();
|
||||
// Check that only owner has read/write (0o600), ignoring file type bits
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
152
crates/codegen/xai-grok-shell-base/src/util/tips.rs
Normal file
152
crates/codegen/xai-grok-shell-base/src/util/tips.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
//! Tip of the Day — selection logic for tips served from remote settings.
|
||||
//!
|
||||
//! Tips are fetched at startup via `RemoteSettings.tips` (from `/v1/settings`).
|
||||
//! This module provides per-session rotation: each launch shows the next tip
|
||||
//! in sequence, cycling through all tips before repeating. The cursor is
|
||||
//! persisted to `~/.grok/tip_cursor.json`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const CURSOR_FILE: &str = "tip_cursor.json";
|
||||
|
||||
/// Persistent state for tip rotation.
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct TipState {
|
||||
cursor: u64,
|
||||
}
|
||||
|
||||
fn cursor_path(grok_home: &Path) -> PathBuf {
|
||||
grok_home.join(CURSOR_FILE)
|
||||
}
|
||||
|
||||
/// Load the cursor from `~/.grok/tip_cursor.json`. Returns 0 on any error.
|
||||
fn load_cursor(grok_home: &Path) -> u64 {
|
||||
let text = match std::fs::read_to_string(cursor_path(grok_home)) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
serde_json::from_str::<TipState>(&text)
|
||||
.map(|s| s.cursor)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Save the cursor to `~/.grok/tip_cursor.json`. Silently ignores write errors.
|
||||
fn save_cursor(grok_home: &Path, cursor: u64) {
|
||||
if let Ok(text) = serde_json::to_string(&TipState { cursor }) {
|
||||
let _ = std::fs::write(cursor_path(grok_home), text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the next tip for this session and advance the persistent cursor.
|
||||
///
|
||||
/// Each call returns the tip at `cursor % tips.len()` and increments the
|
||||
/// cursor in `~/.grok/tip_cursor.json`, so every session sees the next tip
|
||||
/// in sequence. After all tips have been shown, the cycle repeats.
|
||||
///
|
||||
/// Returns `None` if `tips` is empty (cursor is not advanced in that case).
|
||||
pub fn pick_and_advance(tips: &[String], grok_home: &Path) -> Option<String> {
|
||||
if tips.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cursor = load_cursor(grok_home);
|
||||
let tip = tips[cursor as usize % tips.len()].clone();
|
||||
save_cursor(grok_home, cursor + 1);
|
||||
Some(tip)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── pick_and_advance ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn empty_list_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(pick_and_advance(&[], dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_list_does_not_advance_cursor() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
pick_and_advance(&[], dir.path());
|
||||
assert_eq!(load_cursor(dir.path()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_tip_always_returned() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tips = vec!["only".to_string()];
|
||||
assert_eq!(pick_and_advance(&tips, dir.path()).as_deref(), Some("only"));
|
||||
assert_eq!(pick_and_advance(&tips, dir.path()).as_deref(), Some("only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycles_through_all_tips_in_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tips = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||
assert_eq!(pick_and_advance(&tips, dir.path()).as_deref(), Some("a"));
|
||||
assert_eq!(pick_and_advance(&tips, dir.path()).as_deref(), Some("b"));
|
||||
assert_eq!(pick_and_advance(&tips, dir.path()).as_deref(), Some("c"));
|
||||
// full cycle: wraps back to first
|
||||
assert_eq!(pick_and_advance(&tips, dir.path()).as_deref(), Some("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_persists_across_calls() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tips = vec!["x".to_string(), "y".to_string()];
|
||||
pick_and_advance(&tips, dir.path()); // cursor → 1
|
||||
assert_eq!(load_cursor(dir.path()), 1);
|
||||
pick_and_advance(&tips, dir.path()); // cursor → 2
|
||||
assert_eq!(load_cursor(dir.path()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_cursor_file_starts_at_zero() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let tips = vec!["first".to_string(), "second".to_string()];
|
||||
assert_eq!(
|
||||
pick_and_advance(&tips, dir.path()).as_deref(),
|
||||
Some("first")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_list_length_change_gracefully() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Start with 3 tips, advance cursor to 3
|
||||
let tips3 = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||
pick_and_advance(&tips3, dir.path()); // cursor 0 → 1
|
||||
pick_and_advance(&tips3, dir.path()); // cursor 1 → 2
|
||||
pick_and_advance(&tips3, dir.path()); // cursor 2 → 3
|
||||
|
||||
// remote settings pushes a 5-tip list; cursor=3, 3%5=3 → "d"
|
||||
let tips5 = vec![
|
||||
"a".to_string(),
|
||||
"b".to_string(),
|
||||
"c".to_string(),
|
||||
"d".to_string(),
|
||||
"e".to_string(),
|
||||
];
|
||||
assert_eq!(pick_and_advance(&tips5, dir.path()).as_deref(), Some("d"));
|
||||
}
|
||||
|
||||
// ── load_cursor / save_cursor ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn load_cursor_returns_zero_for_corrupt_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(cursor_path(dir.path()), b"not json").unwrap();
|
||||
assert_eq!(load_cursor(dir.path()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
save_cursor(dir.path(), 42);
|
||||
assert_eq!(load_cursor(dir.path()), 42);
|
||||
}
|
||||
}
|
||||
156
crates/codegen/xai-grok-shell-base/src/util/uname.rs
Normal file
156
crates/codegen/xai-grok-shell-base/src/util/uname.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
//! OS version string for the `<user_info>` preamble.
|
||||
//!
|
||||
//! Emits `OS Version: <kernel> <release>` (e.g. `darwin 24.6.0`,
|
||||
//! `linux 6.5.0-...`).
|
||||
//!
|
||||
//! `std::env::consts::OS` returns `"macos"` / `"linux"` -- the OS *family*,
|
||||
//! not the kernel name and not the release. This module wraps `libc::uname`
|
||||
//! (Unix) with a `std::env::consts::OS` fallback (any non-unix platform or
|
||||
//! syscall failure) so the result is always a non-empty string we can drop
|
||||
//! into the placeholder bag.
|
||||
|
||||
/// Return `"<kernel-lowercased> <release>"` for the `os_family` placeholder
|
||||
/// (e.g. `"darwin 24.6.0"` on macOS Sonoma 14.6, `"linux 6.5.0-1024-aws"` on
|
||||
/// Linux). Falls back to `std::env::consts::OS` when uname is unavailable
|
||||
/// or fails -- callers always get a non-empty string.
|
||||
pub fn os_kernel_and_release() -> String {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Some(s) = uname_unix() {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Some(s) = windows_version() {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
std::env::consts::OS.to_string()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn uname_unix() -> Option<String> {
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
let mut uts: MaybeUninit<libc::utsname> = MaybeUninit::zeroed();
|
||||
// SAFETY: libc::uname writes into the provided buffer and returns 0 on
|
||||
// success / -1 on failure. We do not read any uninitialized fields on
|
||||
// the failure path.
|
||||
let rc = unsafe { libc::uname(uts.as_mut_ptr()) };
|
||||
if rc != 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: rc == 0 means uname populated all fields with NUL-terminated
|
||||
// strings of length <= the buffer size (per POSIX).
|
||||
let uts = unsafe { uts.assume_init() };
|
||||
|
||||
let sysname = c_char_array_to_lowercase_string(&uts.sysname)?;
|
||||
let release = c_char_array_to_string(&uts.release)?;
|
||||
Some(format!("{sysname} {release}"))
|
||||
}
|
||||
|
||||
/// Convert a NUL-terminated `c_char` array (as returned in `utsname` fields)
|
||||
/// into an owned `String`. Returns `None` if the bytes are not valid UTF-8 or
|
||||
/// the array lacks a NUL terminator.
|
||||
#[cfg(unix)]
|
||||
fn c_char_array_to_string(bytes: &[libc::c_char]) -> Option<String> {
|
||||
use std::ffi::CStr;
|
||||
// SAFETY: utsname fields are POSIX-defined NUL-terminated byte strings.
|
||||
// The cast from c_char to u8 is layout-compatible on all platforms libc
|
||||
// supports; we treat the bytes as opaque UTF-8 candidates.
|
||||
let bytes: &[u8] =
|
||||
unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast::<u8>(), bytes.len()) };
|
||||
let cstr = CStr::from_bytes_until_nul(bytes).ok()?;
|
||||
cstr.to_str().ok().map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn c_char_array_to_lowercase_string(bytes: &[libc::c_char]) -> Option<String> {
|
||||
c_char_array_to_string(bytes).map(|s| s.to_lowercase())
|
||||
}
|
||||
|
||||
/// Return `"windows <major>.<minor>.<build>"` (e.g. `"windows 10.0.22631.4890"`)
|
||||
/// by parsing the output of `cmd /C ver`. Falls back to `None` on any failure
|
||||
/// so callers get the `std::env::consts::OS` default.
|
||||
#[cfg(windows)]
|
||||
fn windows_version() -> Option<String> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/C", "ver"]);
|
||||
xai_tty_utils::detach_std_command(&mut cmd);
|
||||
let output = cmd.output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
// `ver` outputs e.g. "Microsoft Windows [Version 10.0.22631.4890]".
|
||||
// The bracketed portion is locale-independent.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let start = stdout.find("[Version ")? + "[Version ".len();
|
||||
let end = stdout[start..].find(']')? + start;
|
||||
let version = stdout[start..end].trim();
|
||||
if version.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("windows {version}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// On any platform the function produces a non-empty string. Exact
|
||||
/// content varies by host so we only assert the shape.
|
||||
#[test]
|
||||
fn os_kernel_and_release_is_non_empty() {
|
||||
let s = os_kernel_and_release();
|
||||
assert!(!s.is_empty(), "os_kernel_and_release returned empty");
|
||||
}
|
||||
|
||||
/// On Unix hosts the format is `<kernel> <release>` -- two
|
||||
/// whitespace-separated tokens, both non-empty, both lowercase for
|
||||
/// the kernel half.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn os_kernel_and_release_unix_shape() {
|
||||
let s = os_kernel_and_release();
|
||||
// Skip the assertion if uname failed and we fell back to
|
||||
// `std::env::consts::OS` (single token, e.g. "macos"). The
|
||||
// fallback is correct behavior; the test just can't tell which
|
||||
// path produced the value without re-calling uname itself.
|
||||
if !s.contains(' ') {
|
||||
return;
|
||||
}
|
||||
let mut parts = s.splitn(2, ' ');
|
||||
let kernel = parts.next().expect("kernel half present");
|
||||
let release = parts.next().expect("release half present");
|
||||
assert!(!kernel.is_empty(), "kernel half empty in '{s}'");
|
||||
assert!(!release.is_empty(), "release half empty in '{s}'");
|
||||
assert_eq!(
|
||||
kernel,
|
||||
kernel.to_lowercase(),
|
||||
"kernel half must be lowercase: '{s}'"
|
||||
);
|
||||
}
|
||||
|
||||
/// On macOS specifically the kernel name is `darwin`. This is the
|
||||
/// regression guard for the original bug ("OS Version: macos" vs
|
||||
/// "OS Version: darwin 24.6.0").
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn os_kernel_and_release_macos_says_darwin() {
|
||||
let s = os_kernel_and_release();
|
||||
// Skip if uname failed (fallback returns "macos"). On real CI/dev
|
||||
// hardware this branch is never taken.
|
||||
if !s.contains(' ') {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
s.starts_with("darwin "),
|
||||
"macOS host must report 'darwin <release>', got '{s}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue