Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View 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

File diff suppressed because it is too large Load diff

View 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"
);
}
}
}

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

View 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");
}
}

View 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);
}
}

View 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,
};

View 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.01.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));
}
}

View 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);
}
}

View 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);
}
}

View 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}'"
);
}
}