M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -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 `$KIGI_SHARE_DIR` 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
|
||||
/// `kigi_home()` OnceLock). A seeded `$KIGI_SHARE_DIR` 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("KIGI_SHARE_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or_else(crate::util::kigi_home::kigi_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 `KIGI_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
|
||||
/// `$KIGI_SHARE_DIR` 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 (`KIGI_SHARE_DIR` /
|
||||
/// `KIGI_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 = kigi_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 `$KIGI_SHARE_DIR` 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("KIGI_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
|
||||
/// `$KIGI_SHARE_DIR` 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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Re-exported from the defining crate so this crate stays off the tool stack.
|
||||
pub use kigi_config::{
|
||||
decode_cwd_from_dirname, encode_cwd_dirname, ensure_sessions_cwd_dir, kigi_application,
|
||||
kigi_home, sessions_cwd_dir,
|
||||
};
|
||||
@@ -0,0 +1,333 @@
|
||||
pub mod changelog;
|
||||
pub mod event_id;
|
||||
pub mod kigi_home;
|
||||
pub mod secure_file;
|
||||
pub mod tips;
|
||||
pub mod uname;
|
||||
pub use kigi_shared::clipboard;
|
||||
pub use kigi_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 subscription coding-API URLs (the compiled production endpoint;
|
||||
/// deliberately NOT the env-overridable [`kigi_env::coding_api_base_url`] so a
|
||||
/// runtime override can't widen this trust set).
|
||||
pub fn is_cli_chat_proxy_url(url: &str) -> bool {
|
||||
matches_trusted_base_url(url, kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url)
|
||||
}
|
||||
/// True for URLs the idle model-metadata refresh may re-fetch from: the
|
||||
/// *effective* subscription coding endpoint (the `KIGI_CODE_BASE_URL`
|
||||
/// override when set, else the compiled production endpoint), plus loopback
|
||||
/// hosts (local dev proxies and test mocks). Unlike [`is_cli_chat_proxy_url`]
|
||||
/// this honours the env override and loopback, so use it only to gate traffic
|
||||
/// that already flows to the session's configured base URL (the refresh
|
||||
/// re-fetches from the same host the session samples against); it must never
|
||||
/// widen a security trust set.
|
||||
pub fn is_effective_coding_endpoint_url(url: &str) -> bool {
|
||||
if is_cli_chat_proxy_url(url) {
|
||||
return true;
|
||||
}
|
||||
if matches_trusted_base_url(url, &kigi_env::coding_api_base_url()) {
|
||||
return true;
|
||||
}
|
||||
reqwest::Url::parse(url)
|
||||
.ok()
|
||||
.is_some_and(|u| match u.host() {
|
||||
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
|
||||
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
|
||||
Some(url::Host::Domain(host)) => host == "localhost",
|
||||
None => 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());
|
||||
kigi_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://api.kimi.com/coding/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://api.kimi.com.evil.example/coding/v1"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_is_effective_coding_endpoint_url_accepts_prod_and_loopback() {
|
||||
assert!(is_effective_coding_endpoint_url(
|
||||
"https://api.kimi.com/coding/v1"
|
||||
));
|
||||
assert!(is_effective_coding_endpoint_url("http://127.0.0.1:8080/v1"));
|
||||
assert!(is_effective_coding_endpoint_url("http://localhost:8080/v1"));
|
||||
assert!(is_effective_coding_endpoint_url("http://[::1]:8080/v1"));
|
||||
}
|
||||
#[test]
|
||||
fn test_is_effective_coding_endpoint_url_rejects_remote_third_party() {
|
||||
assert!(!is_effective_coding_endpoint_url("https://api.x.ai/v1"));
|
||||
assert!(!is_effective_coding_endpoint_url(
|
||||
"https://localhost.evil.example/v1"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_is_cli_chat_proxy_url_rejects_v11_prefix_confusion() {
|
||||
assert!(!is_cli_chat_proxy_url(
|
||||
"https://api.kimi.com/coding/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://api.kimi.com/coding/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));
|
||||
}
|
||||
}
|
||||
@@ -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 kigi_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);
|
||||
}
|
||||
}
|
||||
@@ -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 `~/.kigi/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(kigi_home: &Path) -> PathBuf {
|
||||
kigi_home.join(CURSOR_FILE)
|
||||
}
|
||||
|
||||
/// Load the cursor from `~/.kigi/tip_cursor.json`. Returns 0 on any error.
|
||||
fn load_cursor(kigi_home: &Path) -> u64 {
|
||||
let text = match std::fs::read_to_string(cursor_path(kigi_home)) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
serde_json::from_str::<TipState>(&text)
|
||||
.map(|s| s.cursor)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Save the cursor to `~/.kigi/tip_cursor.json`. Silently ignores write errors.
|
||||
fn save_cursor(kigi_home: &Path, cursor: u64) {
|
||||
if let Ok(text) = serde_json::to_string(&TipState { cursor }) {
|
||||
let _ = std::fs::write(cursor_path(kigi_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 `~/.kigi/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], kigi_home: &Path) -> Option<String> {
|
||||
if tips.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cursor = load_cursor(kigi_home);
|
||||
let tip = tips[cursor as usize % tips.len()].clone();
|
||||
save_cursor(kigi_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);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
kigi_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}'"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user