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:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,46 @@
//! Stable per-install agent identifier.
//!
//! Stamped on requests (`x-grok-agent-id` / `x_grok_agent_id`) so the backend
//! can bucket by install. Cached in `$KIGI_SHARE_DIR/agent_id` so every process on
//! this install (and restarts) agree; the in-memory `OnceLock` makes repeat
//! calls free.
use std::sync::OnceLock;
/// Cached agent ID — stored in memory after first load.
static AGENT_ID: OnceLock<String> = OnceLock::new();
/// Cached agent instance ID — per-process lifetime.
static AGENT_INSTANCE_ID: OnceLock<String> = OnceLock::new();
/// Returns the per-install agent ID, backed by a file cache under the grok
/// home so it is stable across process restarts.
pub fn agent_id() -> String {
AGENT_ID.get_or_init(load_or_compute_agent_id).clone()
}
/// Returns a per-process agent instance ID: stable within one process,
/// new on process restart.
pub fn agent_instance_id() -> String {
AGENT_INSTANCE_ID
.get_or_init(|| uuid::Uuid::new_v4().to_string())
.clone()
}
fn load_or_compute_agent_id() -> String {
let cache_path = crate::util::kigi_home::kigi_home().join("agent_id");
// Try to read from the cache file first (fast path).
if let Ok(cached) = std::fs::read_to_string(&cache_path) {
let cached = cached.trim();
if !cached.is_empty() {
return cached.to_string();
}
}
let id = uuid::Uuid::new_v4().to_string();
// Save to the cache file (best effort, ignore errors).
let _ = std::fs::write(&cache_path, &id);
id
}
@@ -0,0 +1,745 @@
//! Campaign dismiss state, remote cache, and effective-config overlay.
//!
//! Design, invariants, and the "adding a second governed field" recipe are
//! documented alongside this module.
use std::collections::HashSet;
use std::path::Path;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering};
use kigi_config::campaigns::{
CampaignEntry, filter_active_campaigns, ids_touching_paths, merge_campaign_entries,
};
use kigi_config::config_override::{PatchPath, patch_touches_any};
use kigi_config::{
CampaignsState, ConfigLayers, campaigns_state_path, load_dismissed_ids_from_home,
user_kigi_home,
};
use kigi_config_types::{CampaignOverride, RemoteSettings};
/// FIFO cap on persisted dismissed ids; evicting the oldest can re-nudge for a
/// still-live campaign after a user dismisses more than this over the CLI's life.
const MAX_DISMISSED_IDS: usize = 32;
static DISMISS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
static DISMISS_TMP_NONCE: AtomicU64 = AtomicU64::new(0);
static REMOTE_CAMPAIGN_CACHE: RwLock<Vec<CampaignEntry>> = RwLock::new(Vec::new());
/// Seed the process-global remote campaign cache. A `None` settings value (e.g.
/// a failed fetch) is a no-op so it can't clobber a previously-seeded cache;
/// `Some` with zero campaigns legitimately clears it (campaigns withdrawn).
pub fn set_remote_campaigns_from_settings(remote: Option<&RemoteSettings>) {
let Some(remote) = remote else {
return;
};
set_remote_campaigns(remote_campaigns_from_settings(Some(remote)));
}
fn set_remote_campaigns(entries: Vec<CampaignEntry>) {
if let Ok(mut g) = REMOTE_CAMPAIGN_CACHE.write() {
*g = entries;
}
}
fn cached_remote_campaigns() -> Vec<CampaignEntry> {
REMOTE_CAMPAIGN_CACHE
.read()
.map(|g| g.clone())
.unwrap_or_default()
}
/// Fail-open dismissed campaign ids from `campaigns_state.json`.
pub fn load_dismissed_ids() -> HashSet<String> {
load_dismissed_ids_from_home()
}
pub fn dismiss_campaign_ids(ids: impl IntoIterator<Item = String>) {
let Some(home) = user_kigi_home() else {
return;
};
if let Err(e) = dismiss_campaign_ids_at(&home, ids) {
tracing::warn!(error = %e, "campaigns: failed to persist dismiss state");
}
}
/// Append `ids` to the dismissed set and write `campaigns_state.json` atomically
/// (temp + rename). Corrupt prior state is renamed aside, not discarded.
fn dismiss_campaign_ids_at(
home: &Path,
ids: impl IntoIterator<Item = String>,
) -> std::io::Result<()> {
use fs2::FileExt as _;
let _guard = DISMISS_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let path = campaigns_state_path(home);
// Cross-process advisory lock over the read-modify-write: in leader mode
// several grok processes share `$KIGI_SHARE_DIR`; the in-process mutex alone would
// let them lose-update the set. Best-effort; a lock failure still proceeds.
let lock = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(path.with_extension("json.lock"));
if let Ok(ref f) = lock {
let _ = f.lock_exclusive();
}
let mut ordered = match std::fs::read_to_string(&path) {
Ok(contents) => match serde_json::from_str::<CampaignsState>(&contents) {
Ok(s) => s.dismissed_ids,
Err(e) => {
let _ = std::fs::rename(&path, path.with_extension("json.corrupt"));
tracing::warn!(error = %e, "campaigns: corrupt dismiss state; renamed aside");
Vec::new()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e),
};
let mut seen: HashSet<String> = ordered.iter().cloned().collect();
for id in ids {
if id.is_empty() || !seen.insert(id.clone()) {
continue;
}
ordered.push(id);
}
if ordered.len() > MAX_DISMISSED_IDS {
let drop_n = ordered.len() - MAX_DISMISSED_IDS;
ordered.drain(..drop_n);
}
let json = serde_json::to_string(&CampaignsState {
dismissed_ids: ordered,
})
.map_err(std::io::Error::other)?;
let nonce = DISMISS_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
let tmp = path.with_extension(format!("json.{}.{}.tmp", std::process::id(), nonce));
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
}
/// `KIGI_CAMPAIGNS_OVERRIDE` JSON array replaces all sources (`[]` = none; beats
/// kill switch). Invalid JSON also resolves to none: the var's intent is "replace
/// campaigns with exactly this", so a typo must not silently fall back to the
/// real sources it was meant to replace.
pub fn campaigns_override() -> Option<Vec<CampaignEntry>> {
let json = std::env::var("KIGI_CAMPAIGNS_OVERRIDE").ok()?;
match serde_json::from_str::<Vec<CampaignOverride>>(&json) {
Ok(list) => Some(
list.into_iter()
.filter_map(remote_campaign_to_entry)
.collect(),
),
Err(e) => {
tracing::warn!(error = %e, "invalid KIGI_CAMPAIGNS_OVERRIDE JSON; suppressing all campaigns");
Some(Vec::new())
}
}
}
fn remote_campaign_to_entry(c: CampaignOverride) -> Option<CampaignEntry> {
let id = c.id.as_deref()?.trim();
if id.is_empty() {
return None;
}
let id = id.to_owned();
// Full-power patch (any field); no allowlist filtering — requirements
// precedence is restored by `ConfigLayers::apply_campaign_overrides`.
let patch = match toml::Value::try_from(serde_json::Value::Object(c.patch)) {
Ok(toml::Value::Table(t)) => t,
Ok(_) => return None,
Err(e) => {
tracing::warn!(error = %e, %id, "campaigns: invalid remote patch; ignoring");
return None;
}
};
if patch.is_empty() {
return None;
}
Some(CampaignEntry { id, patch })
}
pub fn remote_campaigns_from_settings(remote: Option<&RemoteSettings>) -> Vec<CampaignEntry> {
remote
.map(|rs| {
rs.campaigns
.iter()
.cloned()
.filter_map(remote_campaign_to_entry)
.collect()
})
.unwrap_or_default()
}
/// The single campaign-resolution path: `KIGI_CAMPAIGNS_OVERRIDE` (replaces all
/// sources and beats the kill switch) → kill switch → layer+remote merge →
/// dismiss. `base` is the pre-campaign effective config, used only for the
/// kill-switch check.
pub fn resolve_active_campaigns_from_layers(
layers: &ConfigLayers,
base: &toml::Value,
remote_entries: &[CampaignEntry],
dismissed: &HashSet<String>,
) -> Vec<CampaignEntry> {
if let Some(over) = campaigns_override() {
return filter_active_campaigns(over, dismissed);
}
layers.resolve_campaigns(base, remote_entries, dismissed)
}
/// Campaigns eligible for dismissal when the user persists a choice (loads
/// layers + remote cache + dismiss state).
///
/// Unlike the apply path this deliberately **ignores the kill switch**:
/// dismissing a suppressed campaign is harmless, while skipping the dismissal
/// lets a later re-enabled campaign override a choice the user already made
/// ("user pick wins, forever"). A layer-load failure likewise falls back to
/// the remote cache instead of failing closed — remote campaigns still get
/// dismissed on that path, though disk-layer campaigns can be missed until
/// the transient failure clears (they re-dismiss on the next pick).
fn resolve_dismissable_campaigns() -> Vec<CampaignEntry> {
let dismissed = load_dismissed_ids();
if let Some(over) = campaigns_override() {
return filter_active_campaigns(over, &dismissed);
}
let remote_entries = cached_remote_campaigns();
match ConfigLayers::load() {
Ok(layers) => filter_active_campaigns(
merge_campaign_entries(&layers.campaign_source_slices(&remote_entries)),
&dismissed,
),
Err(e) => {
tracing::warn!(error = %e, "campaigns: layer load failed; dismiss bookkeeping using remote cache only");
filter_active_campaigns(remote_entries, &dismissed)
}
}
}
/// Effective config with remote/override-aware campaign overlay
/// (base → resolve [override/kill/merge/dismiss] → apply), one `ConfigLayers::load`.
pub fn load_effective_config() -> std::io::Result<toml::Value> {
let layers = ConfigLayers::load()?;
let dismissed = load_dismissed_ids();
let remote = cached_remote_campaigns();
let mut effective = layers.effective_config_base();
let active = resolve_active_campaigns_from_layers(&layers, &effective, &remote, &dismissed);
layers.apply_campaign_overrides(&mut effective, &active);
Ok(effective)
}
/// Effective config with **disk campaigns only** — no remote cache, no
/// `KIGI_CAMPAIGNS_OVERRIDE`. For one-shot CLI entrypoints that never fetch
/// remote settings: calling [`load_effective_config`] there would silently
/// resolve against a never-seeded cache, so the divergence is named instead
/// of implied (mirrors `ConfigLayers::effective_config_disk_only`).
pub fn load_effective_config_disk_only() -> std::io::Result<toml::Value> {
Ok(ConfigLayers::load()?.effective_config_disk_only())
}
/// Read the value at `path` from an effective-config tree.
fn read_path(tree: &toml::Value, path: PatchPath) -> Option<toml::Value> {
let mut cur = tree;
for key in path {
cur = cur.get(*key)?;
}
Some(cur.clone())
}
fn as_string(v: Option<toml::Value>) -> Option<String> {
v.and_then(|v| v.as_str().map(str::to_owned))
}
/// Resolved campaign state for one [`CampaignField`] after the overlay.
struct CampaignFieldValue {
/// Effective value (campaign value if it won, else the merged base value).
value: Option<toml::Value>,
/// Whether an active campaign actually changed the effective value.
driven: bool,
/// Pre-campaign value to recover to; `Some` only when `driven` and the base had one.
recovery: Option<toml::Value>,
}
/// A config field a campaign may temporarily override until the user sets it.
/// `apply_campaign_fields` drives every [`CAMPAIGN_FIELDS`] entry, so the resolve
/// pass is one row here. A field still needs its runtime state, a `persist_*`
/// writer through [`persist_user_choice`], and any field-specific reaction (e.g.
/// the model catalog-miss/live-session handling in `agent::models`).
struct CampaignField {
/// Path into the effective config; also the dismiss key shared with the writer.
path: PatchPath,
/// Store the resolved value, flag, and recovery onto the agent config.
store: fn(&mut crate::agent::config::Config, CampaignFieldValue),
/// Clear the campaign-driven flag + recovery (value untouched). Used when
/// resolution fails so the runtime state is defined (fail closed, matching
/// the apply path) instead of stale.
reset: fn(&mut crate::agent::config::Config),
}
/// Path of the `models.default` campaign field, shared by the registry row and
/// its dismiss writer so the two can't drift.
const MODELS_DEFAULT_PATH: PatchPath = &["models", "default"];
const CAMPAIGN_FIELDS: &[CampaignField] = &[CampaignField {
path: MODELS_DEFAULT_PATH,
store: |cfg, r| {
cfg.models.default = as_string(r.value);
cfg.models.default_is_campaign_driven = r.driven;
cfg.models.pre_campaign_default = as_string(r.recovery);
},
reset: |cfg| {
cfg.models.default_is_campaign_driven = false;
cfg.models.pre_campaign_default = None;
},
}];
/// Resolve each [`CAMPAIGN_FIELDS`] entry's value, campaign-driven flag, and
/// recovery value from the campaign overlay and store them onto `cfg`. Pure given
/// the resolved `base`/`effective`/`active`; the I/O lives in [`sync_campaign_fields`].
fn apply_campaign_fields(
cfg: &mut crate::agent::config::Config,
base: &toml::Value,
effective: &toml::Value,
active: &[CampaignEntry],
) {
for field in CAMPAIGN_FIELDS {
let value = read_path(effective, field.path);
let base_value = read_path(base, field.path);
// A campaign only *drives* a field when it actually changed the effective
// value: requirements are re-merged after campaigns, so an admin pin wins
// and the campaign patch is a no-op (don't flag it).
let driven = value != base_value
&& active
.iter()
.any(|e| patch_touches_any(&e.patch, &[field.path]));
let recovery = if driven { base_value } else { None };
(field.store)(
cfg,
CampaignFieldValue {
value,
driven,
recovery,
},
);
}
}
/// Seed the remote cache, set every [`CAMPAIGN_FIELDS`] entry (value + flag +
/// recovery) from the campaign overlay, then re-apply requirements so admin pins win.
pub fn sync_campaign_fields(cfg: &mut crate::agent::config::Config) {
let remote = remote_campaigns_from_settings(cfg.remote_settings.as_ref());
// Seed the process-global cache from the parse we already did (skip on `None`
// so a failed fetch can't clobber a previously-seeded cache).
if cfg.remote_settings.is_some() {
set_remote_campaigns(remote.clone());
}
let Ok(layers) = ConfigLayers::load() else {
// Fail closed like the apply path: leave the field values as loaded but
// clear the campaign-driven flags/recovery so they can't go stale (a
// stale flag would mislabel a user value as campaign-driven, or vice
// versa disarm the live-session guard for a campaign value).
tracing::warn!("campaigns: config layer load failed; clearing campaign-driven field state");
for field in CAMPAIGN_FIELDS {
(field.reset)(cfg);
}
return;
};
let dismissed = load_dismissed_ids();
let base = layers.effective_config_base();
let active = resolve_active_campaigns_from_layers(&layers, &base, &remote, &dismissed);
let mut effective = base.clone();
layers.apply_campaign_overrides(&mut effective, &active);
apply_campaign_fields(cfg, &base, &effective, &active);
let _ = crate::config::apply_requirements(cfg);
}
/// Dismiss any active campaign whose patch touches `path`, then persist the
/// setting via `update_config`. The single field-keyed chokepoint, so a new
/// campaign-governable field is one call here with no per-field dismiss wiring.
///
/// Dismiss is recorded **before** the config write so a crash between the two
/// can't leave the campaign active over the user's just-saved value (re-nudge).
/// A dismiss-then-failed-write leaves the dismiss standing (fail-toward-no-nudge).
pub async fn persist_user_choice(
path: PatchPath,
write: impl FnOnce(&mut super::mcp::Config),
) -> anyhow::Result<()> {
// Config-layer reads + the flock'd read-modify-write are blocking I/O;
// keep them off the async worker. Awaited before the config write so the
// dismiss-before-write ordering above holds. A panicked/cancelled dismiss
// task must NOT abort the user's write: bookkeeping failure is logged and
// the write proceeds (the campaign may re-nudge; the pick is never lost).
let dismissed = tokio::task::spawn_blocking(move || {
let ids = ids_touching_paths(&resolve_dismissable_campaigns(), &[path]);
if !ids.is_empty() {
tracing::info!(
?ids,
?path,
"campaigns: dismissed after the user set the field"
);
dismiss_campaign_ids(ids);
}
})
.await;
if let Err(e) = dismissed {
tracing::warn!(error = %e, "campaigns: dismiss bookkeeping task failed; persisting the choice anyway");
}
super::persist::update_config(write).await
}
/// Persist the default model (+ optional reasoning effort) through
/// [`persist_user_choice`], so picking a model dismisses a campaign nudging
/// `models.default`. `None` clears the field.
pub async fn persist_models_default(
value: Option<String>,
reasoning_effort: Option<kigi_sampling_types::ReasoningEffort>,
) -> anyhow::Result<()> {
let s = value.unwrap_or_default();
if s.len() > super::settings_writes::MAX_DEFAULT_MODEL_LEN {
anyhow::bail!(
"model name too long ({} > {} bytes)",
s.len(),
super::settings_writes::MAX_DEFAULT_MODEL_LEN
);
}
persist_user_choice(MODELS_DEFAULT_PATH, move |cfg| {
cfg.models.default = if s.is_empty() { None } else { Some(s) };
if let Some(effort) = reasoning_effort {
cfg.models.default_reasoning_effort = Some(effort);
}
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_config::ConfigLayers;
use kigi_test_support::EnvGuard;
use serial_test::serial;
use tempfile::tempdir;
fn models_default_patch(default: &str) -> toml::Table {
let mut models = toml::map::Map::new();
models.insert("default".into(), toml::Value::String(default.into()));
let mut t = toml::map::Map::new();
t.insert("models".into(), toml::Value::Table(models));
t
}
/// `KIGI_CAMPAIGNS_OVERRIDE` applies despite the kill switch; without it the
/// kill switch (`features.campaigns = false`) wins.
#[test]
#[serial]
fn override_beats_kill_switch() {
let base: toml::Value = toml::from_str("[features]\ncampaigns = false\n").unwrap();
let layers = ConfigLayers::default();
{
let _env = EnvGuard::set(
"KIGI_CAMPAIGNS_OVERRIDE",
r#"[{"id":"c","models":{"default":"m"}}]"#,
);
let active = resolve_active_campaigns_from_layers(&layers, &base, &[], &HashSet::new());
assert_eq!(active.len(), 1, "override must apply despite kill switch");
assert_eq!(active[0].id, "c");
assert_eq!(active[0].patch["models"]["default"].as_str(), Some("m"));
}
// Same disabled base, override now unset → kill switch suppresses all.
let _env = EnvGuard::unset("KIGI_CAMPAIGNS_OVERRIDE");
let active = resolve_active_campaigns_from_layers(&layers, &base, &[], &HashSet::new());
assert!(
active.is_empty(),
"kill switch wins when override is absent"
);
}
/// Invalid `KIGI_CAMPAIGNS_OVERRIDE` JSON fails toward *no campaigns*: the
/// var's intent is "replace campaigns with exactly this", so a typo must not
/// silently re-enable the layer/remote campaigns it was meant to replace.
#[test]
#[serial]
fn invalid_override_json_suppresses_all_campaigns() {
let _env = EnvGuard::set("KIGI_CAMPAIGNS_OVERRIDE", "{ not json");
let mut layers = ConfigLayers::default();
layers.campaigns.user = vec![CampaignEntry {
id: "from-layer".into(),
patch: models_default_patch("layer-model"),
}];
let remote = vec![CampaignEntry {
id: "from-remote".into(),
patch: models_default_patch("remote-model"),
}];
let base = toml::Value::Table(Default::default());
let active = resolve_active_campaigns_from_layers(&layers, &base, &remote, &HashSet::new());
assert!(
active.is_empty(),
"an invalid override must suppress all campaigns, not fall back to real sources"
);
}
/// Dismiss bookkeeping deliberately ignores the kill switch: a model pick
/// made while `KIGI_CAMPAIGNS=0` must still record the dismissal, or a
/// later re-enabled campaign would override the user's explicit choice.
#[test]
#[serial]
fn dismiss_resolution_ignores_kill_switch() {
let _over = EnvGuard::unset("KIGI_CAMPAIGNS_OVERRIDE");
let _kill = EnvGuard::set("KIGI_CAMPAIGNS", "0");
let mut patch = serde_json::Map::new();
patch.insert("models".into(), serde_json::json!({ "default": "m" }));
let rs = RemoteSettings {
campaigns: vec![CampaignOverride {
id: Some("dismiss-during-kill-switch".into()),
patch,
}],
..Default::default()
};
set_remote_campaigns_from_settings(Some(&rs));
let resolved = resolve_dismissable_campaigns();
// Clear the process-global cache before asserting so a failure can't
// leak state into sibling tests.
set_remote_campaigns_from_settings(Some(&RemoteSettings::default()));
assert!(
resolved
.iter()
.any(|c| c.id == "dismiss-during-kill-switch"),
"kill switch must not hide a campaign from dismiss bookkeeping"
);
}
/// `KIGI_CAMPAIGNS_OVERRIDE="[]"` replaces all sources with nothing — even
/// layer + remote campaigns resolve to empty.
#[test]
#[serial]
fn override_empty_means_none() {
let _env = EnvGuard::set("KIGI_CAMPAIGNS_OVERRIDE", "[]");
let mut layers = ConfigLayers::default();
layers.campaigns.user = vec![CampaignEntry {
id: "from-layer".into(),
patch: models_default_patch("layer-model"),
}];
let remote = vec![CampaignEntry {
id: "from-remote".into(),
patch: models_default_patch("remote-model"),
}];
let base = toml::Value::Table(Default::default());
let active = resolve_active_campaigns_from_layers(&layers, &base, &remote, &HashSet::new());
assert!(
active.is_empty(),
"empty override replaces all sources, yielding no campaigns"
);
}
/// Contract: `persist_user_choice(["models","default"], ..)` dismisses only
/// campaigns that touch that path, never a sibling-field campaign. The full
/// wiring (set_default_model -> persist -> dismiss) is covered end to end by
/// the pager `pty_e2e` campaign test.
#[test]
fn models_default_persist_targets_only_model_campaigns() {
let model_campaign = CampaignEntry {
id: "release".into(),
patch: models_default_patch("new-model"),
};
let other_campaign = CampaignEntry {
id: "other".into(),
patch: toml::from_str::<toml::Table>("[features]\nweb_fetch = true\n").unwrap(),
};
let path: &[PatchPath] = &[&["models", "default"]];
let ids = ids_touching_paths(&[model_campaign, other_campaign], path);
assert_eq!(ids, vec!["release".to_string()]);
}
/// `apply_campaign_fields` flags a field campaign-driven only when the campaign
/// actually changed the effective value: a campaign win sets the flag + recovery,
/// but a requirements win (effective == base) does not (and stores no recovery).
#[test]
fn campaign_field_flags_campaign_win_not_requirements_win() {
let active = vec![CampaignEntry {
id: "release".into(),
patch: models_default_patch("campaign-model"),
}];
let base: toml::Value = toml::from_str("[models]\ndefault = \"base-model\"\n").unwrap();
// Campaign won the effective default.
let mut cfg = crate::agent::config::Config::default();
let won: toml::Value = toml::from_str("[models]\ndefault = \"campaign-model\"\n").unwrap();
apply_campaign_fields(&mut cfg, &base, &won, &active);
assert_eq!(cfg.models.default.as_deref(), Some("campaign-model"));
assert!(cfg.models.default_is_campaign_driven);
assert_eq!(
cfg.models.pre_campaign_default.as_deref(),
Some("base-model")
);
// Requirements re-merge clobbered the campaign back to the base value.
let mut cfg = crate::agent::config::Config::default();
apply_campaign_fields(&mut cfg, &base, &base, &active);
assert_eq!(cfg.models.default.as_deref(), Some("base-model"));
assert!(!cfg.models.default_is_campaign_driven);
assert_eq!(cfg.models.pre_campaign_default, None);
// No active campaign touching the field: never driven.
let mut cfg = crate::agent::config::Config::default();
apply_campaign_fields(&mut cfg, &base, &won, &[]);
assert!(!cfg.models.default_is_campaign_driven);
assert_eq!(cfg.models.pre_campaign_default, None);
}
/// Fix: in leader mode the pager seeds the remote-campaign cache so the
/// dismiss path (which runs in the pager process) can see remote campaigns.
/// Verify a seeded remote campaign round-trips into the dismiss-id set.
#[test]
#[serial]
fn seeded_remote_campaign_is_visible_to_dismiss() {
let _env = EnvGuard::unset("KIGI_CAMPAIGNS_OVERRIDE");
let mut patch = serde_json::Map::new();
patch.insert("models".into(), serde_json::json!({ "default": "m" }));
let rs = RemoteSettings {
campaigns: vec![CampaignOverride {
id: Some("remote-1".into()),
patch,
}],
..Default::default()
};
set_remote_campaigns_from_settings(Some(&rs));
let cached = cached_remote_campaigns();
assert!(cached.iter().any(|c| c.id == "remote-1"));
let path: &[PatchPath] = &[&["models", "default"]];
assert_eq!(
ids_touching_paths(&cached, path),
vec!["remote-1".to_string()]
);
// Clear the process-global cache so other tests aren't affected.
set_remote_campaigns_from_settings(Some(&RemoteSettings::default()));
}
/// An override-supplied campaign whose id is already dismissed is dropped.
#[test]
#[serial]
fn dismissed_id_is_dropped_from_override() {
let _env = EnvGuard::set(
"KIGI_CAMPAIGNS_OVERRIDE",
r#"[{"id":"seen","models":{"default":"m"}}]"#,
);
let layers = ConfigLayers::default();
let base = toml::Value::Table(Default::default());
let dismissed: HashSet<String> = ["seen".to_owned()].into_iter().collect();
let active = resolve_active_campaigns_from_layers(&layers, &base, &[], &dismissed);
assert!(active.is_empty(), "a dismissed id must not re-apply");
}
/// Corrupt `campaigns_state.json` is preserved as `*.json.corrupt`, the new
/// dismiss still lands, and the cap drops the oldest ids.
#[test]
fn dismiss_persists_handles_corrupt_and_caps() {
let home = tempdir().unwrap();
std::fs::write(campaigns_state_path(home.path()), "{ not json").unwrap();
dismiss_campaign_ids_at(home.path(), ["new-id".to_owned()]).unwrap();
assert!(
home.path().join("campaigns_state.json.corrupt").exists(),
"corrupt state must be renamed aside, not discarded"
);
dismiss_campaign_ids_at(home.path(), (0..40).map(|i| format!("id-{i}"))).unwrap();
let contents = std::fs::read_to_string(campaigns_state_path(home.path())).unwrap();
let set: HashSet<String> = serde_json::from_str::<CampaignsState>(&contents)
.unwrap()
.dismissed_ids
.into_iter()
.collect();
assert_eq!(set.len(), MAX_DISMISSED_IDS);
assert!(set.contains("id-39"));
assert!(!set.contains("new-id"), "oldest ids evicted past the cap");
}
/// A remote campaign's flattened JSON patch becomes a full TOML patch (any
/// field), and an id-less entry is dropped.
#[test]
fn remote_campaign_to_entry_builds_full_patch() {
let mut patch = serde_json::Map::new();
patch.insert(
"models".into(),
serde_json::json!({ "default": "remote-model" }),
);
patch.insert("features".into(), serde_json::json!({ "web_fetch": true }));
let entry = remote_campaign_to_entry(CampaignOverride {
id: Some("r1".into()),
patch,
})
.expect("entry with id + patch survives");
assert_eq!(
entry.patch["models"]["default"].as_str(),
Some("remote-model")
);
assert_eq!(entry.patch["features"]["web_fetch"].as_bool(), Some(true));
let no_id = CampaignOverride {
id: None,
patch: {
let mut p = serde_json::Map::new();
p.insert("models".into(), serde_json::json!({ "default": "x" }));
p
},
};
assert!(remote_campaign_to_entry(no_id).is_none());
}
/// The remote JSON shape accepts `campaign_id` as an alias for `id`, matching
/// the TOML `CampaignMeta` contract so the two sides can't drift. The id key
/// (either spelling) must be *consumed*, never leak into the flattened patch —
/// a leaked key would deep-merge junk into every effective config.
#[test]
fn campaign_id_json_alias_is_accepted_and_does_not_leak_into_patch() {
for raw in [
r#"[{"campaign_id":"r1","models":{"default":"m"}}]"#,
r#"[{"id":"r1","models":{"default":"m"}}]"#,
] {
let list: Vec<CampaignOverride> = serde_json::from_str(raw).unwrap();
let entry = remote_campaign_to_entry(list.into_iter().next().unwrap())
.expect("entry with id survives");
assert_eq!(entry.id, "r1");
assert!(
entry.patch.get("id").is_none() && entry.patch.get("campaign_id").is_none(),
"id keys must not leak into the patch: {raw}"
);
}
}
/// Every registry row's `reset` clears the campaign-driven runtime state a
/// prior `store` set (used on the resolution-failure path so flags can't go
/// stale).
#[test]
fn campaign_field_reset_clears_driven_state() {
let mut cfg = crate::agent::config::Config::default();
for field in CAMPAIGN_FIELDS {
(field.store)(
&mut cfg,
CampaignFieldValue {
value: Some(toml::Value::String("campaign-model".into())),
driven: true,
recovery: Some(toml::Value::String("base-model".into())),
},
);
}
assert!(cfg.models.default_is_campaign_driven);
assert!(cfg.models.pre_campaign_default.is_some());
for field in CAMPAIGN_FIELDS {
(field.reset)(&mut cfg);
}
assert!(!cfg.models.default_is_campaign_driven);
assert_eq!(cfg.models.pre_campaign_default, None);
// The field *value* is left as loaded; reset only clears the metadata.
assert_eq!(cfg.models.default.as_deref(), Some("campaign-model"));
}
}
@@ -0,0 +1,376 @@
use super::ContextualHintsRemote;
use crate::agent::config::ContextualHints;
use toml::Value as TomlValue;
use toml::map::Map as TomlMap;
/// Persisted worktree preference for `/new` and `/fork` (`[hints]` in config.toml).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WorktreeHintMode {
/// Always show the popup.
Ask,
/// Always create a worktree, skip the popup.
Always,
/// Never create a worktree, skip the popup.
#[default]
Never,
}
impl WorktreeHintMode {
pub fn from_config_str(s: &str) -> Self {
match s {
"always" => Self::Always,
"never" => Self::Never,
"ask" => Self::Ask,
other => {
tracing::debug!(
value = other,
"unrecognised worktree_mode, defaulting to never"
);
Self::Never
}
}
}
pub fn as_config_str(self) -> &'static str {
match self {
Self::Ask => "ask",
Self::Always => "always",
Self::Never => "never",
}
}
/// Returns `(new_session_worktree_mode, fork_worktree_mode)`.
///
/// - `/new`: `new_session_worktree_mode`, else legacy `worktree_mode`, else `Never`.
/// - `/fork`: `fork_worktree_mode`, else legacy `worktree_mode`, else `Ask`.
pub fn resolve_pair(hints: Option<&TomlValue>) -> (Self, Self) {
let get_str = |key: &str| -> Option<Self> {
hints
.and_then(|h| h.get(key))
.and_then(|v| v.as_str())
.map(Self::from_config_str)
};
let legacy = get_str("worktree_mode");
let new_session = get_str("new_session_worktree_mode")
.or(legacy)
.unwrap_or(Self::Never);
let fork = get_str("fork_worktree_mode")
.or(legacy)
.unwrap_or(Self::Ask);
(new_session, fork)
}
}
/// Resolved `[hints]` UI opt-outs (TUI "don't ask again" and related).
///
/// Read via effective config merge when available; falls back to partial layer
/// merge so a bad user `config.toml` does not drop managed/requirements hints.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedHints {
pub project_picker_disabled: bool,
pub new_session_worktree_mode: WorktreeHintMode,
pub fork_worktree_mode: WorktreeHintMode,
}
impl Default for ResolvedHints {
fn default() -> Self {
Self {
project_picker_disabled: false,
new_session_worktree_mode: WorktreeHintMode::Never,
fork_worktree_mode: WorktreeHintMode::Ask,
}
}
}
impl ResolvedHints {
fn from_hints_table(hints: Option<&TomlValue>) -> Self {
let hint_bool = |key: &str| -> bool {
hints
.and_then(|h| h.get(key))
.and_then(|v| v.as_bool())
.unwrap_or(false)
};
let (new_session, fork) = WorktreeHintMode::resolve_pair(hints);
Self {
project_picker_disabled: hint_bool("project_picker_disabled"),
new_session_worktree_mode: new_session,
fork_worktree_mode: fork,
}
}
}
/// Resolved per-tip contextual-hint gates (one bool per tip). Defaults all on.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResolvedContextualHints {
pub undo: bool,
pub plan_mode: bool,
pub image_input: bool,
pub send_now: bool,
pub small_screen: bool,
pub word_select: bool,
}
impl Default for ResolvedContextualHints {
fn default() -> Self {
Self {
undo: true,
plan_mode: true,
image_input: true,
send_now: true,
small_screen: true,
word_select: true,
}
}
}
/// Resolve the per-tip contextual-hint gates. Per tip the precedence is:
/// env master `KIGI_CONTEXTUAL_HINTS` (all-on/off) > user config
/// `[ui.contextual_hints].X` > remote settings `contextual_hints.X` >
/// default ON. User-explicit beats the remote tier (which only sets the
/// default / soft-disables); the env master is a global kill/force switch.
pub fn resolve_contextual_hints(
ui: &ContextualHints,
remote: Option<&ContextualHintsRemote>,
) -> ResolvedContextualHints {
use crate::agent::config::BoolFlag;
let resolve_tip = |user: Option<bool>, feature_flag: Option<bool>| -> bool {
BoolFlag::env("KIGI_CONTEXTUAL_HINTS")
.config(user)
.feature_flag(feature_flag)
.default(true)
.resolve()
.value
};
ResolvedContextualHints {
undo: resolve_tip(ui.undo, remote.and_then(|r| r.undo)),
plan_mode: resolve_tip(ui.plan_mode, remote.and_then(|r| r.plan_mode)),
image_input: resolve_tip(ui.image_input, remote.and_then(|r| r.image_input)),
send_now: resolve_tip(ui.send_now, remote.and_then(|r| r.send_now)),
small_screen: resolve_tip(ui.small_screen, remote.and_then(|r| r.small_screen)),
word_select: resolve_tip(ui.word_select, remote.and_then(|r| r.word_select)),
}
}
/// Merge config layers in effective-config order (system managed → managed →
/// user → requirements). Used when [`load_effective_config`] fails but some
/// layers still loaded (same pattern as tips/announcements).
fn merge_hints_config_layers(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
) -> TomlValue {
let mut merged = crate::config::load_system_managed_config()
.unwrap_or_else(|_| TomlValue::Table(TomlMap::new()));
if let Some(m) = managed {
kigi_config::deep_merge_toml(&mut merged, m);
}
if let Some(u) = user {
kigi_config::deep_merge_toml(&mut merged, u);
}
if let Some(r) = requirements {
kigi_config::deep_merge_toml(&mut merged, r);
}
merged
}
/// Resolve `[hints]` from effective config or partial layer merge.
///
/// Prefer passing a pre-loaded `effective_config` when startup already called
/// [`crate::config::load_effective_config`]. When it is `None`, merges the
/// same layers tips/announcements use so managed/requirements still apply.
pub fn resolve_hints(
effective_config: Option<&TomlValue>,
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
) -> ResolvedHints {
let root = effective_config
.cloned()
.unwrap_or_else(|| merge_hints_config_layers(requirements, user, managed));
ResolvedHints::from_hints_table(root.get("hints"))
}
/// Load config from disk and resolve `[hints]`.
pub fn resolve_hints_from_disk() -> ResolvedHints {
let effective = crate::config::load_effective_config().ok();
let requirements = crate::config::load_merged_requirements();
let user = crate::config::load_from_disk().ok();
let managed = crate::config::load_managed_config().ok();
resolve_hints(
effective.as_ref(),
requirements.as_ref(),
user.as_ref(),
managed.as_ref(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_hints_requirements_overrides_user_when_effective_missing() {
let user: TomlValue = toml::from_str("[hints]\nproject_picker_disabled = false\n").unwrap();
let requirements: TomlValue =
toml::from_str("[hints]\nproject_picker_disabled = true\n").unwrap();
let resolved = resolve_hints(None, Some(&requirements), Some(&user), None);
assert!(resolved.project_picker_disabled);
assert_eq!(resolved.new_session_worktree_mode, WorktreeHintMode::Never);
assert_eq!(resolved.fork_worktree_mode, WorktreeHintMode::Ask);
}
#[test]
fn resolve_hints_uses_effective_root_when_provided() {
let effective: TomlValue =
toml::from_str("[hints]\nproject_picker_disabled = true\n").unwrap();
let resolved = resolve_hints(Some(&effective), None, None, None);
assert!(resolved.project_picker_disabled);
}
const ENV_CONTEXTUAL_HINTS: &str = "KIGI_CONTEXTUAL_HINTS";
// `KIGI_CONTEXTUAL_HINTS` is process-global; serialize the tests reading it
// and force it unset so a developer's shell value can't make them flaky.
static CONTEXTUAL_HINTS_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn contextual_hints_guard() -> std::sync::MutexGuard<'static, ()> {
let g = CONTEXTUAL_HINTS_ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
unsafe { std::env::remove_var(ENV_CONTEXTUAL_HINTS) };
g
}
fn remote(
undo: Option<bool>,
plan_mode: Option<bool>,
image_input: Option<bool>,
send_now: Option<bool>,
word_select: Option<bool>,
) -> ContextualHintsRemote {
ContextualHintsRemote {
undo,
plan_mode,
image_input,
send_now,
small_screen: None,
word_select,
}
}
#[test]
fn contextual_hints_default_on_when_absent() {
let _g = contextual_hints_guard();
let resolved = resolve_contextual_hints(&ContextualHints::default(), None);
assert!(resolved.undo, "undo defaults ON");
assert!(resolved.plan_mode, "plan_mode defaults ON");
assert!(resolved.image_input, "image_input defaults ON");
assert!(resolved.send_now, "send_now defaults ON");
assert!(resolved.small_screen, "small_screen defaults ON");
assert!(resolved.word_select, "word_select defaults ON");
}
#[test]
fn contextual_hints_config_opts_out_per_tip() {
let _g = contextual_hints_guard();
// User disables only the undo tip; the others stay on.
let ui = ContextualHints {
undo: Some(false),
..ContextualHints::default()
};
let resolved = resolve_contextual_hints(&ui, None);
assert!(!resolved.undo);
assert!(resolved.plan_mode);
assert!(resolved.image_input);
assert!(resolved.send_now);
assert!(resolved.small_screen);
assert!(resolved.word_select);
}
#[test]
fn contextual_hints_remote_tier_controls_default_per_tip() {
let _g = contextual_hints_guard();
// Remote disables plan_mode; absent tips fall through to default ON.
let r = remote(None, Some(false), None, None, None);
let resolved = resolve_contextual_hints(&ContextualHints::default(), Some(&r));
assert!(resolved.undo, "absent remote tip → default ON");
assert!(!resolved.plan_mode, "remote `false` soft-disables");
assert!(resolved.image_input);
assert!(resolved.send_now);
assert!(resolved.word_select);
}
#[test]
fn contextual_hints_config_true_overrides_remote_disable() {
let _g = contextual_hints_guard();
// Explicit user opt-in beats a remote `false` (the disable tier).
let ui = ContextualHints {
image_input: Some(true),
..ContextualHints::default()
};
let r = remote(None, None, Some(false), None, None);
let resolved = resolve_contextual_hints(&ui, Some(&r));
assert!(
resolved.image_input,
"user `true` must override a remote `false` (disable)"
);
}
#[test]
fn contextual_hints_env_master_forces_all_on() {
let _g = contextual_hints_guard();
unsafe { std::env::set_var(ENV_CONTEXTUAL_HINTS, "1") };
// User + remote both disable every tip; the env master forces all on.
let ui = ContextualHints {
undo: Some(false),
plan_mode: Some(false),
image_input: Some(false),
send_now: Some(false),
small_screen: Some(false),
word_select: Some(false),
};
let r = remote(
Some(false),
Some(false),
Some(false),
Some(false),
Some(false),
);
let resolved = resolve_contextual_hints(&ui, Some(&r));
assert!(
resolved.undo
&& resolved.plan_mode
&& resolved.image_input
&& resolved.send_now
&& resolved.small_screen
&& resolved.word_select
);
unsafe { std::env::remove_var(ENV_CONTEXTUAL_HINTS) };
}
#[test]
fn contextual_hints_env_master_zero_forces_all_off() {
let _g = contextual_hints_guard();
unsafe { std::env::set_var(ENV_CONTEXTUAL_HINTS, "0") };
// User + remote both enable; the env master forces all off (global kill).
let ui = ContextualHints {
undo: Some(true),
plan_mode: Some(true),
image_input: Some(true),
send_now: Some(true),
small_screen: Some(true),
word_select: Some(true),
};
let r = remote(Some(true), Some(true), Some(true), Some(true), Some(true));
let resolved = resolve_contextual_hints(&ui, Some(&r));
assert!(
!resolved.undo
&& !resolved.plan_mode
&& !resolved.image_input
&& !resolved.send_now
&& !resolved.small_screen
&& !resolved.word_select
);
unsafe { std::env::remove_var(ENV_CONTEXTUAL_HINTS) };
}
}
@@ -0,0 +1,209 @@
use super::mcp::*;
use toml::Value as TomlValue;
/// Resolve a bool from an optional env var > config.toml `[section] key` > false.
///
/// Uses [`crate::agent::config::env_bool`] for consistent env var parsing
/// (`1/true/yes/on/enabled` and their negations).
fn toml_bool_sync(env_var: Option<&str>, section: &str, key: &str) -> bool {
if let Some(var) = env_var
&& let Some(val) = crate::agent::config::env_bool(var)
{
return val;
}
let root: TomlValue = match crate::config::load_effective_config() {
Ok(r) => r,
Err(_) => return false,
};
if let TomlValue::Table(table) = root
&& let Some(TomlValue::Table(s)) = table.get(section)
{
s.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
} else {
false
}
}
/// `[harness]` blocking-upload settings from ONE effective-config parse:
/// `block_for_upload` (default false — prompt handling waits for turn-end
/// uploads when set) and `upload_flush_timeout_secs` (default 60 — budget for
/// that wait).
pub fn load_blocking_upload_config_sync() -> (bool, std::time::Duration) {
const DEFAULT_FLUSH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
let root: TomlValue = match crate::config::load_effective_config() {
Ok(r) => r,
Err(_) => return (false, DEFAULT_FLUSH_TIMEOUT),
};
let harness = match &root {
TomlValue::Table(table) => table.get("harness"),
_ => None,
};
let block_for_upload = harness
.and_then(|h| h.get("block_for_upload"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let flush_timeout = harness
.and_then(|h| h.get("upload_flush_timeout_secs"))
.and_then(|v| v.as_integer())
.and_then(|v| u64::try_from(v).ok())
.map(std::time::Duration::from_secs)
.unwrap_or(DEFAULT_FLUSH_TIMEOUT);
(block_for_upload, flush_timeout)
}
pub async fn load_config() -> Config {
let root: TomlValue = match crate::config::load_effective_config() {
Ok(v) => v,
Err(_) => return Config::default(),
};
load_config_from_toml(&root)
}
/// Parse `Config` from a pre-loaded TOML value. Used by both async and sync paths.
pub fn load_config_from_toml(root: &TomlValue) -> Config {
let table = match root.as_table() {
Some(t) => t,
None => return Config::default(),
};
fn section<T: serde::de::DeserializeOwned + Default>(
table: &toml::map::Map<String, TomlValue>,
key: &str,
) -> T {
table
.get(key)
.and_then(|v| v.clone().try_into().ok())
.unwrap_or_default()
}
if let Some(TomlValue::Table(toolset)) = table.get("toolset")
&& toolset.get("use_concise").is_some()
{
tracing::warn!(
"`[toolset] use_concise` is deprecated and no longer has any effect. \
Set `use_concise = true` on individual model entries in config.toml instead."
);
}
let management_api_key = table
.get("endpoints")
.and_then(|v| v.get("management_api_key"))
.and_then(|v| v.as_str())
.map(str::to_owned);
let permission = table
.get("permission")
.and_then(|v| v.clone().try_into::<PermissionConfig>().ok());
Config {
cli: section(table, "cli"),
models: section(table, "models"),
ui: section(table, "ui"),
harness: section(table, "harness"),
skills: section(table, "skills"),
compat: section(table, "compat"),
management_api_key,
permission,
diagnostics: section(table, "diagnostics"),
session: section(table, "session"),
ask_user_question: table
.get("toolset")
.and_then(|t| t.get("ask_user_question"))
.and_then(|v| v.clone().try_into().ok())
.unwrap_or_default(),
}
}
/// Resolve permission config with project override semantics.
///
/// Priority (per approved plan):
/// 1. Nearest project `.kigi/config.toml` with `[permission]` section (from cwd upward)
/// 2. Global `~/.kigi/config.toml` `[permission]` section
///
/// Project `[permission]` overrides global wholesale (no deep merge).
///
/// Returns `(config, source_path)` from the highest-priority config file
/// that contains a `[permission]` section.
pub async fn resolve_permission_config(
cwd: &std::path::Path,
) -> Option<(PermissionConfig, std::path::PathBuf)> {
let project_configs = crate::config::find_project_configs(cwd);
for config_path in project_configs.into_iter().rev() {
if let Ok(root) = crate::config::load_config_file(&config_path)
&& let Some(perm_val) = root.get("permission")
{
match perm_val.clone().try_into::<PermissionConfig>() {
Ok(perm_config) => {
tracing::info!("Loaded [permission] from project");
return Some((perm_config, config_path));
}
Err(e) => tracing::warn!(error = % e, "Failed to parse [permission]"),
}
}
}
let global_path = user_config_path();
load_config().await.permission.map(|cfg| (cfg, global_path))
}
#[cfg(test)]
mod tests {
use super::*;
use toml::Value as TomlValue;
#[test]
fn test_models_default_parsing() {
let toml_str = r#"
[models]
default = "grok-code-fast-1"
"#;
let root: TomlValue = toml::from_str(toml_str).unwrap();
if let TomlValue::Table(table) = root
&& let Some(TomlValue::Table(models)) = table.get("models")
{
let default = models
.get("default")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
assert_eq!(default.as_deref(), Some("grok-code-fast-1"));
} else {
panic!("Expected models table");
}
}
#[test]
fn test_remote_secret_parsing() {
let toml_str = r#"
[remote]
secret = "my-secret-token"
"#;
let root: TomlValue = toml::from_str(toml_str).unwrap();
if let TomlValue::Table(table) = root
&& let Some(TomlValue::Table(remote)) = table.get("remote")
{
let secret = remote
.get("secret")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
assert_eq!(secret, Some("my-secret-token".to_string()));
} else {
panic!("Expected remote table");
}
}
#[test]
fn test_remote_secret_empty_section() {
let toml_str = r#"
[remote]
"#;
let root: TomlValue = toml::from_str(toml_str).unwrap();
if let TomlValue::Table(table) = root
&& let Some(TomlValue::Table(remote)) = table.get("remote")
{
let secret = remote
.get("secret")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
assert!(secret.is_none());
} else {
panic!("Expected remote table");
}
}
#[test]
fn test_remote_secret_no_section() {
let toml_str = r#"
[models]
default = "grok-code-fast-1"
"#;
let root: TomlValue = toml::from_str(toml_str).unwrap();
if let TomlValue::Table(table) = root {
let has_remote = table.get("remote").is_some();
assert!(!has_remote);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
// `McpOAuthConfig` / `McpOAuthConfigMap` re-exported via `mcp` (see `mcp.rs`).
mod campaigns;
mod hints;
mod load;
mod mcp;
mod permissions;
mod persist;
mod resolve;
mod settings_writes;
mod tips;
mod worktree;
pub use campaigns::{
load_effective_config, load_effective_config_disk_only, persist_models_default,
remote_campaigns_from_settings, set_remote_campaigns_from_settings, sync_campaign_fields,
};
pub use hints::*;
pub use load::*;
pub use mcp::*;
pub use permissions::*;
pub use persist::*;
// `remote` extracted to the `kigi-config-types` crate (dependency inversion);
// re-exported so `crate::util::config::{RemoteSettings, GoalRoleModel}` keep working.
pub use kigi_config_types::{
CampaignOverride, ContextualHintsRemote, DisplayRefreshSettings, DoomLoopRecoverySettings,
GoalRoleModel, RemoteSettings,
};
pub use resolve::*;
pub use settings_writes::*;
pub use tips::*;
pub use worktree::*;
@@ -0,0 +1,745 @@
use toml::Value as TomlValue;
pub use kigi_config_types::PermissionMode;
/// Parse a `permission_mode` canonical string to `PermissionMode`.
///
/// Valid values: `"always-approve"` → `AlwaysApprove`, `"auto"` → `Auto`,
/// `"ask"` / `"default"` → `Ask`.
/// Unknown strings fall back to `Ask` (safe direction — no YOLO on garbage).
/// The `"ask"` and `"default"` arms are explicit so a future `Default` variant
/// is a one-line change without touching the catch-all.
pub fn parse_permission_mode_canonical(mode_str: &str) -> PermissionMode {
match mode_str {
"always-approve" => PermissionMode::AlwaysApprove,
"auto" => PermissionMode::Auto,
"ask" => PermissionMode::Ask,
"default" => PermissionMode::Ask,
_ => PermissionMode::Ask,
}
}
/// Canonical `[ui] permission_mode` string for a resolved [`PermissionMode`].
///
/// Inverse of [`parse_permission_mode_canonical`] for the real variants, so
/// `parse_permission_mode_canonical(permission_mode_canonical_str(m)) == m`.
pub fn permission_mode_canonical_str(mode: PermissionMode) -> &'static str {
match mode {
PermissionMode::AlwaysApprove => "always-approve",
PermissionMode::Auto => "auto",
PermissionMode::Ask => "ask",
}
}
/// Keys under `[ui]` that count as an explicit permission-mode setting.
const UI_PERMISSION_MODE_KEYS: &[&str] = &["permission_mode", "approval_mode", "yolo"];
/// Parse `[ui]` permission mode when any explicit key is set.
///
/// `Some` if `permission_mode`, legacy `approval_mode`, or legacy `yolo` is
/// present (including `yolo = false` → `Some(Ask)` so remote cannot win).
/// Precedence: `permission_mode` > `approval_mode` > `yolo = true`. Unknown /
/// `"default"` → Ask. Non-table or no keys → `None`.
pub fn permission_mode_from_ui_if_set(ui: &TomlValue) -> Option<PermissionMode> {
let table = ui.as_table()?;
if !UI_PERMISSION_MODE_KEYS
.iter()
.any(|k| table.contains_key(*k))
{
return None;
}
if let Some(mode_str) = table.get("permission_mode").and_then(|v| v.as_str()) {
return Some(parse_permission_mode_canonical(mode_str));
}
if let Some(mode_str) = table.get("approval_mode").and_then(|v| v.as_str()) {
return Some(match mode_str {
"always-approve" => PermissionMode::AlwaysApprove,
_ => PermissionMode::Ask,
});
}
if table.get("yolo").and_then(|v| v.as_bool()).unwrap_or(false) {
return Some(PermissionMode::AlwaysApprove);
}
Some(PermissionMode::Ask)
}
/// Pure resolver: effective TOML `[ui]` permission keys (if any) >
/// remote `permission_mode` > `Ask`. CLI is applied above this by the launch
/// helpers. Managed/requirements TOML already deep-merge into effective config.
pub fn resolve_permission_mode(
effective_ui: Option<&TomlValue>,
remote_permission_mode: Option<&str>,
) -> PermissionMode {
if let Some(ui) = effective_ui
&& let Some(mode) = permission_mode_from_ui_if_set(ui)
{
return mode;
}
if let Some(mode_str) = remote_permission_mode {
return parse_permission_mode_canonical(mode_str);
}
PermissionMode::Ask
}
/// Display projection for a selected mode that did NOT win yolo/auto
/// enforcement: AlwaysApprove (policy pin) and Auto (feature gate off) show
/// as Ask so the UI never claims more than enforcement grants.
pub fn clamped_display_permission_mode(mode: PermissionMode) -> &'static str {
if mode.is_always_approve() || mode.is_auto() {
"ask"
} else {
permission_mode_canonical_str(mode)
}
}
/// Displayed mode for a non-CLI resolution (effective TOML > remote > Ask),
/// clamped per [`clamped_display_permission_mode`]. A persisted `"default"`
/// keeps its distinct spelling (own settings option; enforcement equals Ask):
/// only the `permission_mode` key can spell it and that key has top
/// precedence, so the raw check before canonicalization is sufficient.
pub fn resolved_display_permission_mode(
effective_ui: Option<&TomlValue>,
remote_permission_mode: Option<&str>,
) -> &'static str {
let toml_spelling = effective_ui
.and_then(|ui| ui.as_table())
.and_then(|t| t.get("permission_mode"))
.and_then(|v| v.as_str());
if toml_spelling == Some("default") {
return "default";
}
let mode = resolve_permission_mode(effective_ui, remote_permission_mode);
clamped_display_permission_mode(mode)
}
/// Load selected permission mode for launch (effective TOML + explicit remote).
///
/// TOML `[ui]` keys win over remote; remote only when no TOML permission key.
/// Missing/unknown → Ask. Config load failure → Ask.
///
/// Accepts (TOML):
/// permission_mode = "always-approve"
/// permission_mode = "auto"
/// permission_mode = "ask"
/// permission_mode = "default" (maps to Ask at runtime)
/// approval_mode = "always-approve" (legacy)
/// yolo = true (legacy)
pub fn load_permission_mode(remote_permission_mode: Option<&str>) -> PermissionMode {
let root: TomlValue = match crate::config::load_effective_config() {
Ok(r) => r,
Err(_) => return PermissionMode::Ask,
};
let ui = root.as_table().and_then(|t| t.get("ui"));
resolve_permission_mode(ui, remote_permission_mode)
}
/// Result of [`effective_yolo_for_launch`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EffectiveYolo {
/// Client-side auto-approve for this launch.
pub yolo: bool,
/// Warning to surface when a requested bypass was neutralized by the pin.
pub blocked_warning: Option<&'static str>,
/// The pin snapshot, set even when no bypass was requested, so callers reuse it.
pub policy_block: Option<&'static str>,
}
/// Effective client-side yolo for the launch: CLI `--permission-mode`/`--yolo`
/// beat `[ui] permission_mode`, and the policy pin force-disables either.
///
/// `remote_permission_mode` is the soft-default when no TOML permission key is
/// set; pass `None` when remote settings are unavailable.
pub fn effective_yolo_for_launch(
cli_always_approve: bool,
cli_permission_mode: Option<&str>,
remote_permission_mode: Option<&str>,
) -> EffectiveYolo {
let config_yolo = load_permission_mode(remote_permission_mode).is_always_approve();
resolve_launch_yolo(
resolve_effective_yolo(cli_always_approve, cli_permission_mode, config_yolo),
yolo_disabled_by_policy(),
)
}
/// Whether this launch should start in **auto** permission mode (LLM/heuristic
/// classifier — not always-approve). CLI `--permission-mode auto` beats config.
/// Mutually exclusive with effective yolo (yolo / `--yolo` wins if both requested).
///
/// `remote_permission_mode` same contract as [`effective_yolo_for_launch`].
pub fn effective_auto_for_launch(
cli_always_approve: bool,
cli_permission_mode: Option<&str>,
remote_permission_mode: Option<&str>,
) -> bool {
// Feature gate (default ON): when the auto permission-mode feature is
// disabled, Auto is inert — never launch into it regardless of CLI/config,
// so the classifier never wires. See `resolve_auto_permission_mode_enabled`.
if !crate::util::config::auto_permission_mode_enabled_from_disk() {
return false;
}
// Explicit --yolo without a competing --permission-mode → not auto.
if cli_always_approve && cli_permission_mode.is_none() {
return false;
}
let yolo = effective_yolo_for_launch(
cli_always_approve,
cli_permission_mode,
remote_permission_mode,
);
if yolo.yolo {
return false;
}
// --yolo + --permission-mode auto: prefer yolo only when mode is full bypass.
if cli_always_approve && matches!(cli_permission_mode, Some("auto")) {
return false;
}
if let Some(mode) = cli_permission_mode {
return mode == "auto";
}
load_permission_mode(remote_permission_mode).is_auto()
}
/// Whether a session should activate the **auto** permission mode: the feature
/// gate must be enabled, auto must be requested (via CLI/config/`default_auto_mode`
/// or a client's `_meta.autoMode`), and yolo (always-approve) must not be set —
/// yolo wins. Pure so the agent's activation seam (session spawn + runtime
/// `SetAutoMode`) is unit-testable without a live session. This is the
/// authoritative agent-side gate: when it returns `false`, the permission
/// manager is never flipped to auto and the classifier never wires.
pub fn auto_mode_session_active(
gate_enabled: bool,
requested_auto: bool,
session_yolo: bool,
) -> bool {
gate_enabled && requested_auto && !session_yolo
}
/// Pure precedence logic (testable).
fn resolve_effective_yolo(
cli_always_approve: bool,
cli_permission_mode: Option<&str>,
config_is_always_approve: bool,
) -> bool {
if let Some(mode) = cli_permission_mode {
// Explicit --permission-mode on the CLI always wins for this launch.
// Only the two "always approve everything" variants produce YOLO.
matches!(mode, "bypassPermissions" | "always-approve")
} else if cli_always_approve {
true
} else {
config_is_always_approve
}
}
/// Pure composition of the requested bypass and the policy pin.
fn resolve_launch_yolo(requested: bool, policy_block: Option<&'static str>) -> EffectiveYolo {
EffectiveYolo {
yolo: requested && policy_block.is_none(),
blocked_warning: if requested { policy_block } else { None },
policy_block,
}
}
/// Shared managed-policy pin predicate; canonical definition lives in
/// `kigi-workspace`.
use kigi_workspace::permission::resolution::yolo_disabled_by_policy;
/// Load `[ui] require_plan_approval` from config.toml.
///
/// When `true`, the plan viewer always opens for explicit user approval
/// when the agent calls `exit_plan_mode`, even in always-approve (YOLO)
/// mode. Defaults to `false`.
pub fn load_require_plan_approval() -> bool {
let root: TomlValue = match crate::config::load_effective_config() {
Ok(r) => r,
Err(_) => return false,
};
root.as_table()
.and_then(|t| t.get("ui"))
.and_then(|v| v.as_table())
.and_then(|ui| ui.get("require_plan_approval"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Synchronously load the remote agent secret from the config file.
/// Looks for [remote] section with secret field.
///
/// Example config.toml:
/// ```toml
/// [remote]
/// secret = "my-secret-token"
/// ```
pub fn load_remote_secret_sync() -> Option<String> {
let root: TomlValue = crate::config::load_effective_config().ok()?;
if let TomlValue::Table(table) = root
&& let Some(TomlValue::Table(remote)) = table.get("remote")
{
remote
.get("secret")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_permission_mode_none_is_ask() {
assert_eq!(resolve_permission_mode(None, None), PermissionMode::Ask);
}
#[test]
fn resolve_permission_mode_remote_only() {
assert_eq!(
resolve_permission_mode(None, Some("auto")),
PermissionMode::Auto,
);
assert_eq!(
resolve_permission_mode(None, Some("always-approve")),
PermissionMode::AlwaysApprove,
);
assert_eq!(
resolve_permission_mode(None, Some("ask")),
PermissionMode::Ask,
);
assert_eq!(
resolve_permission_mode(None, Some("default")),
PermissionMode::Ask,
);
}
#[test]
fn resolve_permission_mode_toml_wins_over_remote() {
let root: TomlValue = toml::from_str("[ui]\npermission_mode = \"ask\"\n").unwrap();
assert_eq!(
resolve_permission_mode(Some(root.get("ui").unwrap()), Some("always-approve")),
PermissionMode::Ask,
);
let yolo: TomlValue = toml::from_str("[ui]\nyolo = true\n").unwrap();
assert_eq!(
resolve_permission_mode(Some(yolo.get("ui").unwrap()), Some("ask")),
PermissionMode::AlwaysApprove,
);
let yolo_off: TomlValue = toml::from_str("[ui]\nyolo = false\n").unwrap();
assert_eq!(
resolve_permission_mode(Some(yolo_off.get("ui").unwrap()), Some("always-approve")),
PermissionMode::Ask,
);
let approval: TomlValue = toml::from_str("[ui]\napproval_mode = \"ask\"\n").unwrap();
assert_eq!(
resolve_permission_mode(Some(approval.get("ui").unwrap()), Some("auto")),
PermissionMode::Ask,
);
}
#[test]
fn permission_mode_from_ui_if_set_none_when_no_keys() {
let theme: TomlValue = toml::from_str("[ui]\ntheme = \"groknight\"\n").unwrap();
assert_eq!(
permission_mode_from_ui_if_set(theme.get("ui").unwrap()),
None,
);
assert_eq!(
permission_mode_from_ui_if_set(&TomlValue::String("nope".into())),
None,
);
let yolo_off: TomlValue = toml::from_str("[ui]\nyolo = false\n").unwrap();
assert_eq!(
permission_mode_from_ui_if_set(yolo_off.get("ui").unwrap()),
Some(PermissionMode::Ask),
);
}
#[test]
fn resolve_permission_mode_unknown_remote_is_ask() {
assert_eq!(
resolve_permission_mode(None, Some("garbage")),
PermissionMode::Ask,
);
assert_eq!(resolve_permission_mode(None, Some("")), PermissionMode::Ask);
}
#[test]
fn parse_permission_mode_canonical_covers_all_canonicals_plus_fallback() {
assert_eq!(
parse_permission_mode_canonical("always-approve"),
PermissionMode::AlwaysApprove,
);
assert_eq!(
parse_permission_mode_canonical("auto"),
PermissionMode::Auto,
);
assert_eq!(parse_permission_mode_canonical("ask"), PermissionMode::Ask,);
// "default" maps to Ask; a future `Default` variant changes only this arm.
assert_eq!(
parse_permission_mode_canonical("default"),
PermissionMode::Ask,
"PR 11: 'default' canonical projects onto Ask at the runtime layer; \
a future enum extension would change this arm",
);
// Unknown / corrupt → Ask (safer direction, no YOLO bypass).
assert_eq!(
parse_permission_mode_canonical("garbage"),
PermissionMode::Ask,
);
assert_eq!(parse_permission_mode_canonical(""), PermissionMode::Ask,);
// Case sensitivity (no normalization — wire format is exact-match).
assert_eq!(
parse_permission_mode_canonical("Always-Approve"),
PermissionMode::Ask,
"wire format is case-sensitive; 'Always-Approve' is unknown",
);
}
/// `[ui]` key precedence (permission_mode > approval_mode > yolo) and
/// canonicalization through `resolve_permission_mode` — the pure logic
/// `load_permission_mode` delegates to. Round-trips through
/// `permission_mode_canonical_str`.
#[test]
fn resolve_permission_mode_ui_precedence_and_canonicalization() {
let cases: &[(&str, PermissionMode, &str)] = &[
// Primary key, canonicalized.
(
"[ui]\npermission_mode = \"always-approve\"\n",
PermissionMode::AlwaysApprove,
"always-approve",
),
(
"[ui]\npermission_mode = \"auto\"\n",
PermissionMode::Auto,
"auto",
),
(
"[ui]\npermission_mode = \"default\"\n",
PermissionMode::Ask,
"ask",
),
(
"[ui]\npermission_mode = \"garbage\"\n",
PermissionMode::Ask,
"ask",
),
// Legacy keys.
(
"[ui]\napproval_mode = \"always-approve\"\n",
PermissionMode::AlwaysApprove,
"always-approve",
),
(
"[ui]\napproval_mode = \"ask\"\n",
PermissionMode::Ask,
"ask",
),
(
"[ui]\nyolo = true\n",
PermissionMode::AlwaysApprove,
"always-approve",
),
("[ui]\nyolo = false\n", PermissionMode::Ask, "ask"),
// Precedence: permission_mode wins over legacy keys.
(
"[ui]\npermission_mode = \"ask\"\nyolo = true\napproval_mode = \"always-approve\"\n",
PermissionMode::Ask,
"ask",
),
// approval_mode wins over yolo.
(
"[ui]\napproval_mode = \"ask\"\nyolo = true\n",
PermissionMode::Ask,
"ask",
),
// No permission keys → Ask.
("[ui]\ntheme = \"groknight\"\n", PermissionMode::Ask, "ask"),
];
for (toml_str, expected_mode, expected_canonical) in cases {
let root: TomlValue = toml::from_str(toml_str).unwrap();
let ui = root.get("ui").expect("test config defines [ui]");
let mode = resolve_permission_mode(Some(ui), None);
assert_eq!(mode, *expected_mode, "config {toml_str:?}");
assert_eq!(
permission_mode_canonical_str(mode),
*expected_canonical,
"config {toml_str:?} canonical string",
);
}
// A non-table [ui] value resolves to Ask (defensive).
assert_eq!(
resolve_permission_mode(Some(&TomlValue::String("nope".into())), None),
PermissionMode::Ask,
);
}
#[test]
fn resolve_effective_yolo_precedence_is_correct() {
use super::resolve_effective_yolo;
// Table-driven: (cli_yolo, cli_perm_mode, config_yolo, expected_yolo, description)
let cases: &[(bool, Option<&str>, bool, bool, &str)] = &[
// --- CLI --permission-mode present: it wins completely ---
(
false,
Some("plan"),
true,
false,
"plan + config yolo → false",
),
(
false,
Some("plan"),
false,
false,
"plan + config safe → false",
),
(
true,
Some("plan"),
true,
false,
"plan beats even explicit --yolo",
),
(
false,
Some("dontAsk"),
true,
false,
"dontAsk forces no auto-approve",
),
(
false,
Some("default"),
true,
false,
"default forces no auto-approve",
),
(
false,
Some("acceptEdits"),
true,
false,
"acceptEdits is not full yolo",
),
(false, Some("auto"), true, false, "auto is not full yolo"),
(
false,
Some("bypassPermissions"),
false,
true,
"bypassPermissions → yolo",
),
(
false,
Some("always-approve"),
false,
true,
"legacy always-approve string → yolo",
),
(
false,
Some("garbage"),
true,
false,
"unknown mode is safe (no yolo)",
),
(false, Some(""), true, false, "empty mode string is safe"),
(
true,
Some("bypassPermissions"),
false,
true,
"bypass + --yolo still yolo",
),
// --- No --permission-mode: fall back to legacy --yolo then config ---
(true, None, false, true, "--yolo alone → yolo"),
(true, None, true, true, "--yolo + config yolo → yolo"),
(false, None, true, true, "no cli flags + config yolo → yolo"),
(
false,
None,
false,
false,
"no cli flags + config safe → safe",
),
];
for &(cli_yolo, perm, cfg_yolo, expected, desc) in cases {
let actual = resolve_effective_yolo(cli_yolo, perm, cfg_yolo);
assert_eq!(
actual, expected,
"failed case: {desc} (cli_yolo={cli_yolo}, perm={perm:?}, cfg_yolo={cfg_yolo})"
);
}
}
#[test]
fn effective_yolo_for_launch_wrapper_calls_resolve() {
// Cover the deterministic CLI precedence paths only. Pure-config
// fallback isn't controllable here, and pin composition is proven by
// `resolve_launch_yolo_policy_pin_neutralizes_requested_bypass`. A loop
// comparing the wrapper to `yolo_disabled_by_policy()` (the same
// predicate prod calls) would be self-referential — it passes even if
// the wrapper dropped the pin — so it's intentionally omitted.
assert!(!effective_yolo_for_launch(false, Some("plan"), None).yolo);
assert!(!effective_yolo_for_launch(false, Some("dontAsk"), None).yolo);
}
/// CLI beats remote in both directions. The dangerous row (remote
/// always-approve must never override an explicit CLI ask) is
/// deterministic on any host; the positive row is skipped under a host
/// requirements pin (pin composition is proven separately by
/// `resolve_launch_yolo_policy_pin_neutralizes_requested_bypass`).
#[test]
fn effective_yolo_for_launch_cli_beats_remote() {
assert!(
!effective_yolo_for_launch(false, Some("ask"), Some("always-approve")).yolo,
"remote always-approve must not override CLI --permission-mode ask"
);
if yolo_disabled_by_policy().is_none() {
assert!(
effective_yolo_for_launch(true, None, Some("ask")).yolo,
"remote ask must not override CLI --yolo"
);
}
}
/// Display clamp: modes that lost enforcement (policy-pinned
/// AlwaysApprove, gated-off Auto) show Ask; the persisted TOML
/// `"default"` spelling survives as its own visible option.
#[test]
fn resolved_display_permission_mode_clamps_and_preserves_default() {
assert_eq!(
clamped_display_permission_mode(PermissionMode::AlwaysApprove),
"ask"
);
assert_eq!(clamped_display_permission_mode(PermissionMode::Auto), "ask");
assert_eq!(clamped_display_permission_mode(PermissionMode::Ask), "ask");
let default_ui: TomlValue =
toml::from_str("[ui]\npermission_mode = \"default\"\n").unwrap();
assert_eq!(
resolved_display_permission_mode(default_ui.get("ui"), Some("always-approve")),
"default",
"persisted 'default' must not collapse onto 'ask' for display"
);
assert_eq!(resolved_display_permission_mode(None, Some("auto")), "ask");
assert_eq!(resolved_display_permission_mode(None, None), "ask");
}
#[test]
fn effective_auto_for_launch_cli_auto_not_yolo() {
// This function is feature-gated; force the gate ON (and serialize with
// the other env-sensitive gate tests) so the auto-activation paths run.
let _g = crate::util::config::resolve::AUTO_PERMISSION_MODE_ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
unsafe { std::env::set_var("KIGI_AUTO_PERMISSION_MODE", "1") };
assert!(effective_auto_for_launch(false, Some("auto"), None));
assert!(
!effective_auto_for_launch(true, Some("auto"), None),
"--yolo beats auto"
);
assert!(!effective_auto_for_launch(
false,
Some("always-approve"),
None
));
assert!(!effective_auto_for_launch(false, Some("ask"), None));
unsafe { std::env::remove_var("KIGI_AUTO_PERMISSION_MODE") };
}
/// The authoritative agent-side gate (used at the `set_auto_mode` seam):
/// auto activates only when the feature gate is ON, auto is requested, and
/// yolo is not set. Gate OFF must never activate, even with a client
/// `_meta.autoMode=true` (the `requested_auto=true` case).
#[test]
fn auto_mode_session_active_requires_gate_request_and_no_yolo() {
assert!(
!auto_mode_session_active(false, true, false),
"gate OFF must not activate auto even when requested"
);
assert!(
auto_mode_session_active(true, true, false),
"gate ON + requested + no yolo activates auto"
);
assert!(
!auto_mode_session_active(true, true, true),
"yolo wins over auto"
);
assert!(
!auto_mode_session_active(true, false, false),
"not requested ⇒ inactive"
);
}
/// With the gate forced OFF (`KIGI_AUTO_PERMISSION_MODE=0`), explicit
/// `--permission-mode auto` / config auto is inert so the classifier never
/// launches. (Compiled-in default is ON; this pins the env kill-switch.)
#[test]
fn effective_auto_for_launch_inert_when_gate_off() {
let _g = crate::util::config::resolve::AUTO_PERMISSION_MODE_ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
unsafe { std::env::set_var("KIGI_AUTO_PERMISSION_MODE", "0") };
assert!(
!effective_auto_for_launch(false, Some("auto"), None),
"gate OFF: explicit --permission-mode auto must not activate auto"
);
assert!(
!effective_auto_for_launch(false, None, None),
"gate OFF: config-driven auto must not activate auto"
);
unsafe { std::env::remove_var("KIGI_AUTO_PERMISSION_MODE") };
}
// Pure tests for the policy predicate itself live next to its canonical
// definition in `kigi_workspace::permission::claude_compat`.
#[test]
fn resolve_launch_yolo_policy_pin_neutralizes_requested_bypass() {
let warning = kigi_workspace::permission::resolution::YOLO_PIN_REASON_REQUIREMENTS;
// Pin + requested bypass → forced off, warning to surface.
assert_eq!(
resolve_launch_yolo(true, Some(warning)),
EffectiveYolo {
yolo: false,
blocked_warning: Some(warning),
policy_block: Some(warning),
},
);
// Pin without a requested bypass → off and silent, pin still carried.
assert_eq!(
resolve_launch_yolo(false, Some(warning)),
EffectiveYolo {
yolo: false,
blocked_warning: None,
policy_block: Some(warning),
},
);
// No pin → requested value passes through unchanged.
assert_eq!(
resolve_launch_yolo(true, None),
EffectiveYolo {
yolo: true,
blocked_warning: None,
policy_block: None,
},
);
assert_eq!(
resolve_launch_yolo(false, None),
EffectiveYolo {
yolo: false,
blocked_warning: None,
policy_block: None,
},
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,494 @@
use crate::util::config::RemoteSettings;
use toml::Value as TomlValue;
/// Env override for the **auto** permission-mode feature gate.
pub(crate) const ENV_AUTO_PERMISSION_MODE: &str = "KIGI_AUTO_PERMISSION_MODE";
/// Crate-wide serialization lock for tests that mutate
/// `KIGI_AUTO_PERMISSION_MODE`. Every test reading the gate (here and in
/// `permissions.rs`, compiled into the same test binary) locks this so a
/// concurrent setter can't make them flaky.
#[cfg(test)]
pub(crate) static AUTO_PERMISSION_MODE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Extract the `[auto_mode] enabled` gate from one TOML layer (the local opt-in
/// that replaced `[features] auto_permission_mode`).
fn auto_permission_mode_from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("auto_mode")?.get("enabled")?.as_bool()
}
/// Coerce a present raw remote settings `auto_mode` JSON value into the shell's typed
/// [`AutoModeConfig`]. Coercion is all-or-nothing: any malformed field (e.g. a
/// bad `prompt_type` enum value) drops the WHOLE object to `None` (falls through
/// to the gate default). A present-but-malformed payload is warned.
fn coerce_auto_mode_json(value: serde_json::Value) -> Option<crate::agent::config::AutoModeConfig> {
match serde_json::from_value(value) {
Ok(cfg) => Some(cfg),
Err(e) => {
tracing::warn!(error = %e, "[auto_mode]: dropped malformed remote payload");
None
}
}
}
/// Coerce the raw remote settings `auto_mode` JSON on `RemoteSettings` (absent ⇒
/// `None`, silently; present-but-malformed ⇒ `None`, warned).
fn coerce_remote_auto_mode(
remote: Option<&RemoteSettings>,
) -> Option<crate::agent::config::AutoModeConfig> {
coerce_auto_mode_json(remote?.auto_mode.clone()?)
}
/// Coerce a `RemoteSettings`' raw `auto_mode` JSON down to just the gate
/// `enabled` bool, for the shell→pager `SettingsUpdateNotification` (the pager
/// only needs the kill-switch, not the full config).
pub fn remote_auto_mode_enabled(remote: Option<&RemoteSettings>) -> Option<bool> {
coerce_remote_auto_mode(remote).and_then(|c| c.enabled)
}
/// Pure precedence core for the auto-permission-mode gate, shared by the
/// `RemoteSettings`-typed resolver and the free-function disk reader so the
/// two can't drift. Precedence: requirement > env (`KIGI_AUTO_PERMISSION_MODE`)
/// > config > managed > remote feature-flag > default (`true`).
fn resolve_auto_permission_mode_layers(
requirement: Option<bool>,
config: Option<bool>,
managed: Option<bool>,
feature_flag: Option<bool>,
) -> crate::agent::config::Resolved<bool> {
use crate::agent::config::BoolFlag;
BoolFlag::env(ENV_AUTO_PERMISSION_MODE)
.requirement(requirement)
.config(config)
.managed(managed)
.feature_flag(feature_flag)
.default(true)
.resolve()
}
/// Resolve whether the **auto** permission mode feature (`PermissionMode::Auto`,
/// the LLM/heuristic classifier) is enabled. Full chain mirroring
/// [`resolve_zdr_access_enabled`](super::resolve_zdr_access_enabled):
///
/// requirements > env (`KIGI_AUTO_PERMISSION_MODE`) > `[auto_mode] enabled` in
/// `config.toml` > managed > remote settings (`auto_mode.enabled`, coerced
/// from the raw JSON) > default (`true`).
///
/// Default ON: Auto is offered unless a higher layer pins it off. Returns
/// [`Resolved`] so callers can log the winning source.
pub fn resolve_auto_permission_mode_enabled(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
remote: Option<&RemoteSettings>,
) -> crate::agent::config::Resolved<bool> {
resolve_auto_permission_mode_layers(
auto_permission_mode_from_toml(requirements),
auto_permission_mode_from_toml(user),
auto_permission_mode_from_toml(managed),
coerce_remote_auto_mode(remote).and_then(|c| c.enabled),
)
}
/// Single source of truth for the remote settings `auto_mode` config at free-function
/// call sites that don't hold a live `RemoteSettings` (gate launch decision,
/// pager kill-switch, classifier wiring). Coerced once on cache; the gate reads
/// `.enabled` off it. Lock poisoning is treated fail-safe (`.read().ok()` etc.).
static REMOTE_AUTO_MODE_CONFIG: std::sync::RwLock<Option<crate::agent::config::AutoModeConfig>> =
std::sync::RwLock::new(None);
/// Record the full remote settings `auto_mode` JSON (coerced once) for the
/// free-function resolvers. Call wherever `RemoteSettings` is applied.
pub fn cache_remote_auto_mode(value: Option<serde_json::Value>) {
let coerced = value.and_then(coerce_auto_mode_json);
if let Ok(mut guard) = REMOTE_AUTO_MODE_CONFIG.write() {
*guard = coerced;
}
}
/// Update ONLY the gate `enabled` in the cached remote config (the pager
/// kill-switch path carries just the bool). Seeds a default config first so
/// `prompt_type`/`classifier_model`/`reasoning_effort` are not clobbered.
pub fn cache_remote_auto_permission_mode_enabled(value: Option<bool>) {
if let Ok(mut guard) = REMOTE_AUTO_MODE_CONFIG.write() {
guard.get_or_insert_with(Default::default).enabled = value;
}
}
fn cached_remote_auto_permission_mode_enabled() -> Option<bool> {
REMOTE_AUTO_MODE_CONFIG
.read()
.ok()
.and_then(|g| g.as_ref().and_then(|c| c.enabled))
}
/// Deserialize the `[auto_mode]` table from one effective-config TOML layer into
/// the typed [`AutoModeConfig`]. A malformed table is dropped to `None` (warned,
/// not silently swallowed, so a bad local `[auto_mode]` is visible in logs).
fn auto_mode_config_from_toml(
v: Option<&TomlValue>,
) -> Option<crate::agent::config::AutoModeConfig> {
let table = v?.get("auto_mode")?.clone();
table
.try_into()
.map_err(|e| tracing::warn!(error = %e, "[auto_mode]: dropped malformed local table"))
.ok()
}
/// Free-function form of [`resolve_auto_permission_mode_enabled`] for call
/// sites without a `RemoteSettings` handle (the launch decision in
/// `effective_auto_for_launch`, the agent's `session_auto_mode` guard, and the
/// pager mode cycle / settings). Reads env + requirements + the effective
/// `config.toml` (user overlaid on managed) from disk plus the cached
/// remote tier. Defaults `true` so Auto is available unless pinned off.
pub fn auto_permission_mode_enabled_from_disk() -> bool {
let requirements = crate::config::load_merged_requirements();
let effective = crate::config::load_effective_config().ok();
resolve_auto_permission_mode_layers(
auto_permission_mode_from_toml(requirements.as_ref()),
auto_permission_mode_from_toml(effective.as_ref()),
None,
cached_remote_auto_permission_mode_enabled(),
)
.value
}
/// Field-wise merge of the two Auto-mode config tiers (config wins, remote fills
/// gaps, default otherwise). Pure so the precedence is unit-testable.
fn merge_auto_mode_config(
config: crate::agent::config::AutoModeConfig,
remote: crate::agent::config::AutoModeConfig,
) -> crate::agent::config::AutoModeConfig {
crate::agent::config::AutoModeConfig {
enabled: config.enabled.or(remote.enabled),
prompt_type: config.prompt_type.or(remote.prompt_type),
classifier_model: config.classifier_model.or(remote.classifier_model),
reasoning_effort: config.reasoning_effort.or(remote.reasoning_effort),
}
}
/// Resolve the full Auto-mode config for the rare classifier-wiring read. Loads
/// the effective `config.toml` ONCE and reads the remote cache ONCE, then merges
/// field-wise: `[auto_mode]` config > cached remote settings `auto_mode` > `None`
/// (unset fields stay `None`; the wire fn applies the built-in defaults). No env
/// layer (mirrors goal's model resolvers); the gate's own env layer is handled by
/// the disk gate reader.
pub fn resolve_auto_mode_config_from_disk() -> crate::agent::config::AutoModeConfig {
let effective = crate::config::load_effective_config().ok();
let config = auto_mode_config_from_toml(effective.as_ref()).unwrap_or_default();
let remote = REMOTE_AUTO_MODE_CONFIG
.read()
.ok()
.and_then(|g| g.clone())
.unwrap_or_default();
merge_auto_mode_config(config, remote)
}
/// Apply the built-in Auto-mode classifier defaults to a resolved config (these
/// take effect once auto mode is enabled): an unset `prompt_type` defaults to
/// `full` (v9-traffic eval: transcript context cuts the residual block rate
/// ~1/3 and lets explicit user authorization satisfy the prompt's
/// confirmation clause); an unset `reasoning_effort` defaults to `low` ONLY
/// when the effective model supports reasoning effort (else stays `None` —
/// provider default). Explicit config/remote values always win. Returns the
/// `(prompt_type, reasoning_effort)` the classifier wiring should use.
pub fn auto_mode_classifier_defaults(
cfg: &crate::agent::config::AutoModeConfig,
effective_supports_reasoning_effort: bool,
) -> (
kigi_workspace::permission::ClassifierPromptType,
Option<kigi_sampling_types::ReasoningEffort>,
) {
let prompt_type = cfg
.prompt_type
.unwrap_or(kigi_workspace::permission::ClassifierPromptType::Full);
let reasoning_effort = cfg.reasoning_effort.or_else(|| {
effective_supports_reasoning_effort.then_some(kigi_sampling_types::ReasoningEffort::Low)
});
(prompt_type, reasoning_effort)
}
#[cfg(test)]
mod auto_permission_mode_gate_tests {
use super::*;
use crate::agent::config::ConfigSource;
// `KIGI_AUTO_PERMISSION_MODE` is process-global; serialize every test that
// reads it (all of them, via `BoolFlag::env`) and force it unset at the top
// of each so a developer's shell value can't make these flaky.
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = super::AUTO_PERMISSION_MODE_ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
unsafe { std::env::remove_var(ENV_AUTO_PERMISSION_MODE) };
g
}
fn toml_features_auto(v: bool) -> TomlValue {
toml::from_str(&format!("[auto_mode]\nenabled = {v}\n")).unwrap()
}
fn remote(v: Option<bool>) -> RemoteSettings {
RemoteSettings {
auto_mode: v.map(|enabled| serde_json::json!({ "enabled": enabled })),
..RemoteSettings::default()
}
}
#[test]
fn defaults_on_when_nothing_set() {
let _g = guard();
let r = resolve_auto_permission_mode_enabled(None, None, None, None);
assert!(r.value, "gate must default ON");
assert_eq!(r.source, ConfigSource::Default);
}
#[test]
fn each_layer_can_turn_it_on() {
let _g = guard();
let on = toml_features_auto(true);
// requirement
let r = resolve_auto_permission_mode_enabled(Some(&on), None, None, None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Requirement);
// config (user)
let r = resolve_auto_permission_mode_enabled(None, Some(&on), None, None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Config);
// managed
let r = resolve_auto_permission_mode_enabled(None, None, Some(&on), None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
// remote settings (RemoteSettings field)
let r = resolve_auto_permission_mode_enabled(None, None, None, Some(&remote(Some(true))));
assert!(r.value);
assert_eq!(r.source, ConfigSource::Remote);
}
#[test]
fn remote_kill_switch_reads_struct_field() {
let _g = guard();
// Server explicitly disables → false from the Remote layer.
let r = resolve_auto_permission_mode_enabled(None, None, None, Some(&remote(Some(false))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Remote);
// Absent remote field → falls through to Default ON.
let r = resolve_auto_permission_mode_enabled(None, None, None, Some(&remote(None)));
assert!(r.value);
assert_eq!(r.source, ConfigSource::Default);
}
#[test]
fn remote_gate_coerces_json_object_else_default() {
let _g = guard();
// A well-formed lean `auto_mode` object yields the gate from `enabled`.
let remote = RemoteSettings {
auto_mode: Some(serde_json::json!({
"enabled": true,
"classifier_model": "some-slug",
"prompt_type": "just_command"
})),
..RemoteSettings::default()
};
let r = resolve_auto_permission_mode_enabled(None, None, None, Some(&remote));
assert!(r.value);
assert_eq!(r.source, ConfigSource::Remote);
// A non-object / malformed payload coerces to None → falls through to Default ON.
let bad = RemoteSettings {
auto_mode: Some(serde_json::json!("not-an-object")),
..RemoteSettings::default()
};
let r = resolve_auto_permission_mode_enabled(None, None, None, Some(&bad));
assert!(r.value);
assert_eq!(r.source, ConfigSource::Default);
}
#[test]
fn remote_gate_malformed_field_falls_through_to_default() {
let _g = guard();
// A malformed field (bad `prompt_type` enum) drops the WHOLE object →
// falls through to Default ON.
let bad = RemoteSettings {
auto_mode: Some(serde_json::json!({ "enabled": true, "prompt_type": "typo" })),
..RemoteSettings::default()
};
let r = resolve_auto_permission_mode_enabled(None, None, None, Some(&bad));
assert!(r.value);
assert_eq!(r.source, ConfigSource::Default);
// A well-formed object still enables the gate from Remote.
let ok = RemoteSettings {
auto_mode: Some(serde_json::json!({ "enabled": true })),
..RemoteSettings::default()
};
let r = resolve_auto_permission_mode_enabled(None, None, None, Some(&ok));
assert!(r.value);
assert_eq!(r.source, ConfigSource::Remote);
}
#[test]
fn precedence_config_beats_managed_beats_remote() {
let _g = guard();
let off = toml_features_auto(false);
let on = toml_features_auto(true);
// config(false) wins over managed(true) and remote(true).
let r = resolve_auto_permission_mode_enabled(
None,
Some(&off),
Some(&on),
Some(&remote(Some(true))),
);
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Config);
// managed(false) wins over remote(true) when no config.
let r =
resolve_auto_permission_mode_enabled(None, None, Some(&off), Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
}
#[test]
fn env_overrides_config_and_remote() {
let _g = guard();
unsafe { std::env::set_var(ENV_AUTO_PERMISSION_MODE, "1") };
let off = toml_features_auto(false);
let r = resolve_auto_permission_mode_enabled(
None,
Some(&off),
None,
Some(&remote(Some(false))),
);
assert!(r.value, "env must override config + remote");
assert_eq!(r.source, ConfigSource::Env);
unsafe { std::env::remove_var(ENV_AUTO_PERMISSION_MODE) };
}
#[test]
fn requirement_beats_env() {
let _g = guard();
unsafe { std::env::set_var(ENV_AUTO_PERMISSION_MODE, "1") };
let off = toml_features_auto(false);
let r = resolve_auto_permission_mode_enabled(Some(&off), None, None, None);
assert!(!r.value, "requirement (managed/MDM floor) must beat env");
assert_eq!(r.source, ConfigSource::Requirement);
unsafe { std::env::remove_var(ENV_AUTO_PERMISSION_MODE) };
}
#[test]
fn remote_cache_round_trips_and_disk_reader_honors_env() {
let _g = guard();
// Gate `enabled` round-trips through the single RwLock store.
cache_remote_auto_permission_mode_enabled(Some(true));
assert_eq!(cached_remote_auto_permission_mode_enabled(), Some(true));
cache_remote_auto_permission_mode_enabled(Some(false));
assert_eq!(cached_remote_auto_permission_mode_enabled(), Some(false));
cache_remote_auto_permission_mode_enabled(None);
assert_eq!(cached_remote_auto_permission_mode_enabled(), None);
// The disk reader wires the env layer (highest deterministic source).
unsafe { std::env::set_var(ENV_AUTO_PERMISSION_MODE, "1") };
assert!(
auto_permission_mode_enabled_from_disk(),
"from_disk must honor the env layer"
);
unsafe { std::env::remove_var(ENV_AUTO_PERMISSION_MODE) };
cache_remote_auto_mode(None);
}
#[test]
fn merge_auto_mode_config_precedence() {
use crate::agent::config::AutoModeConfig;
use kigi_sampling_types::ReasoningEffort;
use kigi_workspace::permission::ClassifierPromptType;
// config wins where set; remote fills the gaps.
let config = AutoModeConfig {
enabled: Some(true),
prompt_type: Some(ClassifierPromptType::JustCommand),
classifier_model: None,
reasoning_effort: None,
};
let remote = AutoModeConfig {
enabled: Some(false),
prompt_type: Some(ClassifierPromptType::Full),
classifier_model: Some("remote-model".into()),
reasoning_effort: Some(ReasoningEffort::Low),
};
let merged = merge_auto_mode_config(config, remote);
assert_eq!(merged.enabled, Some(true));
assert_eq!(merged.prompt_type, Some(ClassifierPromptType::JustCommand));
assert_eq!(merged.classifier_model.as_deref(), Some("remote-model"));
assert_eq!(merged.reasoning_effort, Some(ReasoningEffort::Low));
// Both unset ⇒ all-None (the wire fn then applies the built-in defaults).
let empty = merge_auto_mode_config(AutoModeConfig::default(), AutoModeConfig::default());
assert!(empty.enabled.is_none() && empty.classifier_model.is_none());
}
#[test]
fn auto_mode_classifier_defaults_apply_when_unset() {
use crate::agent::config::AutoModeConfig;
use kigi_sampling_types::ReasoningEffort;
use kigi_workspace::permission::ClassifierPromptType;
// Unset + RE-supporting effective model ⇒ full (transcript) + low.
let (pt, eff) = auto_mode_classifier_defaults(&AutoModeConfig::default(), true);
assert_eq!(pt, ClassifierPromptType::Full);
assert_eq!(eff, Some(ReasoningEffort::Low));
// Unset + non-RE model ⇒ full + None (no effort override).
let (pt, eff) = auto_mode_classifier_defaults(&AutoModeConfig::default(), false);
assert_eq!(pt, ClassifierPromptType::Full);
assert_eq!(eff, None);
// Explicit values win over the defaults, even on a RE-supporting model.
let cfg = AutoModeConfig {
prompt_type: Some(ClassifierPromptType::JustCommand),
reasoning_effort: Some(ReasoningEffort::High),
..AutoModeConfig::default()
};
let (pt, eff) = auto_mode_classifier_defaults(&cfg, true);
assert_eq!(pt, ClassifierPromptType::JustCommand);
assert_eq!(eff, Some(ReasoningEffort::High));
}
#[test]
fn auto_mode_config_from_toml_round_trips_and_warns_on_malformed() {
use kigi_workspace::permission::ClassifierPromptType;
// A real [auto_mode] table round-trips (not silently dropped).
let toml: TomlValue = toml::from_str(
"[auto_mode]\nenabled = true\nprompt_type = \"just_command\"\nclassifier_model = \"m\"\n",
)
.unwrap();
let cfg = auto_mode_config_from_toml(Some(&toml)).expect("table parses");
assert_eq!(cfg.enabled, Some(true));
assert_eq!(cfg.prompt_type, Some(ClassifierPromptType::JustCommand));
assert_eq!(cfg.classifier_model.as_deref(), Some("m"));
// Absent [auto_mode] ⇒ None.
let bare: TomlValue = toml::from_str("[features]\ngoal = true\n").unwrap();
assert!(auto_mode_config_from_toml(Some(&bare)).is_none());
// Malformed enum ⇒ dropped to None (warned), never a panic.
let bad: TomlValue = toml::from_str("[auto_mode]\nprompt_type = \"bogus\"\n").unwrap();
assert!(auto_mode_config_from_toml(Some(&bad)).is_none());
}
#[test]
fn remote_cache_single_store_killswitch_preserves_fields() {
use kigi_workspace::permission::ClassifierPromptType;
let _g = guard();
// Seed the full remote config, then flip ONLY the gate via the pager
// kill-switch path — prompt_type / classifier_model must survive.
cache_remote_auto_mode(Some(serde_json::json!({
"enabled": true,
"prompt_type": "bare_instructions",
"classifier_model": "remote-model"
})));
assert_eq!(cached_remote_auto_permission_mode_enabled(), Some(true));
cache_remote_auto_permission_mode_enabled(Some(false));
assert_eq!(cached_remote_auto_permission_mode_enabled(), Some(false));
let stored = REMOTE_AUTO_MODE_CONFIG
.read()
.unwrap()
.clone()
.expect("config still cached");
assert_eq!(
stored.prompt_type,
Some(ClassifierPromptType::BareInstructions)
);
assert_eq!(stored.classifier_model.as_deref(), Some("remote-model"));
cache_remote_auto_mode(None);
}
}
@@ -0,0 +1,142 @@
/// Default auto-compact threshold (% of context window) when no source sets it.
pub const DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT: u8 = 85;
/// Env-var override for `auto_compact_threshold_percent`. Parsed as `u8`;
/// out-of-range or unparseable values are ignored.
pub(crate) const ENV_AUTO_COMPACT_THRESHOLD_PERCENT: &str = "KIGI_AUTO_COMPACT_THRESHOLD_PERCENT";
/// Resolve auto-compact threshold percent (0-100) for the given model.
///
/// Two scopes (per-model and global) across two tiers (user TOML and
/// remote settings). User-tier always wins over remote; within a tier, per-model
/// wins over global. Env var sits on top as a per-process override.
///
/// Precedence (highest first):
/// 1. env `KIGI_AUTO_COMPACT_THRESHOLD_PERCENT`
/// 2. user TOML `[model.<id>].auto_compact_threshold_percent`
/// (read from `cfg.config_models`; the effective merge of user +
/// managed `[model.<id>]` sections)
/// 3. user TOML `[session].auto_compact_threshold_percent`
/// (read from `cfg.session.auto_compact_threshold_percent: Option<u8>`)
/// 4. remote settings per-model `ModelInfo.auto_compact_threshold_percent`
/// (populated from `grok_build_models[i].auto_compact_threshold_percent`;
/// intentionally NOT collapsed via `ConfigModelOverride::apply` so the
/// user-vs-GB per-model distinction is preserved)
/// 5. remote settings global `RemoteSettings.auto_compact_threshold_percent`
/// (populated from `grok_build_settings.auto_compact_threshold_percent`)
/// 6. default `DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT` (85)
///
/// Values outside `0..=100` from the env var are ignored with a debug log and
/// the resolver falls through to the next tier. TOML/remote fields are typed
/// `u8` and so naturally constrained.
pub fn resolve_auto_compact_threshold_percent(
cfg: &crate::agent::config::Config,
model_id: &str,
model: Option<&crate::agent::config::ModelInfo>,
) -> u8 {
resolve_auto_compact_threshold_percent_from_tiers(
cfg.config_models
.get(model_id)
.and_then(|m| m.auto_compact_threshold_percent),
cfg.session.auto_compact_threshold_percent,
model.and_then(|m| m.auto_compact_threshold_percent),
cfg.remote_settings
.as_ref()
.and_then(|r| r.auto_compact_threshold_percent),
)
}
/// Lower-level form of [`resolve_auto_compact_threshold_percent`] that takes
/// the four tiers as plain `Option<u8>` values rather than reaching into a
/// `Config`. Useful from sites that don't hold a `Config` reference (e.g.,
/// subagent spawn paths where the parent's config tiers are plumbed in
/// explicitly and the per-model lookup uses the SUBAGENT's resolved model id,
/// not the parent's).
///
/// Precedence: env > `user_per_model` > `user_global` > `gb_per_model`
/// > `gb_global` > `DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT`.
pub fn resolve_auto_compact_threshold_percent_from_tiers(
user_per_model: Option<u8>,
user_global: Option<u8>,
gb_per_model: Option<u8>,
gb_global: Option<u8>,
) -> u8 {
fn clamp_env(raw: i64) -> Option<u8> {
if (0..=100).contains(&raw) {
Some(raw as u8)
} else {
tracing::debug!(
source = "env",
value = raw,
"auto_compact_threshold_percent out of range 0..=100; ignoring"
);
None
}
}
let from_env = || -> Option<u8> {
std::env::var(ENV_AUTO_COMPACT_THRESHOLD_PERCENT)
.ok()
.and_then(|s| s.parse::<i64>().ok())
.and_then(clamp_env)
};
from_env()
.or(user_per_model)
.or(user_global)
.or(gb_per_model)
.or(gb_global)
.unwrap_or(DEFAULT_AUTO_COMPACT_THRESHOLD_PERCENT)
}
/// Client default per-compaction wall-clock budget (seconds). Fleet p99 of
/// successful compactions is ~181s (≈225s at 400K+ input), so 300s clears the
/// legit tail with margin while cutting a runaway from the ~600s deadline.
pub const DEFAULT_COMPACTION_WALL_CLOCK_BUDGET_SECS: u64 = 300;
/// Below this, a configured budget is almost certainly a misconfig (fleet
/// success p99 ~181s); logged at `warn`, not clamped.
const COMPACTION_WALL_CLOCK_BUDGET_WARN_SECS: u64 = 120;
/// Env override for the compaction wall-clock budget (seconds). Parsed as
/// `u64`; unparseable values fall through.
const ENV_COMPACTION_WALL_CLOCK_BUDGET_SECS: &str = "KIGI_COMPACTION_WALL_CLOCK_SECS";
/// Resolve the per-compaction wall-clock budget (seconds). Precedence: env
/// `KIGI_COMPACTION_WALL_CLOCK_SECS` > remote settings global
/// `RemoteSettings.compaction_wall_clock_budget_secs` >
/// [`DEFAULT_COMPACTION_WALL_CLOCK_BUDGET_SECS`] (a per-model `ModelInfo` tier
/// would slot in ahead of the global one).
///
/// `0` **disables** it. Low values are warned, not clamped — any "safe" clamp
/// (e.g. 30s) would itself cut legit compactions, trading one silent failure for
/// another; ops own the value.
pub fn resolve_compaction_wall_clock_budget_secs(gb_global: Option<u64>) -> u64 {
let from_env = std::env::var(ENV_COMPACTION_WALL_CLOCK_BUDGET_SECS)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok());
let resolved = from_env
.or(gb_global)
.unwrap_or(DEFAULT_COMPACTION_WALL_CLOCK_BUDGET_SECS);
if resolved > 0 && resolved < COMPACTION_WALL_CLOCK_BUDGET_WARN_SECS {
tracing::warn!(
budget_secs = resolved,
"compaction wall-clock budget {resolved}s is below {COMPACTION_WALL_CLOCK_BUDGET_WARN_SECS}s \
and may cut legitimate compactions (fleet success p99 ~181s); set 0 to disable"
);
}
resolved
}
#[cfg(test)]
mod compaction_wall_clock_budget_tests {
use super::resolve_compaction_wall_clock_budget_secs as resolve;
// Assumes KIGI_COMPACTION_WALL_CLOCK_SECS is unset in the test env.
#[test]
fn default_global_disable_and_no_clamp() {
assert_eq!(resolve(None), 300); // client default
assert_eq!(resolve(Some(450)), 450); // server global wins
assert_eq!(resolve(Some(0)), 0); // 0 explicitly disables (no clamp)
assert_eq!(resolve(Some(5)), 5); // low values pass through (warned, not clamped)
}
}
@@ -0,0 +1,230 @@
use crate::util::config::RemoteSettings;
use toml::Value as TomlValue;
/// Env override for the full crash-handler install gate.
pub(crate) const ENV_CRASH_HANDLER: &str = "KIGI_CRASH_HANDLER";
/// Extract `[diagnostics] crash_handler` from one TOML layer.
fn crash_handler_from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("diagnostics")?.get("crash_handler")?.as_bool()
}
/// Precedence core shared by the typed resolver and the disk reader so they
/// can't drift: requirement > env > config > managed > remote > default `false`.
fn resolve_crash_handler_enabled_layers(
requirement: Option<bool>,
config: Option<bool>,
managed: Option<bool>,
feature_flag: Option<bool>,
) -> crate::agent::config::Resolved<bool> {
use crate::agent::config::BoolFlag;
BoolFlag::env(ENV_CRASH_HANDLER)
.requirement(requirement)
.config(config)
.managed(managed)
.feature_flag(feature_flag)
.resolve()
}
/// Resolve whether the full crash handler should be installed.
/// Precedence: requirements > env (`KIGI_CRASH_HANDLER`) >
/// user `[diagnostics] crash_handler` > managed > remote settings
/// `crash_handler_enabled` > default `false`.
pub fn resolve_crash_handler_enabled(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
remote: Option<&RemoteSettings>,
) -> crate::agent::config::Resolved<bool> {
resolve_crash_handler_enabled_layers(
crash_handler_from_toml(requirements),
crash_handler_from_toml(user),
crash_handler_from_toml(managed),
remote.and_then(|r| r.crash_handler_enabled),
)
}
/// Process-global cache of the remote tier, read by
/// [`load_crash_handler_enabled_sync`] at pre-Tokio install (no live
/// `RemoteSettings` there). Fail-safe to `None` on lock poisoning.
static REMOTE_CRASH_HANDLER_ENABLED: std::sync::RwLock<Option<bool>> = std::sync::RwLock::new(None);
/// Record the remote settings value; called when the agent applies `RemoteSettings`.
pub fn cache_remote_crash_handler_enabled(value: Option<bool>) {
if let Ok(mut guard) = REMOTE_CRASH_HANDLER_ENABLED.write() {
*guard = value;
}
}
fn cached_remote_crash_handler_enabled() -> Option<bool> {
REMOTE_CRASH_HANDLER_ENABLED.read().ok().and_then(|g| *g)
}
/// Merge system-managed policy (`/etc/kigi`) under home `managed_config.toml`
/// so MDM/system layers still reach the managed BoolFlag tier.
fn load_managed_toml_layers() -> Option<TomlValue> {
let system = crate::config::load_system_managed_config().ok();
let managed = crate::config::load_managed_config().ok();
match (system, managed) {
(None, None) => None,
(Some(s), None) => Some(s),
(None, Some(m)) => Some(m),
(Some(mut s), Some(m)) => {
kigi_config::deep_merge_toml(&mut s, &m);
Some(s)
}
}
}
/// Free-function form of [`resolve_crash_handler_enabled`] for the pager-bin
/// install path (no live `RemoteSettings`): env + requirements + user +
/// system/home managed from disk plus the cached remote tier. Defaults
/// `false`.
pub fn load_crash_handler_enabled_sync() -> bool {
let requirements = crate::config::load_merged_requirements();
let user = crate::config::load_from_disk().ok();
let managed = load_managed_toml_layers();
resolve_crash_handler_enabled_layers(
crash_handler_from_toml(requirements.as_ref()),
crash_handler_from_toml(user.as_ref()),
crash_handler_from_toml(managed.as_ref()),
cached_remote_crash_handler_enabled(),
)
.value
}
#[cfg(test)]
mod crash_handler_gate_tests {
use super::*;
use crate::agent::config::ConfigSource;
// `KIGI_CRASH_HANDLER` is process-global; serialize and force it unset at
// the top of each test so a developer's shell value can't make these flaky.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe { std::env::remove_var(ENV_CRASH_HANDLER) };
g
}
fn toml_diag(v: bool) -> TomlValue {
toml::from_str(&format!("[diagnostics]\ncrash_handler = {v}\n")).unwrap()
}
fn remote(v: Option<bool>) -> RemoteSettings {
RemoteSettings {
crash_handler_enabled: v,
..RemoteSettings::default()
}
}
#[test]
fn defaults_off_when_nothing_set() {
let _g = guard();
let r = resolve_crash_handler_enabled(None, None, None, None);
assert!(!r.value, "gate must default OFF");
assert_eq!(r.source, ConfigSource::Default);
}
#[test]
fn each_layer_can_turn_it_on() {
let _g = guard();
let on = toml_diag(true);
let r = resolve_crash_handler_enabled(Some(&on), None, None, None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Requirement);
let r = resolve_crash_handler_enabled(None, Some(&on), None, None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Config);
let r = resolve_crash_handler_enabled(None, None, Some(&on), None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
let r = resolve_crash_handler_enabled(None, None, None, Some(&remote(Some(true))));
assert!(r.value);
assert_eq!(r.source, ConfigSource::Remote);
}
#[test]
fn each_layer_can_force_disable() {
let _g = guard();
let off = toml_diag(false);
let r = resolve_crash_handler_enabled(Some(&off), None, None, Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Requirement);
let r = resolve_crash_handler_enabled(None, Some(&off), None, Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Config);
let r = resolve_crash_handler_enabled(None, None, Some(&off), Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
}
#[test]
fn remote_kill_switch_reads_struct_field() {
let _g = guard();
let r = resolve_crash_handler_enabled(None, None, None, Some(&remote(Some(false))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Remote);
let r = resolve_crash_handler_enabled(None, None, None, Some(&remote(None)));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Default);
}
#[test]
fn precedence_config_beats_managed_beats_remote() {
let _g = guard();
let off = toml_diag(false);
let on = toml_diag(true);
let r =
resolve_crash_handler_enabled(None, Some(&off), Some(&on), Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Config);
let r = resolve_crash_handler_enabled(None, None, Some(&off), Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
}
#[test]
fn env_overrides_config_and_remote() {
let _g = guard();
unsafe { std::env::set_var(ENV_CRASH_HANDLER, "1") };
let off = toml_diag(false);
let r = resolve_crash_handler_enabled(None, Some(&off), None, Some(&remote(Some(false))));
assert!(r.value, "env must override config + remote");
assert_eq!(r.source, ConfigSource::Env);
unsafe { std::env::remove_var(ENV_CRASH_HANDLER) };
}
#[test]
fn env_can_force_disable_over_config() {
let _g = guard();
unsafe { std::env::set_var(ENV_CRASH_HANDLER, "0") };
let on = toml_diag(true);
let r = resolve_crash_handler_enabled(None, Some(&on), None, Some(&remote(Some(true))));
assert!(!r.value, "env=0 must override config + remote");
assert_eq!(r.source, ConfigSource::Env);
unsafe { std::env::remove_var(ENV_CRASH_HANDLER) };
}
#[test]
fn requirement_beats_env() {
let _g = guard();
unsafe { std::env::set_var(ENV_CRASH_HANDLER, "1") };
let off = toml_diag(false);
let r = resolve_crash_handler_enabled(Some(&off), None, None, None);
assert!(!r.value, "requirement must beat env");
assert_eq!(r.source, ConfigSource::Requirement);
unsafe { std::env::remove_var(ENV_CRASH_HANDLER) };
}
#[test]
fn remote_cache_round_trips() {
let _g = guard();
cache_remote_crash_handler_enabled(Some(true));
assert_eq!(cached_remote_crash_handler_enabled(), Some(true));
cache_remote_crash_handler_enabled(Some(false));
assert_eq!(cached_remote_crash_handler_enabled(), Some(false));
cache_remote_crash_handler_enabled(None);
assert_eq!(cached_remote_crash_handler_enabled(), None);
}
}
@@ -0,0 +1,654 @@
//! Display-refresh probe + auto-cadence policy resolve and pure cadence derivation.
use crate::util::config::RemoteSettings;
use kigi_config_types::DisplayRefreshSettings;
use serde::Deserialize;
use toml::Value as TomlValue;
pub const ENV_DISPLAY_REFRESH_PROBE_ENABLED: &str = "KIGI_DISPLAY_REFRESH_PROBE_ENABLED";
pub const ENV_DISPLAY_REFRESH_AUTO_CADENCE: &str = "KIGI_DISPLAY_REFRESH_AUTO_CADENCE";
/// Default motion paint cadence (~60Hz) when env and auto-cadence do not apply.
pub const DISPLAY_REFRESH_DEFAULT_CADENCE_MS: u64 = 16;
/// Client defaults for [`DisplayRefreshPolicy`].
pub const DISPLAY_REFRESH_DEFAULT_PROBE_ENABLED: bool = true;
pub const DISPLAY_REFRESH_DEFAULT_AUTO_CADENCE_ENABLED: bool = false;
pub const DISPLAY_REFRESH_DEFAULT_FLOOR_MS: u32 = 8;
pub const DISPLAY_REFRESH_DEFAULT_CEILING_MS: u32 = 16;
pub const DISPLAY_REFRESH_DEFAULT_MIN_HZ: u32 = 55;
pub const DISPLAY_REFRESH_DEFAULT_MAX_HZ: u32 = 165;
/// Same band as env cadence knobs (`KIGI_MIN_DRAW_MS` / `KIGI_SCROLL_CADENCE_MS`).
const CADENCE_MS_MIN: u32 = 1;
const CADENCE_MS_MAX: u32 = 100;
#[cfg(test)]
static DISPLAY_REFRESH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Effective display-refresh policy after layered resolve (compiled defaults applied).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DisplayRefreshPolicy {
pub probe_enabled: bool,
pub auto_cadence_enabled: bool,
pub floor_ms: u32,
pub ceiling_ms: u32,
pub min_hz: u32,
pub max_hz: u32,
}
impl Default for DisplayRefreshPolicy {
fn default() -> Self {
Self {
probe_enabled: DISPLAY_REFRESH_DEFAULT_PROBE_ENABLED,
auto_cadence_enabled: DISPLAY_REFRESH_DEFAULT_AUTO_CADENCE_ENABLED,
floor_ms: DISPLAY_REFRESH_DEFAULT_FLOOR_MS,
ceiling_ms: DISPLAY_REFRESH_DEFAULT_CEILING_MS,
min_hz: DISPLAY_REFRESH_DEFAULT_MIN_HZ,
max_hz: DISPLAY_REFRESH_DEFAULT_MAX_HZ,
}
}
}
/// Pure auto-cadence decision from policy + probe Hz (ignores env cadence knobs).
///
/// `ms = clamp(round(1000/hz), floor_ms, ceiling_ms)` when
/// `auto_cadence_enabled` and `hz` is in `[min_hz, max_hz]`; otherwise no auto.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AutoCadenceDecision {
/// Derived cadence when auto applies; `None` when gated off / fail-closed.
pub ms: Option<u64>,
/// Stable reason token for telemetry: `flag_off` | `disabled` |
/// `probe_skip` | `hz_out_of_range` | `applied`.
pub reason: &'static str,
}
/// Effective min-draw + scroll cadence after auto-cadence + env merge.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MotionCadence {
pub min_draw_ms: u64,
pub scroll_ms: u64,
/// Derived auto ms used on at least one clock.
pub auto_applied: bool,
/// Auto reason, or `env_override` when both env knobs are set.
pub reason: &'static str,
}
/// One TOML layer: nested `[ui.display_refresh]` via canonical tolerant type.
#[derive(Debug, Clone, Default, PartialEq)]
struct DisplayRefreshLayer {
settings: DisplayRefreshSettings,
}
impl DisplayRefreshLayer {
fn from_toml(root: Option<&TomlValue>) -> Self {
let Some(ui) = root.and_then(|v| v.get("ui")) else {
return Self::default();
};
let settings = ui
.get("display_refresh")
.cloned()
.and_then(|v| DisplayRefreshSettings::deserialize(v).ok())
.unwrap_or_default();
Self { settings }
}
}
/// Priority: 0 requirements (highest) … 4 default (lowest).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Picked {
value: u32,
prio: u8,
}
fn pick_u32(
requirements: Option<u32>,
user: Option<u32>,
managed: Option<u32>,
remote: Option<u32>,
default: u32,
) -> Picked {
if let Some(v) = requirements {
return Picked { value: v, prio: 0 };
}
if let Some(v) = user {
return Picked { value: v, prio: 1 };
}
if let Some(v) = managed {
return Picked { value: v, prio: 2 };
}
if let Some(v) = remote {
return Picked { value: v, prio: 3 };
}
Picked {
value: default,
prio: 4,
}
}
fn clamp_cadence_ms(v: u32) -> u32 {
v.clamp(CADENCE_MS_MIN, CADENCE_MS_MAX)
}
/// When bounds invert, keep the higher-priority bound and collapse the lower
/// tier onto it (same-priority invert → compiled defaults).
fn order_bounds(lo: Picked, hi: Picked, def_lo: u32, def_hi: u32) -> (u32, u32) {
if lo.value <= hi.value {
(lo.value, hi.value)
} else if lo.prio < hi.prio {
(lo.value, lo.value)
} else if hi.prio < lo.prio {
(hi.value, hi.value)
} else {
(def_lo, def_hi)
}
}
/// Resolve display-refresh probe + auto-cadence policy.
///
/// Precedence per field: requirements > env (bools only) > user TOML >
/// managed > remote `display_refresh` object > compiled defaults.
///
/// TOML/remote use tolerant [`DisplayRefreshSettings`]. Floor/ceiling clamp
/// `1..=100`; inverted bounds keep the higher-priority side.
pub fn resolve_display_refresh(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
remote: Option<&RemoteSettings>,
) -> DisplayRefreshPolicy {
use crate::agent::config::BoolFlag;
let req = DisplayRefreshLayer::from_toml(requirements);
let usr = DisplayRefreshLayer::from_toml(user);
let mng = DisplayRefreshLayer::from_toml(managed);
let remote_obj = remote.and_then(|r| r.display_refresh.as_ref());
let remote_probe = remote_obj.and_then(|d| d.probe_enabled);
let remote_auto = remote_obj.and_then(|d| d.auto_cadence_enabled);
let probe_enabled = BoolFlag::env(ENV_DISPLAY_REFRESH_PROBE_ENABLED)
.requirement(req.settings.probe_enabled)
.config(usr.settings.probe_enabled)
.managed(mng.settings.probe_enabled)
.feature_flag(remote_probe)
.default(DISPLAY_REFRESH_DEFAULT_PROBE_ENABLED)
.resolve()
.value;
let auto_cadence_enabled = BoolFlag::env(ENV_DISPLAY_REFRESH_AUTO_CADENCE)
.requirement(req.settings.auto_cadence_enabled)
.config(usr.settings.auto_cadence_enabled)
.managed(mng.settings.auto_cadence_enabled)
.feature_flag(remote_auto)
.default(DISPLAY_REFRESH_DEFAULT_AUTO_CADENCE_ENABLED)
.resolve()
.value;
let floor = pick_u32(
req.settings.floor_ms,
usr.settings.floor_ms,
mng.settings.floor_ms,
remote_obj.and_then(|d| d.floor_ms),
DISPLAY_REFRESH_DEFAULT_FLOOR_MS,
);
let ceiling = pick_u32(
req.settings.ceiling_ms,
usr.settings.ceiling_ms,
mng.settings.ceiling_ms,
remote_obj.and_then(|d| d.ceiling_ms),
DISPLAY_REFRESH_DEFAULT_CEILING_MS,
);
let floor = Picked {
value: clamp_cadence_ms(floor.value),
prio: floor.prio,
};
let ceiling = Picked {
value: clamp_cadence_ms(ceiling.value),
prio: ceiling.prio,
};
let (floor_ms, ceiling_ms) = order_bounds(
floor,
ceiling,
DISPLAY_REFRESH_DEFAULT_FLOOR_MS,
DISPLAY_REFRESH_DEFAULT_CEILING_MS,
);
let min_hz = pick_u32(
req.settings.min_hz,
usr.settings.min_hz,
mng.settings.min_hz,
remote_obj.and_then(|d| d.min_hz),
DISPLAY_REFRESH_DEFAULT_MIN_HZ,
);
let max_hz = pick_u32(
req.settings.max_hz,
usr.settings.max_hz,
mng.settings.max_hz,
remote_obj.and_then(|d| d.max_hz),
DISPLAY_REFRESH_DEFAULT_MAX_HZ,
);
let (min_hz, max_hz) = order_bounds(
min_hz,
max_hz,
DISPLAY_REFRESH_DEFAULT_MIN_HZ,
DISPLAY_REFRESH_DEFAULT_MAX_HZ,
);
DisplayRefreshPolicy {
probe_enabled,
auto_cadence_enabled,
floor_ms,
ceiling_ms,
min_hz,
max_hz,
}
}
/// Pure auto-cadence derivation from policy + probe Hz.
///
/// - `policy.probe_enabled == false` → reason `disabled` (no Hz, no auto)
/// - `auto_cadence_enabled == false` → reason `flag_off`
/// - no `hz` → reason `probe_skip`
/// - `hz` outside `[min_hz, max_hz]` → reason `hz_out_of_range`
/// - else `ms = clamp(round(1000/hz), floor, ceiling)`, reason `applied`
pub fn decide_auto_cadence(
policy: &DisplayRefreshPolicy,
probe_hz: Option<u32>,
) -> AutoCadenceDecision {
if !policy.probe_enabled {
return AutoCadenceDecision {
ms: None,
reason: "disabled",
};
}
if !policy.auto_cadence_enabled {
return AutoCadenceDecision {
ms: None,
reason: "flag_off",
};
}
let Some(hz) = probe_hz else {
return AutoCadenceDecision {
ms: None,
reason: "probe_skip",
};
};
if hz < policy.min_hz || hz > policy.max_hz {
return AutoCadenceDecision {
ms: None,
reason: "hz_out_of_range",
};
}
let raw = ((1000.0_f64 / f64::from(hz)).round() as u64).max(1);
let lo = u64::from(policy.floor_ms);
let hi = u64::from(policy.ceiling_ms);
let ms = raw.clamp(lo, hi);
AutoCadenceDecision {
ms: Some(ms),
reason: "applied",
}
}
/// Merge auto-cadence with optional env cadence overrides.
///
/// Env knobs always win when present (`Some(ms)` even if parse-defaulted).
/// When env is `None`, auto `ms` is used if `Some`, else `default_ms`.
///
/// `reason` is `env_override` when **both** env knobs are set and auto is not
/// gated off (`flag_off` / `disabled`) — including when the probe was skipped
/// for cadence because env already pins both clocks.
pub fn merge_motion_cadence(
auto: AutoCadenceDecision,
min_draw_env: Option<u64>,
scroll_env: Option<u64>,
default_ms: u64,
) -> MotionCadence {
let auto_ms = auto.ms.unwrap_or(default_ms);
let min_draw_ms = min_draw_env.unwrap_or(auto_ms);
let scroll_ms = scroll_env.unwrap_or(auto_ms);
let auto_applied = auto.ms.is_some() && (min_draw_env.is_none() || scroll_env.is_none());
let both_env = min_draw_env.is_some() && scroll_env.is_some();
let reason = if both_env && auto.reason != "flag_off" && auto.reason != "disabled" {
"env_override"
} else {
auto.reason
};
MotionCadence {
min_draw_ms,
scroll_ms,
auto_applied,
reason,
}
}
/// Decide auto-cadence from policy + probe, then merge optional env overrides.
pub fn resolve_motion_cadence(
policy: &DisplayRefreshPolicy,
probe_hz: Option<u32>,
min_draw_env: Option<u64>,
scroll_env: Option<u64>,
) -> MotionCadence {
let auto = decide_auto_cadence(policy, probe_hz);
merge_motion_cadence(
auto,
min_draw_env,
scroll_env,
DISPLAY_REFRESH_DEFAULT_CADENCE_MS,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = DISPLAY_REFRESH_ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
unsafe {
std::env::remove_var(ENV_DISPLAY_REFRESH_PROBE_ENABLED);
std::env::remove_var(ENV_DISPLAY_REFRESH_AUTO_CADENCE);
}
g
}
fn toml_nested(body: &str) -> TomlValue {
toml::from_str(&format!("[ui.display_refresh]\n{body}\n")).unwrap()
}
fn remote_object(settings: DisplayRefreshSettings) -> RemoteSettings {
RemoteSettings {
display_refresh: Some(settings),
..RemoteSettings::default()
}
}
#[test]
fn defaults_probe_on_auto_off() {
let _g = guard();
let p = resolve_display_refresh(None, None, None, None);
assert_eq!(p, DisplayRefreshPolicy::default());
assert!(p.probe_enabled);
assert!(!p.auto_cadence_enabled);
assert_eq!(p.floor_ms, 8);
assert_eq!(p.ceiling_ms, 16);
assert_eq!(p.min_hz, 55);
assert_eq!(p.max_hz, 165);
}
#[test]
fn nested_toml_and_remote_probe_kill() {
let _g = guard();
let off = toml_nested("probe_enabled = false\n");
assert!(!resolve_display_refresh(None, Some(&off), None, None).probe_enabled);
let remote = remote_object(DisplayRefreshSettings {
probe_enabled: Some(false),
auto_cadence_enabled: Some(true),
..Default::default()
});
let p = resolve_display_refresh(None, None, None, Some(&remote));
assert!(!p.probe_enabled);
assert!(p.auto_cadence_enabled);
}
#[test]
fn nested_auto_and_knobs_from_toml_and_remote() {
let _g = guard();
let user = toml_nested("auto_cadence_enabled = true\nfloor_ms = 7\nmin_hz = 50\n");
let remote = remote_object(DisplayRefreshSettings {
ceiling_ms: Some(12),
max_hz: Some(200),
..Default::default()
});
let p = resolve_display_refresh(None, Some(&user), None, Some(&remote));
assert!(p.auto_cadence_enabled);
assert_eq!(p.floor_ms, 7);
assert_eq!(p.ceiling_ms, 12);
assert_eq!(p.min_hz, 50);
assert_eq!(p.max_hz, 200);
}
#[test]
fn user_toml_beats_remote_for_auto() {
let _g = guard();
let user = toml_nested("auto_cadence_enabled = false\n");
let remote = remote_object(DisplayRefreshSettings {
auto_cadence_enabled: Some(true),
..Default::default()
});
assert!(
!resolve_display_refresh(None, Some(&user), None, Some(&remote)).auto_cadence_enabled
);
}
#[test]
fn env_overrides_probe_and_auto() {
let _g = guard();
unsafe {
std::env::set_var(ENV_DISPLAY_REFRESH_PROBE_ENABLED, "0");
std::env::set_var(ENV_DISPLAY_REFRESH_AUTO_CADENCE, "1");
}
let on = toml_nested("probe_enabled = true\nauto_cadence_enabled = false\n");
let remote = remote_object(DisplayRefreshSettings {
probe_enabled: Some(true),
auto_cadence_enabled: Some(false),
..Default::default()
});
let p = resolve_display_refresh(None, Some(&on), None, Some(&remote));
assert!(!p.probe_enabled);
assert!(p.auto_cadence_enabled);
unsafe {
std::env::remove_var(ENV_DISPLAY_REFRESH_PROBE_ENABLED);
std::env::remove_var(ENV_DISPLAY_REFRESH_AUTO_CADENCE);
}
}
#[test]
fn requirement_beats_env() {
let _g = guard();
unsafe {
std::env::set_var(ENV_DISPLAY_REFRESH_PROBE_ENABLED, "0");
std::env::set_var(ENV_DISPLAY_REFRESH_AUTO_CADENCE, "0");
}
let req = toml_nested("probe_enabled = true\nauto_cadence_enabled = true\n");
let p = resolve_display_refresh(Some(&req), None, None, None);
assert!(p.probe_enabled);
assert!(p.auto_cadence_enabled);
unsafe {
std::env::remove_var(ENV_DISPLAY_REFRESH_PROBE_ENABLED);
std::env::remove_var(ENV_DISPLAY_REFRESH_AUTO_CADENCE);
}
}
#[test]
fn floor_ceiling_clamp_and_inverted_same_layer_defaults() {
let _g = guard();
// same-layer inverted → compiled defaults
let user = toml_nested("floor_ms = 20\nceiling_ms = 10\n");
let p = resolve_display_refresh(None, Some(&user), None, None);
assert_eq!(p.floor_ms, DISPLAY_REFRESH_DEFAULT_FLOOR_MS);
assert_eq!(p.ceiling_ms, DISPLAY_REFRESH_DEFAULT_CEILING_MS);
// 0 → clamp to 1
let zero = toml_nested("floor_ms = 0\nceiling_ms = 0\n");
let p = resolve_display_refresh(None, Some(&zero), None, None);
assert_eq!(p.floor_ms, 1);
assert_eq!(p.ceiling_ms, 1);
// above env band → clamp to 100
let hi = toml_nested("floor_ms = 200\nceiling_ms = 500\n");
let p = resolve_display_refresh(None, Some(&hi), None, None);
assert_eq!(p.floor_ms, 100);
assert_eq!(p.ceiling_ms, 100);
}
#[test]
fn higher_priority_bound_wins_when_inverted() {
let _g = guard();
// requirements min_hz=100 beats remote max_hz=90 → keep 100..=100
let req = toml_nested("min_hz = 100\n");
let remote = remote_object(DisplayRefreshSettings {
max_hz: Some(90),
..Default::default()
});
let p = resolve_display_refresh(Some(&req), None, None, Some(&remote));
assert_eq!(p.min_hz, 100);
assert_eq!(p.max_hz, 100);
}
#[test]
fn wrong_typed_field_does_not_drop_siblings() {
let _g = guard();
let toml = toml::from_str(
r#"
[ui.display_refresh]
probe_enabled = false
floor_ms = "bad"
auto_cadence_enabled = true
"#,
)
.unwrap();
let p = resolve_display_refresh(None, Some(&toml), None, None);
assert!(!p.probe_enabled);
assert!(p.auto_cadence_enabled);
assert_eq!(p.floor_ms, DISPLAY_REFRESH_DEFAULT_FLOOR_MS);
}
#[test]
fn both_env_reports_env_override_without_probe_hz() {
let policy = policy_auto_on();
let m = resolve_motion_cadence(&policy, None, Some(8), Some(8));
assert_eq!(m.min_draw_ms, 8);
assert_eq!(m.scroll_ms, 8);
assert!(!m.auto_applied);
assert_eq!(m.reason, "env_override");
}
fn policy_auto_on() -> DisplayRefreshPolicy {
DisplayRefreshPolicy {
auto_cadence_enabled: true,
..DisplayRefreshPolicy::default()
}
}
#[test]
fn decide_flag_off_by_default() {
let d = decide_auto_cadence(&DisplayRefreshPolicy::default(), Some(120));
assert_eq!(d.ms, None);
assert_eq!(d.reason, "flag_off");
}
#[test]
fn decide_disabled_when_probe_off() {
let mut p = policy_auto_on();
p.probe_enabled = false;
let d = decide_auto_cadence(&p, Some(120));
assert_eq!(d.ms, None);
assert_eq!(d.reason, "disabled");
}
#[test]
fn decide_probe_skip_without_hz() {
let d = decide_auto_cadence(&policy_auto_on(), None);
assert_eq!(d.ms, None);
assert_eq!(d.reason, "probe_skip");
}
#[test]
fn decide_hz_out_of_range() {
let d = decide_auto_cadence(&policy_auto_on(), Some(30));
assert_eq!(d.ms, None);
assert_eq!(d.reason, "hz_out_of_range");
let d = decide_auto_cadence(&policy_auto_on(), Some(200));
assert_eq!(d.reason, "hz_out_of_range");
}
#[test]
fn decide_applied_clamps_round_1000_over_hz() {
let d = decide_auto_cadence(&policy_auto_on(), Some(120));
assert_eq!(d.ms, Some(8));
assert_eq!(d.reason, "applied");
let d = decide_auto_cadence(&policy_auto_on(), Some(60));
assert_eq!(d.ms, Some(16));
let d = decide_auto_cadence(&policy_auto_on(), Some(144));
assert_eq!(d.ms, Some(8));
let d = decide_auto_cadence(&policy_auto_on(), Some(55));
assert_eq!(d.reason, "applied");
let d = decide_auto_cadence(&policy_auto_on(), Some(165));
assert_eq!(d.reason, "applied");
}
#[test]
fn merge_uses_auto_when_env_unset() {
let auto = AutoCadenceDecision {
ms: Some(8),
reason: "applied",
};
let c = merge_motion_cadence(auto, None, None, 16);
assert_eq!((c.min_draw_ms, c.scroll_ms), (8, 8));
assert!(c.auto_applied);
assert_eq!(c.reason, "applied");
}
#[test]
fn merge_env_wins_per_clock() {
let auto = AutoCadenceDecision {
ms: Some(8),
reason: "applied",
};
let c = merge_motion_cadence(auto, Some(10), None, 16);
assert_eq!(c.min_draw_ms, 10);
assert_eq!(c.scroll_ms, 8);
assert!(c.auto_applied);
assert_eq!(c.reason, "applied");
}
#[test]
fn merge_both_env_override_reason() {
let auto = AutoCadenceDecision {
ms: Some(8),
reason: "applied",
};
let c = merge_motion_cadence(auto, Some(10), Some(12), 16);
assert_eq!((c.min_draw_ms, c.scroll_ms), (10, 12));
assert!(!c.auto_applied);
assert_eq!(c.reason, "env_override");
}
#[test]
fn merge_defaults_when_auto_off() {
let auto = AutoCadenceDecision {
ms: None,
reason: "flag_off",
};
let c = merge_motion_cadence(auto, None, None, 16);
assert_eq!((c.min_draw_ms, c.scroll_ms), (16, 16));
assert!(!c.auto_applied);
assert_eq!(c.reason, "flag_off");
}
#[test]
fn resolve_motion_cadence_folds_decide_and_merge() {
let p = policy_auto_on();
let c = resolve_motion_cadence(&p, Some(120), None, None);
assert_eq!(c.min_draw_ms, 8);
assert_eq!(c.scroll_ms, 8);
assert!(c.auto_applied);
assert_eq!(c.reason, "applied");
let c = resolve_motion_cadence(&p, Some(120), Some(10), Some(12));
assert_eq!((c.min_draw_ms, c.scroll_ms), (10, 12));
assert!(!c.auto_applied);
assert_eq!(c.reason, "env_override");
}
#[test]
fn layer_deserializes_partial_object() {
let toml = toml_nested("auto_cadence_enabled = true\nfloor_ms = 7\n");
let layer = DisplayRefreshLayer::from_toml(Some(&toml));
assert_eq!(layer.settings.auto_cadence_enabled, Some(true));
assert_eq!(layer.settings.floor_ms, Some(7));
assert_eq!(layer.settings.probe_enabled, None);
}
}
@@ -0,0 +1,262 @@
use crate::util::config::RemoteSettings;
use toml::Value as TomlValue;
/// Resolve whether ZDR users are allowed to use the product.
///
/// Precedence: requirements > env > config.toml > managed > remote settings > default (false).
pub fn resolve_zdr_access_enabled(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
remote: Option<&RemoteSettings>,
) -> bool {
use crate::agent::config::BoolFlag;
fn from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("features")?.get("zdr_access_enabled")?.as_bool()
}
BoolFlag::env("KIGI_ZDR_ACCESS_ENABLED")
.requirement(from_toml(requirements))
.config(from_toml(user))
.managed(from_toml(managed))
.feature_flag(remote.and_then(|r| r.zdr_access_enabled))
.resolve()
.value
}
/// Whether model-catalog (`/v1/models`) and remote-settings (`/v1/settings`)
/// fetches from xAI backends are allowed, including the deployment-config sync
/// bundled into the startup prefetch (the background managed-config sync has
/// its own `[features] managed_config` gate).
///
/// Precedence: requirements (MDM > system > user) > managed
/// (`managed_config.toml` > system managed) > user `config.toml` > default
/// (true). Callable before an `AgentConfig` exists (startup prefetch runs
/// pre-agent), so it re-reads the config layers like
/// `managed_config::is_fetch_enabled`.
///
/// Deliberately no env var and no remote tier: remote settings are exactly
/// what is unreachable when this knob is needed (firewalled / air-gapped
/// deployments), and an env var would be one more way to re-arm the fetches.
pub fn resolve_remote_fetch_enabled() -> bool {
match crate::config::ConfigLayers::load() {
Ok(layers) => remote_fetch_enabled_from_layers(&layers),
// The full-layer load is all-or-nothing, but the policy tiers load
// independently (requirements soft-fail per layer; the managed loaders
// are the same ones ConfigLayers::load uses) — a corrupt user-writable
// config.toml must not disarm a requirements or managed-layer pin.
// Fail open only when policy is genuinely absent.
Err(_) => remote_fetch_enabled_from_policy_layers(
crate::config::load_merged_requirements().as_ref(),
crate::config::load_managed_config().ok().as_ref(),
crate::config::load_system_managed_config().ok().as_ref(),
),
}
}
fn remote_fetch_value(v: &TomlValue) -> Option<bool> {
v.get("features")?.get("remote_fetch")?.as_bool()
}
/// First-match layer walk instead of the plain effective-config merge: the
/// merge puts the user layer over managed, but for this knob the management
/// layer must win so a user's stray `remote_fetch = true` cannot re-arm a
/// deployment's "never fetch" decision.
fn remote_fetch_enabled_from_layers(layers: &crate::config::ConfigLayers) -> bool {
// Exhaustive destructure (no `..`): a future layer must be slotted into the
// walk deliberately instead of silently keeping stale precedence.
// `campaigns` is deliberately NOT in the walk: campaign patches are soft,
// dismissable overlays applied after the layer merge — they must never
// arm/disarm a policy knob like remote_fetch (requirements are re-merged
// over campaigns for the same reason).
let crate::config::ConfigLayers {
system_managed,
managed,
user,
user_requirements,
system_requirements,
mdm_requirements,
campaigns: _,
} = layers;
[
mdm_requirements.as_ref(),
system_requirements.as_ref(),
user_requirements.as_ref(),
Some(managed),
Some(system_managed),
Some(user),
]
.into_iter()
.flatten()
.find_map(remote_fetch_value)
.unwrap_or(true)
}
/// Err-arm fallback for [`resolve_remote_fetch_enabled`]: the independently
/// loadable policy tiers in Ok-arm walk order — merged requirements
/// (`load_merged_requirements` merges user, system, MDM with last-wins,
/// matching the walk), then the managed tiers — so a root-owned or synced
/// managed-only pin also survives a corrupt user layer. The user `config.toml`
/// tier stays fail-open: it is a preference, not deployment policy. Mirrors
/// the `auto_permission_mode_enabled_from_disk` soft-fail precedent.
fn remote_fetch_enabled_from_policy_layers(
merged_requirements: Option<&TomlValue>,
managed: Option<&TomlValue>,
system_managed: Option<&TomlValue>,
) -> bool {
[merged_requirements, managed, system_managed]
.into_iter()
.flatten()
.find_map(remote_fetch_value)
.unwrap_or(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigLayers;
fn empty_layers() -> ConfigLayers {
ConfigLayers {
system_managed: TomlValue::Table(Default::default()),
managed: TomlValue::Table(Default::default()),
user: TomlValue::Table(Default::default()),
user_requirements: None,
system_requirements: None,
mdm_requirements: None,
..Default::default()
}
}
fn features_remote_fetch(v: bool) -> TomlValue {
toml::from_str(&format!("[features]\nremote_fetch = {v}\n")).unwrap()
}
#[test]
fn remote_fetch_defaults_to_true_when_absent() {
assert!(remote_fetch_enabled_from_layers(&empty_layers()));
}
#[test]
fn remote_fetch_reads_user_config() {
let mut layers = empty_layers();
layers.user = features_remote_fetch(false);
assert!(!remote_fetch_enabled_from_layers(&layers));
}
#[test]
fn remote_fetch_managed_overrides_user() {
let mut layers = empty_layers();
layers.user = features_remote_fetch(true);
layers.managed = features_remote_fetch(false);
assert!(
!remote_fetch_enabled_from_layers(&layers),
"managed=false must beat user=true"
);
// Both directions, proving precedence rather than AND-ing.
layers.user = features_remote_fetch(false);
layers.managed = features_remote_fetch(true);
assert!(
remote_fetch_enabled_from_layers(&layers),
"managed=true must beat user=false"
);
}
#[test]
fn remote_fetch_requirements_pin_beats_managed_and_user() {
let mut layers = empty_layers();
layers.user = features_remote_fetch(true);
layers.managed = features_remote_fetch(true);
layers.user_requirements = Some(features_remote_fetch(false));
assert!(
!remote_fetch_enabled_from_layers(&layers),
"requirements=false must beat managed and user"
);
layers.user = features_remote_fetch(false);
layers.managed = features_remote_fetch(false);
layers.user_requirements = Some(features_remote_fetch(true));
assert!(
remote_fetch_enabled_from_layers(&layers),
"requirements=true must beat managed and user"
);
}
#[test]
fn remote_fetch_system_and_mdm_tiers_follow_the_walk() {
// Within the managed tier: user-level managed_config.toml beats the
// system managed layer (mirrors effective_config merge order), and
// system managed still beats the user config.
let mut layers = empty_layers();
layers.system_managed = features_remote_fetch(true);
layers.managed = features_remote_fetch(false);
assert!(!remote_fetch_enabled_from_layers(&layers));
layers.managed = features_remote_fetch(true);
layers.system_managed = features_remote_fetch(false);
assert!(remote_fetch_enabled_from_layers(&layers));
let mut layers = empty_layers();
layers.user = features_remote_fetch(true);
layers.system_managed = features_remote_fetch(false);
assert!(!remote_fetch_enabled_from_layers(&layers));
// Within the requirements tier: system beats user requirements, MDM
// beats both (mirrors requirements_layers apply order).
let mut layers = empty_layers();
layers.user_requirements = Some(features_remote_fetch(true));
layers.system_requirements = Some(features_remote_fetch(false));
assert!(!remote_fetch_enabled_from_layers(&layers));
layers.mdm_requirements = Some(features_remote_fetch(true));
assert!(remote_fetch_enabled_from_layers(&layers));
layers.mdm_requirements = Some(features_remote_fetch(false));
layers.system_requirements = Some(features_remote_fetch(true));
assert!(!remote_fetch_enabled_from_layers(&layers));
}
/// The all-or-nothing layer load failing (corrupt user config.toml, IO
/// error) must not disarm a policy pin — the Err arm still consults the
/// merged requirements and both managed tiers, in Ok-arm walk order, and
/// fails open only with no policy at all.
#[test]
fn remote_fetch_layer_load_failure_still_honors_policy_pins() {
let off = features_remote_fetch(false);
let on = features_remote_fetch(true);
// Requirements pin survives, both directions.
assert!(!remote_fetch_enabled_from_policy_layers(
Some(&off),
None,
None
));
assert!(remote_fetch_enabled_from_policy_layers(
Some(&on),
None,
None
));
// A pin living only in a managed tier survives too (root-owned
// system-managed-only and synced managed-only deployments).
assert!(!remote_fetch_enabled_from_policy_layers(
None,
None,
Some(&off)
));
assert!(!remote_fetch_enabled_from_policy_layers(
None,
Some(&off),
None
));
// Precedence mirrors the Ok-arm walk: requirements > managed > system managed.
assert!(remote_fetch_enabled_from_policy_layers(
Some(&on),
Some(&off),
Some(&off)
));
assert!(!remote_fetch_enabled_from_policy_layers(
None,
Some(&off),
Some(&on)
));
assert!(
remote_fetch_enabled_from_policy_layers(None, None, None),
"genuinely absent policy fails open"
);
}
}
@@ -0,0 +1,466 @@
use toml::Value as TomlValue;
/// Resolve `mcp.liveness_watchers` for a session.
///
/// Thin wrapper around the canonical
/// [`crate::agent::config::resolve_mcp_liveness_watchers`], which
/// unifies the two previous implementations so they can't drift.
///
/// Pulls each layer from its appropriate TOML / runtime source:
///
/// | Layer | Source |
/// |--------------|-----------------------------------------------------------------|
/// | requirement | `[features] mcp_liveness_watchers` in `requirements.toml` |
/// | cli | (none — no CLI flag) |
/// | env | `KIGI_MCP_LIVENESS_WATCHERS` (handled by `BoolFlag::env`) |
/// | config | `[features] mcp_liveness_watchers` in `~/.kigi/config.toml` |
/// | managed | `[features] mcp_liveness_watchers` in `managed_config.toml` |
/// | feature_flag | (none yet — remote settings plumbing TBD) |
/// | default | `true` |
///
/// Returns the resolved boolean (the `Resolved::source` is discarded
/// for this call site — session-actor only needs the value).
pub fn resolve_mcp_liveness_watchers(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
) -> bool {
fn from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("features")?.get("mcp_liveness_watchers")?.as_bool()
}
crate::agent::config::resolve_mcp_liveness_watchers(
from_toml(requirements),
/* cli */ None,
from_toml(user),
from_toml(managed),
/* feature_flag */ None,
)
.value
}
/// Resolve `mcp.auto_restart` for a session.
///
/// Thin wrapper around the canonical
/// [`crate::agent::config::resolve_mcp_auto_restart`]. Mirrors
/// [`resolve_mcp_liveness_watchers`].
///
/// Pulls each layer from its appropriate TOML / runtime source:
///
/// | Layer | Source |
/// |--------------|-----------------------------------------------------------------|
/// | requirement | `[features] mcp_auto_restart` in `requirements.toml` |
/// | cli | (none — no CLI flag) |
/// | env | `KIGI_MCP_AUTO_RESTART` (handled by `BoolFlag::env`) |
/// | config | `[features] mcp_auto_restart` in `~/.kigi/config.toml` |
/// | managed | `[features] mcp_auto_restart` in `managed_config.toml` |
/// | feature_flag | (none yet — remote settings plumbing TBD) |
/// | default | `true` |
///
/// Returns the resolved boolean (the `Resolved::source` is discarded
/// for this call site — session-actor only needs the value).
pub fn resolve_mcp_auto_restart(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
) -> bool {
fn from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("features")?.get("mcp_auto_restart")?.as_bool()
}
crate::agent::config::resolve_mcp_auto_restart(
from_toml(requirements),
/* cli */ None,
from_toml(user),
from_toml(managed),
/* feature_flag */ None,
)
.value
}
/// Resolve `mcp.push_server_status` for a session.
///
/// Thin wrapper around the canonical
/// [`crate::agent::config::resolve_mcp_push_server_status`] that
/// mirrors [`resolve_mcp_liveness_watchers`].
///
/// Pulls each layer from its TOML / runtime source:
///
/// | Layer | Source |
/// |--------------|-----------------------------------------------------------------|
/// | requirement | `[features] mcp_push_server_status` in `requirements.toml` |
/// | cli | (none — no CLI flag) |
/// | env | `KIGI_MCP_PUSH_SERVER_STATUS` (handled by `BoolFlag::env`) |
/// | config | `[features] mcp_push_server_status` in `~/.kigi/config.toml` |
/// | managed | `[features] mcp_push_server_status` in `managed_config.toml` |
/// | feature_flag | (none yet — remote settings plumbing TBD) |
/// | default | `true` |
///
/// Returns the resolved boolean.
pub fn resolve_mcp_push_server_status(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
) -> bool {
fn from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("features")?.get("mcp_push_server_status")?.as_bool()
}
crate::agent::config::resolve_mcp_push_server_status(
from_toml(requirements),
/* cli */ None,
from_toml(user),
from_toml(managed),
/* feature_flag */ None,
)
.value
}
/// Resolve `mcp.recursive_config_watch` for the leader's
/// `ConfigFileWatcher` spawn path.
///
/// Thin wrapper around the canonical
/// [`crate::agent::config::resolve_mcp_recursive_config_watch`] —
/// mirrors the same wrapper pattern as the other MCP resolvers so the
/// two implementations can't drift.
///
/// Pulls each layer from its TOML / runtime source:
///
/// | Layer | Source |
/// |--------------|---------------------------------------------------------------------|
/// | requirement | `[features] mcp_recursive_config_watch` in `requirements.toml` |
/// | cli | (none — no CLI flag) |
/// | env | `KIGI_MCP_RECURSIVE_CONFIG_WATCH` (handled by `BoolFlag::env`) |
/// | config | `[features] mcp_recursive_config_watch` in `~/.kigi/config.toml` |
/// | managed | `[features] mcp_recursive_config_watch` in `managed_config.toml` |
/// | feature_flag | (none yet — remote settings plumbing TBD) |
/// | default | `true` |
///
/// Returns the resolved boolean (the `Resolved::source` is discarded
/// for this call site — the leader's watcher-spawn only needs the
/// value).
pub fn resolve_mcp_recursive_config_watch(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
) -> bool {
fn from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("features")?
.get("mcp_recursive_config_watch")?
.as_bool()
}
crate::agent::config::resolve_mcp_recursive_config_watch(
from_toml(requirements),
/* cli */ None,
from_toml(user),
from_toml(managed),
/* feature_flag */ None,
)
.value
}
/// Default MCP startup-handshake timeout (seconds) when nothing overrides it.
/// Kept in sync with `kigi_mcp::servers`'s standalone fallback.
pub const DEFAULT_MCP_STARTUP_TIMEOUT_SECS: u64 = 30;
/// Env override for the MCP startup timeout, in milliseconds (shared with
/// common third-party tooling, so an existing setting carries over).
const ENV_MCP_TIMEOUT_MS: &str = "MCP_TIMEOUT";
/// Env override for the MCP startup timeout, in seconds (grok-native).
const ENV_MCP_STARTUP_TIMEOUT_SECS: &str = "KIGI_MCP_STARTUP_TIMEOUT_SECS";
/// Cached remote settings `mcp_startup_timeout_secs` (`0` = unset). MCP servers start
/// from free functions with no handle to the live `RemoteSettings`, so the
/// remote tier is cached here when settings are applied.
static REMOTE_MCP_STARTUP_TIMEOUT_SECS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
/// Record the remote settings `mcp_startup_timeout_secs` for the free-function
/// resolver. Call wherever `RemoteSettings` is applied. `0` is treated as unset.
pub fn cache_remote_mcp_startup_timeout_secs(value: Option<u64>) {
REMOTE_MCP_STARTUP_TIMEOUT_SECS.store(value.unwrap_or(0), std::sync::atomic::Ordering::Relaxed);
}
fn cached_remote_mcp_startup_timeout_secs() -> Option<u64> {
match REMOTE_MCP_STARTUP_TIMEOUT_SECS.load(std::sync::atomic::Ordering::Relaxed) {
0 => None,
secs => Some(secs),
}
}
/// Global default MCP startup-handshake timeout (seconds), applying the cached
/// remote tier. Global fallback only — a per-server
/// `startup_timeout_sec` / `_meta.startupTimeoutMs` still wins (see
/// `session::mcp_servers`).
pub fn resolved_mcp_startup_timeout_secs() -> u64 {
resolve_mcp_startup_timeout_secs(cached_remote_mcp_startup_timeout_secs())
}
/// Resolve the global MCP startup-handshake timeout (seconds). Precedence:
/// requirements.toml `[mcp].startup_timeout_sec` > env (`MCP_TIMEOUT` ms /
/// `KIGI_MCP_STARTUP_TIMEOUT_SECS` secs) > effective `config.toml [mcp]` >
/// remote settings `remote` > [`DEFAULT_MCP_STARTUP_TIMEOUT_SECS`].
pub fn resolve_mcp_startup_timeout_secs(remote: Option<u64>) -> u64 {
fn extract(v: &toml::Value) -> Option<u64> {
v.get("mcp")?
.get("startup_timeout_sec")?
.as_integer()
.and_then(|n| u64::try_from(n).ok())
.filter(|n| *n > 0)
}
let requirements = crate::config::load_merged_requirements()
.as_ref()
.and_then(extract);
let config = crate::config::load_effective_config()
.ok()
.as_ref()
.and_then(extract);
resolve_mcp_startup_timeout_precedence(
requirements,
mcp_startup_timeout_from_env(),
config,
remote,
)
}
/// `MCP_TIMEOUT` (ms, rounded up so a sub-second value never becomes 0s) >
/// `KIGI_MCP_STARTUP_TIMEOUT_SECS` (secs). Unparseable/zero values are ignored.
fn mcp_startup_timeout_from_env() -> Option<u64> {
if let Some(ms) = std::env::var(ENV_MCP_TIMEOUT_MS)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|n| *n > 0)
{
return Some(ms.div_ceil(1000));
}
std::env::var(ENV_MCP_STARTUP_TIMEOUT_SECS)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|n| *n > 0)
}
/// Pure precedence for [`resolve_mcp_startup_timeout_secs`] (tiers injected so it
/// is unit-testable without touching env/disk).
fn resolve_mcp_startup_timeout_precedence(
requirements: Option<u64>,
env: Option<u64>,
config: Option<u64>,
remote: Option<u64>,
) -> u64 {
requirements
.or(env)
.or(config)
.or(remote)
.unwrap_or(DEFAULT_MCP_STARTUP_TIMEOUT_SECS)
}
#[cfg(test)]
mod mcp_startup_timeout_tests {
use super::{DEFAULT_MCP_STARTUP_TIMEOUT_SECS, resolve_mcp_startup_timeout_precedence as r};
#[test]
fn precedence_requirements_env_config_remote_default() {
assert_eq!(r(None, None, None, None), DEFAULT_MCP_STARTUP_TIMEOUT_SECS);
assert_eq!(r(Some(5), Some(6), Some(7), Some(8)), 5); // requirements highest
assert_eq!(r(None, Some(6), Some(7), Some(8)), 6); // env
assert_eq!(r(None, None, Some(7), Some(8)), 7); // config
assert_eq!(r(None, None, None, Some(8)), 8); // remote
}
}
// ── MCP max output bytes (inline tool-result cap) ───────────────────────────
//
// Full multi-tier resolve lives only here (shell can read config/requirements).
// Tools holds a single effective atomic: we resolve once on apply and push the
// result via `set_mcp_max_output_bytes` so free-function truncation sees it.
/// Default MCP tool-result inline cap (bytes).
pub const DEFAULT_MAX_MCP_OUTPUT_BYTES: usize = kigi_tools::MCP_MAX_OUTPUT_BYTES;
/// Resolve the full stack for `remote` and seed the tools-crate effective limit.
///
/// Call wherever `RemoteSettings` is applied (same sites as
/// [`cache_remote_mcp_startup_timeout_secs`]). Unlike that helper — which only
/// caches the remote tier for a free-function resolver still living in shell —
/// this pushes the *fully resolved* value into tools (tools cannot re-read
/// config/requirements on every use).
pub fn cache_remote_max_mcp_output_bytes(remote: Option<u64>) {
kigi_tools::set_mcp_max_output_bytes(resolve_max_mcp_output_bytes(remote));
}
/// Extract `[mcp] max_output_bytes` from one TOML root. Positive integers only.
fn max_mcp_output_bytes_from_toml(v: &toml::Value) -> Option<usize> {
let raw = v.get("mcp")?.get("max_output_bytes")?.as_integer()?;
u64::try_from(raw)
.ok()
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n > 0)
}
/// Resolve the MCP tool-result inline cap (bytes) — **global / atomic path**
/// (no cwd, so no project tier; see [`resolve_max_mcp_output_bytes_for_cwd`]).
///
/// Precedence (highest first):
/// 1. requirements.toml `[mcp] max_output_bytes`
/// 2. env `KIGI_MAX_MCP_OUTPUT_BYTES` / `MAX_MCP_OUTPUT_BYTES`
/// (Grok-native wins when both set)
/// 3. effective `config.toml [mcp] max_output_bytes`
/// 4. remote settings `RemoteSettings.max_mcp_output_bytes`
/// 5. [`DEFAULT_MAX_MCP_OUTPUT_BYTES`] (20_000)
pub fn resolve_max_mcp_output_bytes(remote: Option<u64>) -> usize {
let remote_usize = remote
.and_then(|n| usize::try_from(n).ok())
.filter(|n| *n > 0);
let requirements = crate::config::load_merged_requirements()
.as_ref()
.and_then(max_mcp_output_bytes_from_toml);
let config = crate::config::load_effective_config()
.ok()
.as_ref()
.and_then(max_mcp_output_bytes_from_toml);
resolve_max_mcp_output_bytes_precedence(
requirements,
kigi_tools::mcp_max_output_bytes_from_env(),
None, // project tier needs a cwd — see resolve_max_mcp_output_bytes_for_cwd
config,
remote_usize,
)
}
/// Project tier of the MCP output cap: `[mcp] max_output_bytes` from the
/// `.kigi/config.toml` chain (`cwd` → git root), deepest file wins.
///
/// Folder-trust-gated: an untrusted checkout must not raise (context-stuffing
/// / cost vector) or lower the cap, matching how project plugin paths and
/// repo env contributions are gated.
fn project_max_mcp_output_bytes(cwd: &std::path::Path) -> Option<usize> {
if !crate::agent::folder_trust::project_scope_allowed(cwd) {
return None;
}
let mut value = None;
// Repo-root-first → cwd-last: later (deeper) files overwrite.
for config_path in crate::config::find_project_configs(cwd) {
if let Ok(toml_val) = kigi_config::load_config_file(&config_path)
&& let Some(v) = max_mcp_output_bytes_from_toml(&toml_val)
{
value = Some(v);
}
}
value
}
/// Session-scoped MCP output cap: `Some(bytes)` **only when the project tier
/// wins** the full precedence stack for `cwd`; `None` otherwise.
///
/// The caller seeds `Some` values into the session's `TruncationCfg` resource
/// (consulted by MCP truncation *before* the process-global atomic). Returning
/// `None` when any higher- or lower-priority tier would win keeps the atomic
/// authoritative for those — including live remote settings refresh — so sessions
/// without a repo-level value behave exactly as before.
///
/// The project tier only wins when requirements and env are absent (it sits
/// above user config / remote settings / default), so `Some` here is simply
/// "requirements and env unset, project value present".
pub fn resolve_max_mcp_output_bytes_for_cwd(cwd: &std::path::Path) -> Option<usize> {
let requirements = crate::config::load_merged_requirements()
.as_ref()
.and_then(max_mcp_output_bytes_from_toml);
if requirements.is_some() || kigi_tools::mcp_max_output_bytes_from_env().is_some() {
return None;
}
project_max_mcp_output_bytes(cwd)
}
/// Pure precedence for [`resolve_max_mcp_output_bytes`] (tiers injected so it is
/// unit-testable without env/disk).
///
/// `requirements` > `env` > `project` > `config` > `remote` > default.
pub(crate) fn resolve_max_mcp_output_bytes_precedence(
requirements: Option<usize>,
env: Option<usize>,
project: Option<usize>,
config: Option<usize>,
remote: Option<usize>,
) -> usize {
requirements
.or(env)
.or(project)
.or(config)
.or(remote)
.unwrap_or(DEFAULT_MAX_MCP_OUTPUT_BYTES)
}
#[cfg(test)]
mod max_mcp_output_bytes_tests {
use super::{
DEFAULT_MAX_MCP_OUTPUT_BYTES, max_mcp_output_bytes_from_toml,
resolve_max_mcp_output_bytes_precedence as r,
};
#[test]
fn precedence_requirements_env_project_config_remote_default() {
assert_eq!(
r(None, None, None, None, None),
DEFAULT_MAX_MCP_OUTPUT_BYTES
);
assert_eq!(r(Some(1), Some(2), Some(9), Some(3), Some(4)), 1); // requirements highest
assert_eq!(r(None, Some(2), Some(9), Some(3), Some(4)), 2); // env beats project
assert_eq!(r(None, None, Some(9), Some(3), Some(4)), 9); // project beats user config
assert_eq!(r(None, None, None, Some(3), Some(4)), 3); // user config beats remote
assert_eq!(r(None, None, None, None, Some(4)), 4); // remote beats default
}
#[test]
fn toml_extractor_rejects_non_positive_and_wrong_types() {
let ok: toml::Value = toml::from_str("[mcp]\nmax_output_bytes = 40000").unwrap();
assert_eq!(max_mcp_output_bytes_from_toml(&ok), Some(40_000));
for bad in [
"[mcp]\nmax_output_bytes = 0",
"[mcp]\nmax_output_bytes = -5",
"[mcp]\nmax_output_bytes = \"big\"",
"[other]\nmax_output_bytes = 5",
] {
let v: toml::Value = toml::from_str(bad).unwrap();
assert_eq!(max_mcp_output_bytes_from_toml(&v), None, "input: {bad}");
}
}
/// The project-tier walk: repo-root-first, deepest file wins; files
/// without the key leave the running value untouched.
///
/// Uses the pure chain logic via tempdirs + `find_project_configs`
/// ordering (repo root → cwd), mirroring `project_max_mcp_output_bytes`
/// without the trust gate (exercised separately — trust is inert in
/// dev/test builds, see `folder_trust_inert`).
#[test]
fn project_chain_deepest_file_wins() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// Make it a git repo so the chain walks subdir → root.
git2::Repository::init(root).unwrap();
let sub = root.join("crates").join("thing");
std::fs::create_dir_all(sub.join(".kigi")).unwrap();
std::fs::create_dir_all(root.join(".kigi")).unwrap();
std::fs::write(
root.join(".kigi/config.toml"),
"[mcp]\nmax_output_bytes = 30000\n",
)
.unwrap();
// Only the repo root sets the key → root value applies at the subdir.
assert_eq!(super::project_max_mcp_output_bytes(&sub), Some(30_000));
// The subdir sets it too → deeper file wins.
std::fs::write(
sub.join(".kigi/config.toml"),
"[mcp]\nmax_output_bytes = 50000\n",
)
.unwrap();
assert_eq!(super::project_max_mcp_output_bytes(&sub), Some(50_000));
// A deeper file *without* the key does not mask the root value.
std::fs::write(sub.join(".kigi/config.toml"), "[ui]\nvim_mode = true\n").unwrap();
assert_eq!(super::project_max_mcp_output_bytes(&sub), Some(30_000));
// No .kigi files with the key anywhere → None.
std::fs::remove_file(root.join(".kigi/config.toml")).unwrap();
assert_eq!(super::project_max_mcp_output_bytes(&sub), None);
}
}
@@ -0,0 +1,29 @@
// Glob re-exports keep the flat `crate::util::config::*` paths that in-crate and cross-crate callers rely on.
mod auto_mode;
mod compaction;
mod crash_handler;
mod display_refresh;
mod features;
mod mcp;
mod system_prompt;
mod tool_approvals;
mod toolset;
mod ui;
mod version;
pub use auto_mode::*;
pub use compaction::*;
pub use crash_handler::*;
pub use display_refresh::*;
pub use features::*;
pub use mcp::*;
pub use system_prompt::*;
pub use tool_approvals::*;
pub use toolset::*;
pub use ui::*;
pub use version::*;
// Single crate-wide env-mutation mutex; `permissions.rs` tests name it via this module's path.
#[cfg(test)]
pub(crate) use auto_mode::AUTO_PERMISSION_MODE_ENV_LOCK;
@@ -0,0 +1,166 @@
pub const ENV_SYSTEM_PROMPT_LABEL: &str = "KIGI_SYSTEM_PROMPT_LABEL";
pub const DEFAULT_SYSTEM_PROMPT_LABEL: &str = kigi_agent::DEFAULT_SYSTEM_PROMPT_LABEL;
/// Resolve system-prompt identity label.
/// Precedence: env → config per-model → `[agent]` → GB per-model → GB global →
/// `"Grok"`. Empty/whitespace falls through.
///
/// Per-model TOML is looked up by session catalog id, then routing slug
/// (`ModelInfo.model`). Do not use CLI `-m` alone — it may outlive a mid-session
/// model switch.
pub fn resolve_system_prompt_label(
cfg: &crate::agent::config::Config,
model_id: &str,
model: Option<&crate::agent::config::ModelInfo>,
) -> String {
let label_for = |key: &str| {
cfg.config_models
.get(key)
.and_then(|m| m.system_prompt_label.clone())
};
let user_per_model =
label_for(model_id).or_else(|| model.map(|m| m.model.as_str()).and_then(label_for));
resolve_system_prompt_label_from_tiers(
user_per_model,
cfg.agent.system_prompt_label.clone(),
model.and_then(|m| m.system_prompt_label.clone()),
cfg.remote_settings
.as_ref()
.and_then(|r| r.system_prompt_label.clone()),
)
}
pub fn resolve_system_prompt_label_from_tiers(
user_per_model: Option<String>,
user_global: Option<String>,
gb_per_model: Option<String>,
gb_global: Option<String>,
) -> String {
let non_empty = |s: Option<String>| {
s.and_then(|v| {
let t = v.trim();
(!t.is_empty()).then(|| t.to_string())
})
};
std::env::var(ENV_SYSTEM_PROMPT_LABEL)
.ok()
.and_then(|s| non_empty(Some(s)))
.or_else(|| non_empty(user_per_model))
.or_else(|| non_empty(user_global))
.or_else(|| non_empty(gb_per_model))
.or_else(|| non_empty(gb_global))
.unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT_LABEL.to_string())
}
#[cfg(test)]
mod system_prompt_label_tests {
use super::{
DEFAULT_SYSTEM_PROMPT_LABEL, ENV_SYSTEM_PROMPT_LABEL,
resolve_system_prompt_label_from_tiers,
};
/// Serialize access to `KIGI_SYSTEM_PROMPT_LABEL` and clear it for tier tests.
/// `env_wins_over_all_tiers` mutates the env; without this lock, parallel tests
/// that expect the var unset (e.g. `gb_per_model_beats_gb_global`) flake.
fn with_env_cleared<R>(f: impl FnOnce() -> R) -> R {
let _guard = ENV_LOCK.lock().unwrap();
let prev = std::env::var(ENV_SYSTEM_PROMPT_LABEL).ok();
// Safety: test-only, locked.
unsafe { std::env::remove_var(ENV_SYSTEM_PROMPT_LABEL) };
let r = f();
match prev {
Some(v) => unsafe { std::env::set_var(ENV_SYSTEM_PROMPT_LABEL, v) },
None => unsafe { std::env::remove_var(ENV_SYSTEM_PROMPT_LABEL) },
}
r
}
#[test]
fn default_when_all_unset() {
with_env_cleared(|| {
assert_eq!(
resolve_system_prompt_label_from_tiers(None, None, None, None),
DEFAULT_SYSTEM_PROMPT_LABEL
);
});
}
#[test]
fn per_model_beats_global_and_gb() {
with_env_cleared(|| {
assert_eq!(
resolve_system_prompt_label_from_tiers(
Some("PerModel".into()),
Some("Global".into()),
Some("GbPer".into()),
Some("GbGlobal".into()),
),
"PerModel"
);
});
}
#[test]
fn global_beats_gb() {
with_env_cleared(|| {
assert_eq!(
resolve_system_prompt_label_from_tiers(
None,
Some("Global".into()),
Some("GbPer".into()),
Some("GbGlobal".into()),
),
"Global"
);
});
}
#[test]
fn gb_per_model_beats_gb_global() {
with_env_cleared(|| {
assert_eq!(
resolve_system_prompt_label_from_tiers(
None,
None,
Some("GbPer".into()),
Some("GbGlobal".into()),
),
"GbPer"
);
});
}
#[test]
fn empty_and_whitespace_fall_through() {
with_env_cleared(|| {
assert_eq!(
resolve_system_prompt_label_from_tiers(
Some(" ".into()),
Some("".into()),
None,
Some("GbGlobal".into()),
),
"GbGlobal"
);
});
}
#[test]
fn env_wins_over_all_tiers() {
let _guard = ENV_LOCK.lock().unwrap();
// Safety: test-only, locked.
unsafe { std::env::set_var(ENV_SYSTEM_PROMPT_LABEL, "FromEnv") };
let got = resolve_system_prompt_label_from_tiers(
Some("PerModel".into()),
Some("Global".into()),
Some("GbPer".into()),
Some("GbGlobal".into()),
);
unsafe { std::env::remove_var(ENV_SYSTEM_PROMPT_LABEL) };
assert_eq!(got, "FromEnv");
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
}
@@ -0,0 +1,192 @@
use crate::util::config::RemoteSettings;
use toml::Value as TomlValue;
/// Env override for the **remember tool approvals** permission-panel gate.
pub(crate) const ENV_REMEMBER_TOOL_APPROVALS: &str = "KIGI_REMEMBER_TOOL_APPROVALS";
/// Extract the user knob `[ui] remember_tool_approvals` from one TOML layer.
fn remember_tool_approvals_from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("ui")?.get("remember_tool_approvals")?.as_bool()
}
/// Precedence core shared by the typed resolver and the disk reader so they
/// can't drift: requirement > env > config > managed > remote > default `false`.
fn resolve_remember_tool_approvals_layers(
requirement: Option<bool>,
config: Option<bool>,
managed: Option<bool>,
feature_flag: Option<bool>,
) -> crate::agent::config::Resolved<bool> {
use crate::agent::config::BoolFlag;
BoolFlag::env(ENV_REMEMBER_TOOL_APPROVALS)
.requirement(requirement)
.config(config)
.managed(managed)
.feature_flag(feature_flag)
.resolve()
}
/// Resolve whether the granular per-tool "Always allow …" prompt options are
/// shown. Precedence: requirements > env (`KIGI_REMEMBER_TOOL_APPROVALS`) >
/// `[ui].remember_tool_approvals` > managed > remote settings > default `false`.
pub fn resolve_remember_tool_approvals(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
remote: Option<&RemoteSettings>,
) -> crate::agent::config::Resolved<bool> {
resolve_remember_tool_approvals_layers(
remember_tool_approvals_from_toml(requirements),
remember_tool_approvals_from_toml(user),
remember_tool_approvals_from_toml(managed),
remote.and_then(|r| r.remember_tool_approvals),
)
}
/// Process-global cache of the remote tier, read by
/// [`remember_tool_approvals_from_disk`] at spawn (no live `RemoteSettings`
/// there). Fail-safe to `None` on lock poisoning.
static REMOTE_REMEMBER_TOOL_APPROVALS: std::sync::RwLock<Option<bool>> =
std::sync::RwLock::new(None);
/// Record the remote settings value; called when the agent applies `RemoteSettings`
/// (`agent::init` at startup, `MvpAgent` on refresh).
pub fn cache_remote_remember_tool_approvals(value: Option<bool>) {
if let Ok(mut guard) = REMOTE_REMEMBER_TOOL_APPROVALS.write() {
*guard = value;
}
}
fn cached_remote_remember_tool_approvals() -> Option<bool> {
REMOTE_REMEMBER_TOOL_APPROVALS.read().ok().and_then(|g| *g)
}
/// Free-function form of [`resolve_remember_tool_approvals`] for the
/// permission-manager spawn (no live `RemoteSettings`): env + requirements +
/// effective `config.toml` + cached remote tier. Defaults `false`.
pub fn remember_tool_approvals_from_disk() -> bool {
let requirements = crate::config::load_merged_requirements();
let effective = crate::config::load_effective_config().ok();
resolve_remember_tool_approvals_layers(
remember_tool_approvals_from_toml(requirements.as_ref()),
remember_tool_approvals_from_toml(effective.as_ref()),
None,
cached_remote_remember_tool_approvals(),
)
.value
}
#[cfg(test)]
mod remember_tool_approvals_gate_tests {
use super::*;
use crate::agent::config::ConfigSource;
// `KIGI_REMEMBER_TOOL_APPROVALS` is process-global; serialize and force it
// unset at the top of each test so a developer's shell value can't make
// these flaky.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe { std::env::remove_var(ENV_REMEMBER_TOOL_APPROVALS) };
g
}
fn toml_ui(v: bool) -> TomlValue {
toml::from_str(&format!("[ui]\nremember_tool_approvals = {v}\n")).unwrap()
}
fn remote(v: Option<bool>) -> RemoteSettings {
RemoteSettings {
remember_tool_approvals: v,
..RemoteSettings::default()
}
}
#[test]
fn defaults_off_when_nothing_set() {
let _g = guard();
let r = resolve_remember_tool_approvals(None, None, None, None);
assert!(!r.value, "gate must default OFF");
assert_eq!(r.source, ConfigSource::Default);
}
#[test]
fn each_layer_can_turn_it_on() {
let _g = guard();
let on = toml_ui(true);
// requirement
let r = resolve_remember_tool_approvals(Some(&on), None, None, None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Requirement);
// config (user)
let r = resolve_remember_tool_approvals(None, Some(&on), None, None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Config);
// managed
let r = resolve_remember_tool_approvals(None, None, Some(&on), None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
// remote settings
let r = resolve_remember_tool_approvals(None, None, None, Some(&remote(Some(true))));
assert!(r.value);
assert_eq!(r.source, ConfigSource::Remote);
}
#[test]
fn remote_kill_switch_reads_struct_field() {
let _g = guard();
let r = resolve_remember_tool_approvals(None, None, None, Some(&remote(Some(false))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Remote);
let r = resolve_remember_tool_approvals(None, None, None, Some(&remote(None)));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Default);
}
#[test]
fn precedence_config_beats_managed_beats_remote() {
let _g = guard();
let off = toml_ui(false);
let on = toml_ui(true);
let r =
resolve_remember_tool_approvals(None, Some(&off), Some(&on), Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Config);
let r = resolve_remember_tool_approvals(None, None, Some(&off), Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
}
#[test]
fn env_overrides_config_and_remote() {
let _g = guard();
unsafe { std::env::set_var(ENV_REMEMBER_TOOL_APPROVALS, "1") };
let off = toml_ui(false);
let r = resolve_remember_tool_approvals(None, Some(&off), None, Some(&remote(Some(false))));
assert!(r.value, "env must override config + remote");
assert_eq!(r.source, ConfigSource::Env);
unsafe { std::env::remove_var(ENV_REMEMBER_TOOL_APPROVALS) };
}
#[test]
fn requirement_beats_env() {
let _g = guard();
unsafe { std::env::set_var(ENV_REMEMBER_TOOL_APPROVALS, "1") };
let off = toml_ui(false);
let r = resolve_remember_tool_approvals(Some(&off), None, None, None);
assert!(!r.value, "requirement (managed/MDM floor) must beat env");
assert_eq!(r.source, ConfigSource::Requirement);
unsafe { std::env::remove_var(ENV_REMEMBER_TOOL_APPROVALS) };
}
#[test]
fn remote_cache_round_trips() {
let _g = guard();
cache_remote_remember_tool_approvals(Some(true));
assert_eq!(cached_remote_remember_tool_approvals(), Some(true));
cache_remote_remember_tool_approvals(Some(false));
assert_eq!(cached_remote_remember_tool_approvals(), Some(false));
cache_remote_remember_tool_approvals(None);
assert_eq!(cached_remote_remember_tool_approvals(), None);
}
}
@@ -0,0 +1,587 @@
use crate::util::config::RemoteSettings;
use kigi_tools::implementations::grok_build::ask_user_question;
use toml::Value as TomlValue;
/// Resolve whether the bash-harness `find`→`bfs` / `grep`→`ugrep` shadows are
/// enabled. Precedence (highest first): `requirements.toml` (org policy, wins
/// outright) > a truthy `DISABLE_EMBEDDED_SEARCH_TOOLS` master (forces off) > env
/// > `config.toml` `[toolset.bash]` > `managed_config.toml` > default-on. Env uses the shared
/// [`kigi_config::env_bool`] parser (`KIGI_TOOLS_FIND_BFS` /
/// `KIGI_TOOLS_GREP_UGREP`, plus the `KIGI_FIND_BFS` / `KIGI_GREP_UGREP` aliases).
///
/// Pass the **merged** requirements ([`crate::config::load_merged_requirements`])
/// so an org policy in any requirements layer — not only
/// `~/.kigi/requirements.toml` — is honored. Returns `(find_bfs, grep_ugrep)`,
/// which the caller bakes into a
/// [`kigi_tools::computer::local::SearchShadowConfig`] on the local terminal
/// backend.
pub fn resolve_search_tools_enabled(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
) -> (bool, bool) {
let disable = kigi_config::env_bool("DISABLE_EMBEDDED_SEARCH_TOOLS");
fn from_toml(v: Option<&TomlValue>, key: &str) -> Option<bool> {
v?.get("toolset")?.get("bash")?.get(key)?.as_bool()
}
let resolve = |primary: &str, alias: &str, key: &str| -> bool {
let env = kigi_config::env_bool(primary).or_else(|| kigi_config::env_bool(alias));
resolve_search_tool_enabled(
disable,
from_toml(requirements, key),
env,
from_toml(user, key),
from_toml(managed, key),
)
};
(
resolve("KIGI_TOOLS_FIND_BFS", "KIGI_FIND_BFS", "find_bfs"),
resolve("KIGI_TOOLS_GREP_UGREP", "KIGI_GREP_UGREP", "grep_ugrep"),
)
}
/// Pure precedence for [`resolve_search_tools_enabled`] (tiers injected so it is
/// unit-testable without env/disk): requirement (org policy) wins outright — even
/// over the user `DISABLE_*` master kill-switch — then the master forces off,
/// then env > config > managed > default-on.
fn resolve_search_tool_enabled(
disable: Option<bool>,
requirement: Option<bool>,
env: Option<bool>,
config: Option<bool>,
managed: Option<bool>,
) -> bool {
// Org policy (requirements.toml) is authoritative, so a user env kill-switch
// can't override an admin-forced value.
if let Some(required) = requirement {
return required;
}
if disable == Some(true) {
return false;
}
env.or(config).or(managed).unwrap_or(true)
}
const ENV_PERSISTENT_SHELL: &str = "KIGI_PERSISTENT_SHELL";
fn persistent_shell_from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("toolset")?
.get("bash")?
.get("persistent_shell")?
.as_bool()
}
pub fn resolve_persistent_local_shell(remote: Option<bool>) -> bool {
let requirements = crate::config::load_merged_requirements();
let layers = match crate::config::ConfigLayers::load() {
Ok(l) => Some(l),
Err(e) => {
tracing::warn!(error = %e, "persistent_local_shell: failed to load config layers");
None
}
};
resolve_persistent_local_shell_tiers(
requirements.as_ref(),
layers.as_ref().map(|l| &l.user),
layers.as_ref().map(|l| &l.managed),
layers.as_ref().map(|l| &l.system_managed),
remote,
)
}
fn resolve_persistent_local_shell_tiers(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
system_managed: Option<&TomlValue>,
remote: Option<bool>,
) -> bool {
use crate::agent::config::BoolFlag;
BoolFlag::env(ENV_PERSISTENT_SHELL)
.requirement(persistent_shell_from_toml(requirements))
.config(persistent_shell_from_toml(user))
.managed(
persistent_shell_from_toml(managed)
.or_else(|| persistent_shell_from_toml(system_managed)),
)
.feature_flag(remote)
.default(true)
.resolve()
.value
}
#[cfg(test)]
mod persistent_local_shell_tests {
use super::{ENV_PERSISTENT_SHELL, resolve_persistent_local_shell_tiers};
use toml::Value as TomlValue;
// KIGI_PERSISTENT_SHELL is process-global (the documented kill-switch a dev
// may export); serialize and force it unset so these tests can't go flaky.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe { std::env::remove_var(ENV_PERSISTENT_SHELL) };
g
}
fn cfg(persistent: bool) -> TomlValue {
toml::from_str(&format!(
"[toolset.bash]\npersistent_shell = {persistent}\n"
))
.unwrap()
}
#[test]
fn default_is_true() {
let _g = guard();
assert!(resolve_persistent_local_shell_tiers(
None, None, None, None, None
));
}
#[test]
fn remote_false_rolls_back() {
let _g = guard();
assert!(!resolve_persistent_local_shell_tiers(
None,
None,
None,
None,
Some(false)
));
}
#[test]
fn config_false_rolls_back() {
let _g = guard();
let off = cfg(false);
assert!(!resolve_persistent_local_shell_tiers(
None,
Some(&off),
None,
None,
None
));
}
#[test]
fn config_beats_remote() {
let _g = guard();
let on = cfg(true);
assert!(resolve_persistent_local_shell_tiers(
None,
Some(&on),
None,
None,
Some(false)
));
}
#[test]
fn requirement_overrides_remote() {
let _g = guard();
let on = cfg(true);
assert!(resolve_persistent_local_shell_tiers(
Some(&on),
None,
None,
None,
Some(false)
));
}
#[test]
fn managed_and_system_managed_apply_below_config() {
let _g = guard();
let off = cfg(false);
assert!(!resolve_persistent_local_shell_tiers(
None,
None,
Some(&off),
None,
None
));
assert!(!resolve_persistent_local_shell_tiers(
None,
None,
None,
Some(&off),
None
));
}
}
/// Env override for `[toolset.ask_user_question] timeout_enabled` (parsed by
/// the shared [`kigi_config::env_bool`] via `BoolFlag`). The secs env var
/// lives in the tools crate (`RESPONSE_TIMEOUT_ENV`), parsed once there.
const ENV_ASK_USER_QUESTION_TIMEOUT_ENABLED: &str = "KIGI_ASK_USER_QUESTION_TIMEOUT_ENABLED";
/// Extract `[toolset.ask_user_question] timeout_enabled` from one TOML layer.
fn ask_user_question_timeout_enabled_from_toml(v: Option<&TomlValue>) -> Option<bool> {
v?.get("toolset")?
.get("ask_user_question")?
.get("timeout_enabled")?
.as_bool()
}
/// Extract `[toolset.ask_user_question] timeout_secs` from one TOML layer.
/// Non-positive values are warned and dropped so the layer falls through —
/// `0` must never mean "wait forever"; that is `timeout_enabled = false`.
fn ask_user_question_timeout_secs_from_toml(v: Option<&TomlValue>) -> Option<u64> {
let raw = v?
.get("toolset")?
.get("ask_user_question")?
.get("timeout_secs")?
.as_integer()?;
let valid = u64::try_from(raw).ok().filter(|secs| *secs > 0);
if valid.is_none() {
tracing::warn!(
value = raw,
"[toolset.ask_user_question] timeout_secs must be a positive integer; ignoring layer"
);
}
valid
}
/// Resolve `[toolset.ask_user_question] timeout_enabled`.
///
/// Precedence: requirements > env (`KIGI_ASK_USER_QUESTION_TIMEOUT_ENABLED`)
/// > user `config.toml` > managed (user-level `managed_config.toml` over the
/// system-managed layer, matching `effective_config()`'s merge order) >
/// remote settings > default `true`. Returns [`Resolved`] so callers can
/// log the winning source.
///
/// [`Resolved`]: crate::agent::config::Resolved
fn resolve_ask_user_question_timeout_enabled(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
system_managed: Option<&TomlValue>,
remote: Option<bool>,
) -> crate::agent::config::Resolved<bool> {
use crate::agent::config::BoolFlag;
BoolFlag::env(ENV_ASK_USER_QUESTION_TIMEOUT_ENABLED)
.requirement(ask_user_question_timeout_enabled_from_toml(requirements))
.config(ask_user_question_timeout_enabled_from_toml(user))
.managed(
ask_user_question_timeout_enabled_from_toml(managed)
.or_else(|| ask_user_question_timeout_enabled_from_toml(system_managed)),
)
.feature_flag(remote)
.default(ask_user_question::DEFAULT_ASK_USER_QUESTION_TIMEOUT_ENABLED)
.resolve()
}
/// Pure precedence for [`resolve_ask_user_question_timeout_secs`] (tiers
/// injected so it is unit-testable without env/disk): requirements > env >
/// config > managed > remote > default (the tool's 30-minute `RESPONSE_TIMEOUT`).
fn resolve_ask_user_question_timeout_secs_from_tiers(
requirement: Option<u64>,
env: Option<u64>,
config: Option<u64>,
managed: Option<u64>,
remote: Option<u64>,
) -> u64 {
requirement
.or(env)
.or(config)
.or(managed)
.or(remote)
.unwrap_or(
kigi_tools::implementations::grok_build::ask_user_question::RESPONSE_TIMEOUT.as_secs(),
)
}
/// Resolve `[toolset.ask_user_question] timeout_secs` (positive seconds).
///
/// Precedence: requirements > env (`KIGI_ASK_USER_QUESTION_TIMEOUT_SECS`,
/// parsed by the tools crate's canonical parser) > user `config.toml` >
/// managed (user-level over system-managed, matching `effective_config()`) >
/// remote settings > default 1800 (30 minutes).
fn resolve_ask_user_question_timeout_secs(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
system_managed: Option<&TomlValue>,
remote: Option<u64>,
) -> u64 {
resolve_ask_user_question_timeout_secs_from_tiers(
ask_user_question_timeout_secs_from_toml(requirements),
kigi_tools::implementations::grok_build::ask_user_question::response_timeout_env_secs(),
ask_user_question_timeout_secs_from_toml(user),
ask_user_question_timeout_secs_from_toml(managed)
.or_else(|| ask_user_question_timeout_secs_from_toml(system_managed)),
// remote settings `0` is treated as unset, mirroring the TOML validation.
remote.filter(|secs| *secs > 0),
)
}
/// Resolve the full `[toolset.ask_user_question]` params injected into the
/// tool as `Params<AskUserQuestionParams>` at agent build/rebuild.
///
/// Reads the raw requirements / user / managed / system-managed layers from
/// disk (raw layers, not the effective merge, so a managed-only value stays
/// below env in the precedence); `remote` is the live remote tier. Both
/// fields resolve to concrete values, so the tool's legacy env fallback only
/// runs for consumers that skip this resolver.
pub(crate) fn resolve_ask_user_question_params_from_disk(
remote: Option<&RemoteSettings>,
) -> kigi_tools::implementations::grok_build::ask_user_question::AskUserQuestionParams {
let requirements = crate::config::load_merged_requirements();
let layers = match crate::config::ConfigLayers::load() {
Ok(l) => Some(l),
Err(e) => {
tracing::warn!(error = %e, "ask_user_question: failed to load config layers");
None
}
};
let user = layers.as_ref().map(|l| &l.user);
let managed = layers.as_ref().map(|l| &l.managed);
let system_managed = layers.as_ref().map(|l| &l.system_managed);
kigi_tools::implementations::grok_build::ask_user_question::AskUserQuestionParams {
timeout_enabled: Some(
resolve_ask_user_question_timeout_enabled(
requirements.as_ref(),
user,
managed,
system_managed,
remote.and_then(|r| r.ask_user_question_timeout_enabled),
)
.value,
),
timeout_secs: Some(resolve_ask_user_question_timeout_secs(
requirements.as_ref(),
user,
managed,
system_managed,
remote.and_then(|r| r.ask_user_question_timeout_secs),
)),
}
}
#[cfg(test)]
mod ask_user_question_timeout_tests {
use super::*;
use crate::agent::config::ConfigSource;
use kigi_tools::implementations::grok_build::ask_user_question::RESPONSE_TIMEOUT_ENV;
// Both env vars are process-global (a dev exports the secs var for TUI
// repro); serialize and force them unset so these tests can't go flaky.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
unsafe { std::env::remove_var(ENV_ASK_USER_QUESTION_TIMEOUT_ENABLED) };
unsafe { std::env::remove_var(RESPONSE_TIMEOUT_ENV) };
g
}
fn toml_ask(body: &str) -> TomlValue {
toml::from_str(&format!("[toolset.ask_user_question]\n{body}\n")).unwrap()
}
#[test]
fn timeout_enabled_tier_precedence() {
let _g = guard();
// Default ON when nothing is set.
let r = resolve_ask_user_question_timeout_enabled(None, None, None, None, None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Default);
// requirements(false) beat user(true) and remote(true).
let off = toml_ask("timeout_enabled = false");
let on = toml_ask("timeout_enabled = true");
let r = resolve_ask_user_question_timeout_enabled(
Some(&off),
Some(&on),
None,
None,
Some(true),
);
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Requirement);
// user(false) beats managed(true) and remote(true).
let r = resolve_ask_user_question_timeout_enabled(
None,
Some(&off),
Some(&on),
None,
Some(true),
);
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Config);
// managed(false) beats system-managed(true) and remote(true).
let r = resolve_ask_user_question_timeout_enabled(
None,
None,
Some(&off),
Some(&on),
Some(true),
);
assert!(!r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
// A system-managed-only value lands: beats remote, loses to user.
let r = resolve_ask_user_question_timeout_enabled(None, None, None, Some(&off), Some(true));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
let r = resolve_ask_user_question_timeout_enabled(
None,
Some(&on),
None,
Some(&off),
Some(false),
);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Config);
// remote alone is honored.
let r = resolve_ask_user_question_timeout_enabled(None, None, None, None, Some(false));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Remote);
}
#[test]
fn timeout_secs_tier_precedence() {
let d =
kigi_tools::implementations::grok_build::ask_user_question::RESPONSE_TIMEOUT.as_secs();
let r = resolve_ask_user_question_timeout_secs_from_tiers;
assert_eq!(r(None, None, None, None, None), d);
assert_eq!(r(Some(1), Some(2), Some(3), Some(4), Some(5)), 1); // requirements highest
assert_eq!(r(None, Some(2), Some(3), Some(4), Some(5)), 2); // env
assert_eq!(r(None, None, Some(3), Some(4), Some(5)), 3); // user config
assert_eq!(r(None, None, None, Some(4), Some(5)), 4); // managed
assert_eq!(r(None, None, None, None, Some(5)), 5); // remote
}
#[test]
fn timeout_secs_rejects_non_positive_layers() {
let _g = guard();
let d =
kigi_tools::implementations::grok_build::ask_user_question::RESPONSE_TIMEOUT.as_secs();
// user 0 and managed negative are dropped; remote fills the gap.
let zero = toml_ask("timeout_secs = 0");
let negative = toml_ask("timeout_secs = -5");
assert_eq!(
resolve_ask_user_question_timeout_secs(
None,
Some(&zero),
Some(&negative),
None,
Some(45)
),
45
);
// remote 0 is unset too → default.
assert_eq!(
resolve_ask_user_question_timeout_secs(None, Some(&zero), None, None, Some(0)),
d
);
// A valid user layer wins over remote.
let user = toml_ask("timeout_secs = 30");
assert_eq!(
resolve_ask_user_question_timeout_secs(None, Some(&user), None, None, Some(45)),
30
);
// A system-managed-only value lands: beats remote, loses to user.
let sys = toml_ask("timeout_secs = 90");
assert_eq!(
resolve_ask_user_question_timeout_secs(None, None, None, Some(&sys), Some(45)),
90
);
assert_eq!(
resolve_ask_user_question_timeout_secs(
None,
Some(&user),
Some(&zero),
Some(&sys),
None
),
30
);
}
}
#[cfg(test)]
mod tests {
use super::*;
// Assumes KIGI_TOOLS_* / DISABLE_EMBEDDED_SEARCH_TOOLS are unset in the test env.
#[test]
fn resolve_search_tools_enabled_layers_and_precedence() {
// Default-on when nothing is set.
assert_eq!(resolve_search_tools_enabled(None, None, None), (true, true));
// config.toml [toolset.bash] disables per tool.
let user: TomlValue =
toml::from_str("[toolset.bash]\nfind_bfs = false\ngrep_ugrep = true\n").unwrap();
assert_eq!(
resolve_search_tools_enabled(None, Some(&user), None),
(false, true)
);
// Precedence: requirements > config.toml > managed.
let req: TomlValue = toml::from_str("[toolset.bash]\nfind_bfs = true\n").unwrap();
let managed: TomlValue = toml::from_str("[toolset.bash]\ngrep_ugrep = false\n").unwrap();
let (find, grep) = resolve_search_tools_enabled(Some(&req), Some(&user), Some(&managed));
assert!(find); // requirements `true` beats user `false`
assert!(grep); // user `true` beats managed `false`
}
#[test]
fn resolve_search_tool_enabled_precedence() {
// args: disable, requirement, env, config, managed
assert!(resolve_search_tool_enabled(None, None, None, None, None)); // default on
// Org requirement wins outright — even over the user DISABLE kill-switch.
assert!(resolve_search_tool_enabled(
Some(true),
Some(true),
Some(false),
Some(false),
Some(false)
));
// With no requirement, a truthy DISABLE master forces off over env/config.
assert!(!resolve_search_tool_enabled(
Some(true),
None,
Some(true),
Some(true),
Some(true)
));
// falsey DISABLE is ignored.
assert!(resolve_search_tool_enabled(
Some(false),
None,
None,
None,
None
));
// requirement(false) forces off even when lower tiers say on.
assert!(!resolve_search_tool_enabled(
None,
Some(false),
Some(true),
Some(true),
Some(true)
));
// env beats config/managed.
assert!(!resolve_search_tool_enabled(
None,
None,
Some(false),
Some(true),
Some(true)
));
// config beats managed; managed is last before default.
assert!(!resolve_search_tool_enabled(
None,
None,
None,
Some(false),
Some(true)
));
assert!(!resolve_search_tool_enabled(
None,
None,
None,
None,
Some(false)
));
}
}
@@ -0,0 +1,442 @@
use crate::util::config::RemoteSettings;
use toml::Value as TomlValue;
/// Env override for showing agent thinking blocks in the TUI.
pub const ENV_SHOW_THINKING_BLOCKS: &str = "KIGI_SHOW_THINKING_BLOCKS";
#[cfg(test)]
static SHOW_THINKING_BLOCKS_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Shared precedence core for `[ui]` bool flags: requirement > env >
/// `[ui].<ui_key>` config > managed > remote (already extracted) > `default`.
fn resolve_ui_bool(
env_var: &str,
ui_key: &str,
default: bool,
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
remote_value: Option<bool>,
) -> crate::agent::config::Resolved<bool> {
use crate::agent::config::BoolFlag;
let from_toml =
|v: Option<&TomlValue>| -> Option<bool> { v?.get("ui")?.get(ui_key)?.as_bool() };
BoolFlag::env(env_var)
.requirement(from_toml(requirements))
.config(from_toml(user))
.managed(from_toml(managed))
.feature_flag(remote_value)
.default(default)
.resolve()
}
/// Resolve whether the TUI should show agent thinking/reasoning blocks.
///
/// Precedence: requirements > env (`KIGI_SHOW_THINKING_BLOCKS`) >
/// `[ui] show_thinking_blocks` > managed > remote settings > default `true`.
pub fn resolve_show_thinking_blocks(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
remote: Option<&RemoteSettings>,
) -> crate::agent::config::Resolved<bool> {
resolve_ui_bool(
ENV_SHOW_THINKING_BLOCKS,
"show_thinking_blocks",
true,
requirements,
user,
managed,
remote.and_then(|r| r.show_thinking_blocks),
)
}
/// Env override for grouping consecutive non-destructive tool calls in the TUI.
pub const ENV_GROUP_TOOL_VERBS: &str = "KIGI_GROUP_TOOL_VERBS";
#[cfg(test)]
static GROUP_TOOL_VERBS_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Resolve whether the TUI folds runs of consecutive non-destructive tool
/// calls (reads/searches/lists) into one transcript row.
///
/// Precedence: requirements > env (`KIGI_GROUP_TOOL_VERBS`) >
/// `[ui] group_tool_verbs` > managed > remote settings > default `true`
/// (remote `Some(false)` is the kill switch).
pub fn resolve_group_tool_verbs(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
remote: Option<&RemoteSettings>,
) -> crate::agent::config::Resolved<bool> {
resolve_ui_bool(
ENV_GROUP_TOOL_VERBS,
"group_tool_verbs",
true,
requirements,
user,
managed,
remote.and_then(|r| r.group_tool_verbs),
)
}
/// Env override for the collapsed-Edit-blocks default in the TUI.
pub const ENV_COLLAPSED_EDIT_BLOCKS: &str = "KIGI_COLLAPSED_EDIT_BLOCKS";
#[cfg(test)]
static COLLAPSED_EDIT_BLOCKS_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Resolve whether the TUI shows Edit tool calls as a collapsed one-line
/// `+N/-M` diffstat summary by default (expand for the diff).
///
/// Precedence: requirements > env (`KIGI_COLLAPSED_EDIT_BLOCKS`) >
/// `[ui] collapsed_edit_blocks` > managed > remote (GrowthBook) > default
/// `false` (rollout flag: off keeps the legacy expanded-diff view).
pub fn resolve_collapsed_edit_blocks(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
remote: Option<&RemoteSettings>,
) -> crate::agent::config::Resolved<bool> {
resolve_ui_bool(
ENV_COLLAPSED_EDIT_BLOCKS,
"collapsed_edit_blocks",
false,
requirements,
user,
managed,
remote.and_then(|r| r.collapsed_edit_blocks),
)
}
/// Resolve the opt-in mouse-reporting toggle shortcut flag.
///
/// When enabled, the pager registers `Ctrl+R` (scrollback-focused only) so the
/// user can flip terminal mouse capture and hand selection back to the terminal
/// for native click-drag copy/paste.
///
/// Precedence: `KIGI_MOUSE_REPORTING_TOGGLE` env > `[ui] mouse_reporting_toggle`
/// in effective config > the parsed [`UiConfig`] field (defends against a
/// partial deserialize) > default (`false`). Returns [`Resolved`] so callers can
/// log the winning source.
///
/// [`UiConfig`]: crate::agent::config::UiConfig
/// [`Resolved`]: crate::agent::config::Resolved
pub fn resolve_mouse_reporting_toggle(
effective_config: Option<&TomlValue>,
ui: &crate::agent::config::UiConfig,
) -> crate::agent::config::Resolved<bool> {
use crate::agent::config::BoolFlag;
let from_effective = effective_config
.and_then(|c| c.get("ui"))
.and_then(|ui| ui.get("mouse_reporting_toggle"))
.and_then(|v| v.as_bool());
BoolFlag::env("KIGI_MOUSE_REPORTING_TOGGLE")
.config(from_effective.or(ui.mouse_reporting_toggle))
.resolve()
}
#[cfg(test)]
mod tests {
use super::*;
// Assumes KIGI_MOUSE_REPORTING_TOGGLE is unset in the test env.
#[test]
fn resolve_mouse_reporting_toggle_defaults_off() {
use crate::agent::config::{ConfigSource, UiConfig};
let resolved = resolve_mouse_reporting_toggle(None, &UiConfig::default());
assert!(!resolved.value);
assert_eq!(resolved.source, ConfigSource::Default);
}
#[test]
fn resolve_mouse_reporting_toggle_reads_effective_config() {
use crate::agent::config::{ConfigSource, UiConfig};
let effective: TomlValue = toml::from_str("[ui]\nmouse_reporting_toggle = true\n").unwrap();
let resolved = resolve_mouse_reporting_toggle(Some(&effective), &UiConfig::default());
assert!(resolved.value);
assert_eq!(resolved.source, ConfigSource::Config);
}
#[test]
fn resolve_mouse_reporting_toggle_falls_back_to_ui_struct() {
use crate::agent::config::UiConfig;
let ui = UiConfig {
mouse_reporting_toggle: Some(true),
..UiConfig::default()
};
// No effective config → the parsed struct field is the fallback layer.
let resolved = resolve_mouse_reporting_toggle(None, &ui);
assert!(resolved.value);
}
}
#[cfg(test)]
mod show_thinking_blocks_tests {
use super::*;
use crate::agent::config::ConfigSource;
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = super::SHOW_THINKING_BLOCKS_ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
unsafe { std::env::remove_var(ENV_SHOW_THINKING_BLOCKS) };
g
}
fn toml_ui(v: bool) -> TomlValue {
toml::from_str(&format!("[ui]\nshow_thinking_blocks = {v}\n")).unwrap()
}
fn remote(v: Option<bool>) -> RemoteSettings {
RemoteSettings {
show_thinking_blocks: v,
..RemoteSettings::default()
}
}
#[test]
fn defaults_on_when_nothing_set() {
let _g = guard();
let r = resolve_show_thinking_blocks(None, None, None, None);
assert!(r.value, "thinking blocks must default ON");
assert_eq!(r.source, ConfigSource::Default);
}
#[test]
fn each_layer_can_turn_it_off() {
let _g = guard();
let off = toml_ui(false);
let r = resolve_show_thinking_blocks(Some(&off), None, None, None);
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Requirement);
let r = resolve_show_thinking_blocks(None, Some(&off), None, None);
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Config);
let r = resolve_show_thinking_blocks(None, None, Some(&off), None);
assert!(!r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
let r = resolve_show_thinking_blocks(None, None, None, Some(&remote(Some(false))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Remote);
}
#[test]
fn env_overrides_config_and_remote() {
let _g = guard();
unsafe { std::env::set_var(ENV_SHOW_THINKING_BLOCKS, "0") };
let on = toml_ui(true);
let r = resolve_show_thinking_blocks(None, Some(&on), None, Some(&remote(Some(true))));
assert!(!r.value, "env must override config + remote");
assert_eq!(r.source, ConfigSource::Env);
unsafe { std::env::remove_var(ENV_SHOW_THINKING_BLOCKS) };
}
#[test]
fn requirement_beats_env() {
let _g = guard();
unsafe { std::env::set_var(ENV_SHOW_THINKING_BLOCKS, "0") };
let on = toml_ui(true);
let r = resolve_show_thinking_blocks(Some(&on), None, None, None);
assert!(r.value, "requirement must beat env");
assert_eq!(r.source, ConfigSource::Requirement);
unsafe { std::env::remove_var(ENV_SHOW_THINKING_BLOCKS) };
}
#[test]
fn config_beats_managed_beats_remote() {
let _g = guard();
let off = toml_ui(false);
let on = toml_ui(true);
let r =
resolve_show_thinking_blocks(None, Some(&off), Some(&on), Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Config);
let r = resolve_show_thinking_blocks(None, None, Some(&off), Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
}
}
#[cfg(test)]
mod group_tool_verbs_tests {
use super::*;
use crate::agent::config::ConfigSource;
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = super::GROUP_TOOL_VERBS_ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
unsafe { std::env::remove_var(ENV_GROUP_TOOL_VERBS) };
g
}
fn toml_ui(v: bool) -> TomlValue {
toml::from_str(&format!("[ui]\ngroup_tool_verbs = {v}\n")).unwrap()
}
fn remote(v: Option<bool>) -> RemoteSettings {
RemoteSettings {
group_tool_verbs: v,
..RemoteSettings::default()
}
}
#[test]
fn defaults_on_when_nothing_set() {
let _g = guard();
let r = resolve_group_tool_verbs(None, None, None, None);
assert!(r.value, "tool-verb grouping must default ON");
assert_eq!(r.source, ConfigSource::Default);
}
#[test]
fn each_layer_can_turn_it_off() {
let _g = guard();
let off = toml_ui(false);
let r = resolve_group_tool_verbs(Some(&off), None, None, None);
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Requirement);
unsafe { std::env::set_var(ENV_GROUP_TOOL_VERBS, "0") };
let r = resolve_group_tool_verbs(None, None, None, None);
assert!(!r.value, "env disable must beat the true default");
assert_eq!(r.source, ConfigSource::Env);
unsafe { std::env::remove_var(ENV_GROUP_TOOL_VERBS) };
let r = resolve_group_tool_verbs(None, Some(&off), None, None);
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Config);
let r = resolve_group_tool_verbs(None, None, Some(&off), None);
assert!(!r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
let r = resolve_group_tool_verbs(None, None, None, Some(&remote(Some(false))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Remote);
}
#[test]
fn env_overrides_config_and_remote() {
let _g = guard();
unsafe { std::env::set_var(ENV_GROUP_TOOL_VERBS, "0") };
let on = toml_ui(true);
let r = resolve_group_tool_verbs(None, Some(&on), None, Some(&remote(Some(true))));
assert!(!r.value, "env must override config + remote");
assert_eq!(r.source, ConfigSource::Env);
unsafe { std::env::remove_var(ENV_GROUP_TOOL_VERBS) };
}
#[test]
fn requirement_beats_env() {
let _g = guard();
unsafe { std::env::set_var(ENV_GROUP_TOOL_VERBS, "0") };
let on = toml_ui(true);
let r = resolve_group_tool_verbs(Some(&on), None, None, None);
assert!(r.value, "requirement must beat env");
assert_eq!(r.source, ConfigSource::Requirement);
unsafe { std::env::remove_var(ENV_GROUP_TOOL_VERBS) };
}
#[test]
fn config_beats_managed_beats_remote() {
let _g = guard();
let off = toml_ui(false);
let on = toml_ui(true);
let r = resolve_group_tool_verbs(None, Some(&off), Some(&on), Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::Config);
let r = resolve_group_tool_verbs(None, None, Some(&off), Some(&remote(Some(true))));
assert!(!r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
}
}
#[cfg(test)]
mod collapsed_edit_blocks_tests {
use super::*;
use crate::agent::config::ConfigSource;
fn guard() -> std::sync::MutexGuard<'static, ()> {
let g = super::COLLAPSED_EDIT_BLOCKS_ENV_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
unsafe { std::env::remove_var(ENV_COLLAPSED_EDIT_BLOCKS) };
g
}
fn toml_ui(v: bool) -> TomlValue {
toml::from_str(&format!("[ui]\ncollapsed_edit_blocks = {v}\n")).unwrap()
}
fn remote(v: Option<bool>) -> RemoteSettings {
RemoteSettings {
collapsed_edit_blocks: v,
..RemoteSettings::default()
}
}
#[test]
fn defaults_off_when_nothing_set() {
let _g = guard();
let r = resolve_collapsed_edit_blocks(None, None, None, None);
assert!(!r.value, "collapsed edit blocks must default OFF");
assert_eq!(r.source, ConfigSource::Default);
}
#[test]
fn each_layer_can_turn_it_on() {
let _g = guard();
let on = toml_ui(true);
let r = resolve_collapsed_edit_blocks(Some(&on), None, None, None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Requirement);
unsafe { std::env::set_var(ENV_COLLAPSED_EDIT_BLOCKS, "1") };
let r = resolve_collapsed_edit_blocks(None, None, None, None);
assert!(r.value, "env enable must beat the false default");
assert_eq!(r.source, ConfigSource::Env);
unsafe { std::env::remove_var(ENV_COLLAPSED_EDIT_BLOCKS) };
let r = resolve_collapsed_edit_blocks(None, Some(&on), None, None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::Config);
let r = resolve_collapsed_edit_blocks(None, None, Some(&on), None);
assert!(r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
let r = resolve_collapsed_edit_blocks(None, None, None, Some(&remote(Some(true))));
assert!(r.value);
assert_eq!(r.source, ConfigSource::Remote);
}
#[test]
fn env_overrides_config_and_remote() {
let _g = guard();
unsafe { std::env::set_var(ENV_COLLAPSED_EDIT_BLOCKS, "0") };
let on = toml_ui(true);
let r = resolve_collapsed_edit_blocks(None, Some(&on), None, Some(&remote(Some(true))));
assert!(!r.value, "env must override config + remote");
assert_eq!(r.source, ConfigSource::Env);
unsafe { std::env::remove_var(ENV_COLLAPSED_EDIT_BLOCKS) };
}
#[test]
fn requirement_beats_env() {
let _g = guard();
unsafe { std::env::set_var(ENV_COLLAPSED_EDIT_BLOCKS, "1") };
let off = toml_ui(false);
let r = resolve_collapsed_edit_blocks(Some(&off), None, None, None);
assert!(!r.value, "requirement must beat env");
assert_eq!(r.source, ConfigSource::Requirement);
unsafe { std::env::remove_var(ENV_COLLAPSED_EDIT_BLOCKS) };
}
#[test]
fn config_beats_managed_beats_remote() {
let _g = guard();
let off = toml_ui(false);
let on = toml_ui(true);
let r =
resolve_collapsed_edit_blocks(None, Some(&on), Some(&off), Some(&remote(Some(false))));
assert!(r.value);
assert_eq!(r.source, ConfigSource::Config);
let r = resolve_collapsed_edit_blocks(None, None, Some(&on), Some(&remote(Some(false))));
assert!(r.value);
assert_eq!(r.source, ConfigSource::ManagedConfig);
}
}
@@ -0,0 +1,137 @@
use toml::Value as TomlValue;
/// Machine-readable channel name derived from the GCS stable pointer cache.
///
/// Reads `stable_version` from `~/.kigi/version.json` (written by the
/// auto-updater) and compares the compiled-in version against it:
/// - `Some("alpha")` when the current version is ahead of stable,
/// - `Some("stable")` when at or behind stable,
/// - `None` when no cached pointer is available (first launch, old cache).
///
/// This is a lightweight duplicate of `kigi_update::channel_name()` for
/// use in `kigi-shell` which cannot depend on `kigi-update`.
pub fn channel_name_from_cache() -> Option<&'static str> {
use std::sync::OnceLock;
static NAME: OnceLock<Option<&'static str>> = OnceLock::new();
*NAME.get_or_init(|| {
let version_path = crate::util::kigi_home::kigi_home().join("version.json");
let content = std::fs::read_to_string(&version_path).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&content).ok()?;
let stable = parsed.get("stable_version")?.as_str()?;
let current = semver::Version::parse(kigi_version::VERSION).ok()?;
let stable_v = semver::Version::parse(stable).ok()?;
if current > stable_v {
Some("alpha")
} else {
Some("stable")
}
})
}
/// Read the minimum-version floor from one TOML layer.
pub fn minimum_version_from_toml(root: &TomlValue) -> Option<String> {
root.get("cli")?
.get("minimum_version")?
.as_str()
.map(str::to_owned)
}
/// Semver-max across candidates. Fails closed on any unparseable input so a
/// typo in one layer can't silently disable enforcement.
pub fn pick_max_minimum_version(
candidates: &[&str],
) -> Result<Option<String>, (String, semver::Error)> {
let mut best: Option<semver::Version> = None;
for raw in candidates {
let parsed = semver::Version::parse(raw).map_err(|e| ((*raw).to_string(), e))?;
match best.as_ref() {
Some(cur) if cur >= &parsed => {}
_ => best = Some(parsed),
}
}
Ok(best.map(|v| v.to_string()))
}
/// Effective `cli.minimum_version`: semver-max across all layers so managed
/// floors can't be lowered by user/project pins.
pub fn resolve_minimum_version() -> Result<Option<String>, (String, semver::Error)> {
let layers = match crate::config::ConfigLayers::load() {
Ok(l) => l,
Err(e) => {
tracing::warn!(error = %e, "minimum_version: failed to load config layers");
return Ok(None);
}
};
resolve_minimum_version_from_layers(&layers)
}
/// Semver-max of `cli.minimum_version` across every layer (incl. the macOS MDM
/// floor) so a managed floor can't be lowered by a user/project pin. Split from
/// the disk load so the layer set can be injected in tests.
fn resolve_minimum_version_from_layers(
layers: &crate::config::ConfigLayers,
) -> Result<Option<String>, (String, semver::Error)> {
let candidates: Vec<String> = [
minimum_version_from_toml(&layers.system_managed),
minimum_version_from_toml(&layers.managed),
minimum_version_from_toml(&layers.user),
layers
.user_requirements
.as_ref()
.and_then(minimum_version_from_toml),
layers
.system_requirements
.as_ref()
.and_then(minimum_version_from_toml),
layers
.mdm_requirements
.as_ref()
.and_then(minimum_version_from_toml),
]
.into_iter()
.flatten()
.collect();
let refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
pick_max_minimum_version(&refs)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pick_max_minimum_version_picks_max_and_fails_closed_on_typos() {
assert_eq!(
pick_max_minimum_version(&["0.1.200", "0.1.100"])
.unwrap()
.as_deref(),
Some("0.1.200")
);
let (bad, _) = pick_max_minimum_version(&["not-a-version", "0.1.150"]).unwrap_err();
assert_eq!(bad, "not-a-version");
}
#[test]
fn minimum_version_includes_the_mdm_layer() {
// The MDM floor must win the semver-max so a managed minimum can't be
// lowered by a user pin.
let layers = crate::config::ConfigLayers {
system_managed: TomlValue::Table(Default::default()),
managed: TomlValue::Table(Default::default()),
user: toml::from_str("[cli]\nminimum_version = \"0.1.100\"\n").unwrap(),
user_requirements: None,
system_requirements: None,
mdm_requirements: Some(
toml::from_str("[cli]\nminimum_version = \"0.1.200\"\n").unwrap(),
),
..Default::default()
};
assert_eq!(
resolve_minimum_version_from_layers(&layers)
.unwrap()
.as_deref(),
Some("0.1.200"),
);
}
}
@@ -0,0 +1,282 @@
use super::persist::update_config;
use anyhow::Result;
// ---------------------------------------------------------------------------
// Settings helpers — typed disk-write wrappers for each setting.
// All route through `update_config` → `merge_section` → `save_config`.
// ---------------------------------------------------------------------------
/// Persist `[ui].compact_mode` via `update_config`.
pub async fn set_compact_mode(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.compact_mode = value).await
}
/// Persist `[ui].show_timestamps` via `update_config`. `UiConfig::show_timestamps`
/// is `Option<bool>` — pager-side `None` means "use default" — so we wrap.
pub async fn set_show_timestamps(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.show_timestamps = Some(value)).await
}
/// Persist `[ui].show_timeline` via `update_config`. Same `Option<bool>`
/// shape as `show_timestamps`.
pub async fn set_show_timeline(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.show_timeline = Some(value)).await
}
/// Persist `[ui].simple_mode` via `update_config`. Same `Option<bool>`
/// shape as `show_timestamps`.
pub async fn set_simple_mode(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.simple_mode = Some(value)).await
}
/// Persist `[ui.contextual_hints].undo` via `update_config`. The nested struct
/// stays out of `config.toml` until a tip is toggled (`skip_serializing_if`).
pub async fn set_contextual_hint_undo(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.contextual_hints.undo = Some(value)).await
}
/// Persist `[ui.contextual_hints].plan_mode` via `update_config`.
pub async fn set_contextual_hint_plan_mode(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.contextual_hints.plan_mode = Some(value)).await
}
/// Persist `[ui.contextual_hints].image_input` via `update_config`.
pub async fn set_contextual_hint_image_input(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.contextual_hints.image_input = Some(value)).await
}
/// Persist `[ui.contextual_hints].send_now` via `update_config`.
pub async fn set_contextual_hint_send_now(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.contextual_hints.send_now = Some(value)).await
}
/// Persist `[ui.contextual_hints].small_screen` via `update_config`.
pub async fn set_contextual_hint_small_screen(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.contextual_hints.small_screen = Some(value)).await
}
/// Persist `[ui.contextual_hints].word_select` via `update_config`.
pub async fn set_contextual_hint_word_select(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.contextual_hints.word_select = Some(value)).await
}
/// Persist `[ui].theme` via `update_config`. Caller must pass the
/// canonical theme name (`groknight`, `tokyonight`, `auto`, etc.).
pub async fn set_theme(value: String) -> Result<()> {
update_config(|cfg| cfg.ui.theme = Some(value)).await
}
/// Persist `[ui].auto_dark_theme` via `update_config`. `UiConfig::auto_dark_theme`
/// is `Option<String>` (canonical theme name; `auto` is rejected by the
/// pager's `load_auto_theme_config` filter at read time to prevent
/// circular reference).
pub async fn set_auto_dark_theme(value: String) -> Result<()> {
update_config(|cfg| cfg.ui.auto_dark_theme = Some(value)).await
}
/// Persist `[ui].auto_light_theme` via `update_config`. Same shape as
/// [`set_auto_dark_theme`].
pub async fn set_auto_light_theme(value: String) -> Result<()> {
update_config(|cfg| cfg.ui.auto_light_theme = Some(value)).await
}
/// Maximum length (in bytes) accepted by [`set_default_model`].
/// Defense against callers bypassing catalog validation.
pub const MAX_DEFAULT_MODEL_LEN: usize = 256;
/// Persist `[models].default` and dismiss any active campaign nudging it (an
/// explicit user pick wins over the soft campaign default).
///
/// This is the only sanctioned writer of `models.default`; it routes through
/// [`super::campaigns::persist_models_default`] so a user pick always dismisses
/// an active campaign. Do not persist `models.default` via raw `update_config`,
/// or a campaign would keep overriding the user's choice.
///
/// Caller must validate `value` against the model catalog first.
/// Empty string clears the field (falls back to remote/built-in default).
/// Length over [`MAX_DEFAULT_MODEL_LEN`] returns `Err`.
pub async fn set_default_model(value: String) -> Result<()> {
super::campaigns::persist_models_default(
if value.is_empty() { None } else { Some(value) },
None,
)
.await
}
/// Persist `[ui].fork_secondary_model` via `update_config`.
///
/// Caller must validate against the model catalog. Empty string
/// restores the built-in default. Length > [`MAX_DEFAULT_MODEL_LEN`] → `Err`.
pub async fn set_fork_secondary_model(value: String) -> Result<()> {
if value.len() > MAX_DEFAULT_MODEL_LEN {
anyhow::bail!(
"fork_secondary_model name too long ({} > {} bytes)",
value.len(),
MAX_DEFAULT_MODEL_LEN
);
}
update_config(|cfg| {
cfg.ui.fork_secondary_model = if value.is_empty() {
crate::models::default_model().to_string()
} else {
value
};
})
.await
}
/// Bounds for [`set_max_thoughts_width`]. Mirrored from the pager's
/// registry consts; a CI test pins the agreement.
const MAX_THOUGHTS_WIDTH_SHELL_MIN: i64 = 40;
const MAX_THOUGHTS_WIDTH_SHELL_MAX: i64 = 500;
/// Persist `[ui].max_thoughts_width` via `update_config`.
/// Defensively clamps to `[40, 500]` at the shell boundary.
pub async fn set_max_thoughts_width(value: i64) -> Result<()> {
let clamped = value.clamp(MAX_THOUGHTS_WIDTH_SHELL_MIN, MAX_THOUGHTS_WIDTH_SHELL_MAX) as u16;
update_config(|cfg| cfg.ui.max_thoughts_width = clamped).await
}
/// Persist `[ui].scroll_speed` via `update_config`.
/// Defensively clamps to `[1, 100]` at the shell boundary.
pub async fn set_scroll_speed(value: i64) -> Result<()> {
let clamped = value.clamp(1, 100) as u8;
update_config(|cfg| cfg.ui.scroll_speed = Some(clamped)).await
}
/// Persist `[ui].scroll_mode` (`auto` | `wheel` | `trackpad`) via `update_config`.
pub async fn set_scroll_mode(value: String) -> Result<()> {
update_config(|cfg| cfg.ui.scroll_mode = Some(value)).await
}
/// Persist `[ui].invert_scroll` via `update_config`.
pub async fn set_invert_scroll(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.invert_scroll = Some(value)).await
}
/// Persist `[ui.display_refresh].auto_cadence_enabled` via `update_config`.
/// Nested field only — does not replace the whole `display_refresh` object.
pub async fn set_display_refresh_auto_cadence(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.display_refresh.auto_cadence_enabled = Some(value)).await
}
/// Persist `[ui].scroll_lines` via `update_config`.
/// Defensively clamps to `[1, 10]` at the shell boundary.
pub async fn set_scroll_lines(value: i64) -> Result<()> {
let clamped = value.clamp(1, 10) as u8;
update_config(|cfg| cfg.ui.scroll_lines = Some(clamped)).await
}
/// Persist `[ui].vim_mode` via `update_config`.
pub async fn set_vim_mode(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.vim_mode = Some(value)).await
}
/// Persist `[ui].remember_tool_approvals` via `update_config`.
pub async fn set_remember_tool_approvals(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.remember_tool_approvals = Some(value)).await
}
/// Persist `[ui].show_thinking_blocks` via `update_config`.
pub async fn set_show_thinking_blocks(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.show_thinking_blocks = Some(value)).await
}
/// Persist `[ui].prompt_suggestions` via `update_config`.
pub async fn set_prompt_suggestions(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.prompt_suggestions = Some(value)).await
}
/// Persist `[toolset.ask_user_question].timeout_enabled` via `update_config`
/// (the user tier of the shell's tiered resolver; the effective value is
/// re-resolved at agent build).
pub async fn set_ask_user_question_timeout_enabled(value: bool) -> Result<()> {
update_config(|cfg| cfg.ask_user_question.timeout_enabled = Some(value)).await
}
/// Persist `[ui].group_tool_verbs` via `update_config`.
pub async fn set_group_tool_verbs(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.group_tool_verbs = Some(value)).await
}
/// Persist `[ui].collapsed_edit_blocks` via `update_config`.
pub async fn set_collapsed_edit_blocks(value: bool) -> Result<()> {
update_config(|cfg| cfg.ui.collapsed_edit_blocks = Some(value)).await
}
/// Persist `[ui].keep_text_selection` (`flash` | `hold` | `word_select`).
/// Clears the legacy `selection_highlight_duration_ms` and the retired
/// `double_click_action` keys it supersedes so the two can never drift (one-shot
/// disk migration away from the legacy key on any Settings write).
pub async fn set_keep_text_selection(value: String) -> Result<()> {
update_config(|cfg| {
cfg.ui.keep_text_selection = Some(value);
cfg.ui.selection_highlight_duration_ms = None;
cfg.ui.double_click_action = None;
})
.await
}
/// Persist `[ui].render_mermaid` via `update_config`. Value is one of the
/// canonical strings `auto` | `on` | `off`.
pub async fn set_render_mermaid(value: String) -> Result<()> {
update_config(|cfg| cfg.ui.render_mermaid = Some(value)).await
}
/// Persist `[ui].hunk_tracker_mode` via `update_config`. Value is one of the
/// canonical strings `agent_only` | `all_dirty` | `off`.
/// Restart-required: the mode is read once at connect time.
pub async fn set_hunk_tracker_mode(value: String) -> Result<()> {
update_config(|cfg| cfg.ui.hunk_tracker_mode = Some(value)).await
}
/// Persist `[ui].voice_capture_mode` via `update_config`. Value is one of the
/// canonical strings `toggle` | `hold`.
pub async fn set_voice_capture_mode(value: String) -> Result<()> {
update_config(|cfg| cfg.ui.voice_capture_mode = Some(value)).await
}
/// Persist `[ui].voice_stt_language` via `update_config`. Value is a canonical
/// language code from the settings catalog (`en`, `es`, …) or `auto` (system
/// locale, falling back to English).
pub async fn set_voice_stt_language(value: String) -> Result<()> {
update_config(|cfg| cfg.ui.voice_stt_language = Some(value)).await
}
/// Persist `[ui].default_selected_permission` via `update_config`. Value is
/// one of the canonical strings from `DEFAULT_SELECTED_PERMISSION_CHOICES`
/// (`default` | `allow_once` | `allow_always` | `reject`); `default` is the
/// "no preselection" sentinel.
pub async fn set_default_selected_permission(value: String) -> Result<()> {
update_config(|cfg| cfg.ui.default_selected_permission = Some(value)).await
}
/// Persist `[ui].cancel_subagents_on_turn_cancel` via `update_config`.
/// Canonical values: `ask` (clear / prompt each time), `always_stop`,
/// `always_continue`.
pub async fn set_cancel_subagents_on_turn_cancel(value: String) -> Result<()> {
update_config(|cfg| {
cfg.ui.cancel_subagents_on_turn_cancel = if value == "ask" { None } else { Some(value) };
})
.await
}
/// Persist `[ui].screen_mode` (`fullscreen` | `minimal`). Empty clears the key.
pub async fn set_screen_mode(value: String) -> Result<()> {
update_config(|cfg| {
cfg.ui.screen_mode = if value.is_empty() { None } else { Some(value) };
})
.await
}
/// Persist `[cli].show_tips` via `update_config`.
/// Restart-required: `resolve_tips` reads this once at startup.
pub async fn set_show_tips(value: bool) -> Result<()> {
update_config(|cfg| cfg.cli.show_tips = Some(value)).await
}
/// Persist `[cli].auto_update` via `update_config`.
/// Restart-required: auto-update check fires once on startup.
pub async fn set_auto_update(value: bool) -> Result<()> {
update_config(|cfg| cfg.cli.auto_update = Some(value)).await
}
@@ -0,0 +1,180 @@
use super::RemoteSettings;
use serde::Deserialize;
use toml::Value as TomlValue;
/// Read `[cli] show_tips` from config.toml. Returns `None` if not set.
/// When `Some(false)`, the tip-of-the-day is suppressed on startup.
pub fn show_tips_from_toml_opt(root: &TomlValue) -> Option<bool> {
if let TomlValue::Table(table) = root
&& let Some(TomlValue::Table(cli)) = table.get("cli")
{
cli.get("show_tips").and_then(|v| v.as_bool())
} else {
None
}
}
/// Local `[tips]` config section.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct TipsOverride {
pub tips: Vec<String>,
/// When true, drop remote/default tips entirely.
pub exclude_default: bool,
}
/// Parse `[tips]` from a TOML value.
pub fn tips_from_toml(root: &TomlValue) -> Option<TipsOverride> {
root.get("tips")?.clone().try_into::<TipsOverride>().ok()
}
/// Merge tip sources in priority order.
///
/// If any local source sets `exclude_default = true`, remote tips are dropped entirely.
/// Otherwise remote tips are inserted after requirements and before user/managed config.
pub fn merge_tips(
requirements: Option<TipsOverride>,
user: Option<TipsOverride>,
managed: Option<TipsOverride>,
remote_tips: Option<&[String]>,
) -> Vec<String> {
let exclude = [&requirements, &user, &managed]
.into_iter()
.flatten()
.any(|s| s.exclude_default);
let mut out = Vec::new();
if let Some(src) = requirements.as_ref() {
out.extend(src.tips.iter().cloned());
}
if !exclude && let Some(remote) = remote_tips {
out.extend(remote.iter().cloned());
}
if let Some(src) = user.as_ref() {
out.extend(src.tips.iter().cloned());
}
if let Some(src) = managed.as_ref() {
out.extend(src.tips.iter().cloned());
}
out
}
/// Resolve the merged tip list from pre-loaded config layers.
///
/// Priority: requirements > remote > user config > managed config.
/// `KIGI_TIPS_OVERRIDE` env var overrides everything (debug builds only).
/// `[cli] show_tips = false` in requirements or user config kills all tips.
pub fn resolve_tips(
requirements: Option<&TomlValue>,
user: Option<&TomlValue>,
managed: Option<&TomlValue>,
remote_tips: Option<&[String]>,
) -> Vec<String> {
if requirements.and_then(show_tips_from_toml_opt) == Some(false) {
return Vec::new();
}
if user.and_then(show_tips_from_toml_opt) == Some(false) {
return Vec::new();
}
#[cfg(debug_assertions)]
if let Ok(raw) = std::env::var("KIGI_TIPS_OVERRIDE") {
return raw.split('|').map(str::to_string).collect();
}
let req = requirements.and_then(tips_from_toml);
let usr = user.and_then(tips_from_toml);
let mgd = managed.and_then(tips_from_toml);
// Priority: requirements > remote > user > managed.
merge_tips(req, usr, mgd, remote_tips)
}
/// Convenience wrapper that loads config layers from disk and picks one tip.
/// Prefer [`resolve_tips`] when layers are already loaded.
pub fn resolve_tips_from_disk(
raw_config: &TomlValue,
remote_settings: Option<&RemoteSettings>,
kigi_home: &std::path::Path,
) -> Option<String> {
let requirements = crate::config::load_merged_requirements();
let managed = crate::config::load_managed_config().ok();
let remote = remote_settings.and_then(|s| s.tips.as_deref());
let all = resolve_tips(
requirements.as_ref(),
Some(raw_config),
managed.as_ref(),
remote,
);
if all.is_empty() {
return None;
}
crate::util::tips::pick_and_advance(&all, kigi_home)
}
/// Read `[cli] channel` from config.toml.
/// Returns `None` when absent (falls through to remote settings).
pub fn channel_from_toml_opt(root: &TomlValue) -> Option<String> {
if let TomlValue::Table(table) = root
&& let Some(TomlValue::Table(cli)) = table.get("cli")
{
cli.get("channel")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::RemoteSettings;
use super::*;
use toml::Value as TomlValue;
#[test]
fn show_tips_defaults_to_none() {
let config = TomlValue::Table(toml::map::Map::new());
assert_eq!(show_tips_from_toml_opt(&config), None);
}
#[test]
fn show_tips_reads_false() {
let config: TomlValue = toml::from_str("[cli]\nshow_tips = false").unwrap();
assert_eq!(show_tips_from_toml_opt(&config), Some(false));
}
#[test]
fn show_tips_reads_true() {
let config: TomlValue = toml::from_str("[cli]\nshow_tips = true").unwrap();
assert_eq!(show_tips_from_toml_opt(&config), Some(true));
}
#[test]
fn remote_settings_tips_absent() {
let json = r#"{}"#;
let s: RemoteSettings = serde_json::from_str(json).unwrap();
assert_eq!(s.tips, None);
}
#[test]
fn remote_settings_tips_null() {
let json = r#"{"tips": null}"#;
let s: RemoteSettings = serde_json::from_str(json).unwrap();
assert_eq!(s.tips, None);
}
#[test]
fn remote_settings_tips_empty() {
let json = r#"{"tips": []}"#;
let s: RemoteSettings = serde_json::from_str(json).unwrap();
assert_eq!(s.tips, Some(vec![]));
}
#[test]
fn remote_settings_tips_populated() {
let json = r#"{"tips": ["a", "b"]}"#;
let s: RemoteSettings = serde_json::from_str(json).unwrap();
assert_eq!(s.tips, Some(vec!["a".to_string(), "b".to_string()]));
}
}
@@ -0,0 +1,379 @@
use super::RemoteSettings;
use super::mcp::use_leader_from_toml;
use kigi_fast_worktree::CreationMode;
use serde::{Deserialize, Serialize};
use toml::Value as TomlValue;
/// Worktree creation type configuration.
///
/// Mirrors the internal `CreationMode` enum from kigi-fast-worktree but uses
/// config-friendly naming (lowercase strings in TOML).
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum WorktreeType {
/// Linked worktree via `git worktree add --no-checkout` + parallel CoW copy.
/// This is the fastest mode for large repos.
#[default]
Linked,
/// Standalone repository copy with independent `.git/` directory.
/// Can be promoted to replace the source via `rename()`.
Standalone,
/// Plain `git worktree add` with full checkout.
Git,
}
impl std::str::FromStr for WorktreeType {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"linked" => Ok(Self::Linked),
"standalone" => Ok(Self::Standalone),
"git" => Ok(Self::Git),
_ => Err(()),
}
}
}
impl From<WorktreeType> for CreationMode {
fn from(t: WorktreeType) -> Self {
match t {
WorktreeType::Linked => CreationMode::Linked,
WorktreeType::Standalone => CreationMode::Standalone,
WorktreeType::Git => CreationMode::GitCheckout,
}
}
}
/// Returns `Some(type)` when `[cli] worktree_type` is set to a valid value in config.toml,
/// `None` when absent or the value type is wrong. Logs a warning for invalid strings.
pub fn worktree_type_from_toml_opt(root: &TomlValue) -> Option<WorktreeType> {
if let TomlValue::Table(table) = root
&& let Some(TomlValue::Table(cli)) = table.get("cli")
&& let Some(toml_value) = cli.get("worktree_type")
{
if let Some(type_str) = toml_value.as_str() {
return match type_str.parse::<WorktreeType>() {
Ok(wt) => Some(wt),
Err(()) => {
tracing::warn!("Invalid worktree_type value in config: {type_str}, ignoring");
None
}
};
}
tracing::warn!("Invalid worktree_type value in config: {toml_value:?}, ignoring");
}
None
}
/// Get the worktree type from config.toml.
///
/// Set in config.toml under [cli] as `worktree_type = "linked|standalone|git"`.
/// Defaults to `WorktreeType::Linked` when not explicitly set.
pub fn worktree_type_from_toml(root: &TomlValue) -> WorktreeType {
worktree_type_from_toml_opt(root).unwrap_or_default()
}
/// Resolve worktree type: local config > remote settings > default (`Linked`).
///
/// Returns the resolved type and its provenance (`"local"`, `"remote"`, or `"default"`).
pub fn resolve_worktree_type(
raw_config: &TomlValue,
remote: Option<&RemoteSettings>,
) -> (WorktreeType, &'static str) {
if let Some(wt) = worktree_type_from_toml_opt(raw_config) {
return (wt, "local");
}
if let Some(s) = remote.and_then(|r| r.worktree_type.as_deref()) {
match s.parse::<WorktreeType>() {
Ok(wt) => return (wt, "remote"),
Err(()) => {
tracing::warn!("Invalid remote worktree_type: {s}, using default");
}
}
}
(WorktreeType::default(), "default")
}
/// Synchronously get the worktree type from the config file.
pub fn worktree_type() -> WorktreeType {
let root: TomlValue = match crate::config::load_effective_config() {
Ok(r) => r,
Err(_) => return WorktreeType::Linked,
};
worktree_type_from_toml(&root)
}
/// Returns `Some(value)` when `[cli] restore_code` is set as a boolean in config.toml.
pub fn restore_code_from_toml(root: &TomlValue) -> Option<bool> {
root.get("cli")
.and_then(|c| c.get("restore_code"))
.and_then(|v| v.as_bool())
}
/// Resolve restore_code: local config > remote settings > default (`false`).
pub fn resolve_restore_code(raw_config: &TomlValue, remote: Option<&RemoteSettings>) -> bool {
restore_code_from_toml(raw_config)
.or(remote.and_then(|r| r.restore_code))
.unwrap_or(false)
}
/// Synchronously check if leader mode is enabled in the config file.
/// When true, the agent will connect to a shared leader process instead of
/// running the agent directly. This allows multiple agent instances to share one backend.
/// Defaults to false when not explicitly set.
pub fn use_leader_sync() -> bool {
let root: TomlValue = match crate::config::load_effective_config() {
Ok(r) => r,
Err(_) => return false,
};
use_leader_from_toml(&root)
}
#[cfg(test)]
mod tests {
use super::RemoteSettings;
use super::*;
use toml::Value as TomlValue;
#[test]
fn test_worktree_type_linked() {
let toml_str = r#"
[cli]
worktree_type = "linked"
"#;
let root: TomlValue = toml::from_str(toml_str).unwrap();
assert_eq!(worktree_type_from_toml(&root), WorktreeType::Linked);
}
#[test]
fn test_worktree_type_standalone() {
let toml_str = r#"
[cli]
worktree_type = "standalone"
"#;
let root: TomlValue = toml::from_str(toml_str).unwrap();
assert_eq!(worktree_type_from_toml(&root), WorktreeType::Standalone);
}
#[test]
fn test_worktree_type_git() {
let toml_str = r#"
[cli]
worktree_type = "git"
"#;
let root: TomlValue = toml::from_str(toml_str).unwrap();
assert_eq!(worktree_type_from_toml(&root), WorktreeType::Git);
}
#[test]
fn test_worktree_type_default_linked() {
let toml_str = r#"
[cli]
auto_update = true
"#;
let root: TomlValue = toml::from_str(toml_str).unwrap();
assert_eq!(worktree_type_from_toml(&root), WorktreeType::Linked);
}
#[test]
fn test_worktree_type_no_cli_section() {
let toml_str = r#"
[models]
default = "grok-code-fast-1"
"#;
let root: TomlValue = toml::from_str(toml_str).unwrap();
assert_eq!(worktree_type_from_toml(&root), WorktreeType::Linked);
}
#[test]
fn test_worktree_type_invalid_value() {
let toml_str = r#"
[cli]
worktree_type = "invalid"
"#;
let root: TomlValue = toml::from_str(toml_str).unwrap();
// Invalid values should fall back to default
assert_eq!(worktree_type_from_toml(&root), WorktreeType::Linked);
}
#[test]
fn test_worktree_type_fromstr() {
assert_eq!("linked".parse::<WorktreeType>(), Ok(WorktreeType::Linked));
assert_eq!(
"standalone".parse::<WorktreeType>(),
Ok(WorktreeType::Standalone)
);
assert_eq!("git".parse::<WorktreeType>(), Ok(WorktreeType::Git));
assert!("invalid".parse::<WorktreeType>().is_err());
assert!("LINKED".parse::<WorktreeType>().is_err());
}
#[test]
fn test_worktree_type_from_toml_opt_present() {
let root: TomlValue = toml::from_str("[cli]\nworktree_type = \"standalone\"").unwrap();
assert_eq!(
worktree_type_from_toml_opt(&root),
Some(WorktreeType::Standalone)
);
}
#[test]
fn test_worktree_type_from_toml_opt_absent() {
let root: TomlValue = toml::from_str("[cli]\nauto_update = true").unwrap();
assert_eq!(worktree_type_from_toml_opt(&root), None);
}
#[test]
fn test_worktree_type_from_toml_opt_invalid() {
let root: TomlValue = toml::from_str("[cli]\nworktree_type = \"bogus\"").unwrap();
assert_eq!(worktree_type_from_toml_opt(&root), None);
}
#[test]
fn test_worktree_type_from_toml_opt_no_cli_section() {
let root: TomlValue = toml::from_str("[models]\ndefault = \"grok\"").unwrap();
assert_eq!(worktree_type_from_toml_opt(&root), None);
}
#[test]
fn test_resolve_worktree_type_local_wins_over_remote() {
let root: TomlValue = toml::from_str("[cli]\nworktree_type = \"git\"").unwrap();
let remote = RemoteSettings {
worktree_type: Some("standalone".to_owned()),
..Default::default()
};
assert_eq!(
resolve_worktree_type(&root, Some(&remote)),
(WorktreeType::Git, "local")
);
}
#[test]
fn test_resolve_worktree_type_remote_fallback() {
let root: TomlValue = toml::from_str("[cli]\nauto_update = true").unwrap();
let remote = RemoteSettings {
worktree_type: Some("standalone".to_owned()),
..Default::default()
};
assert_eq!(
resolve_worktree_type(&root, Some(&remote)),
(WorktreeType::Standalone, "remote")
);
}
#[test]
fn test_resolve_worktree_type_default_when_no_config() {
let root: TomlValue = toml::from_str("[cli]\nauto_update = true").unwrap();
assert_eq!(
resolve_worktree_type(&root, None),
(WorktreeType::Linked, "default")
);
}
#[test]
fn test_resolve_worktree_type_invalid_remote_falls_back_to_default() {
let root: TomlValue = toml::from_str("[cli]\nauto_update = true").unwrap();
let remote = RemoteSettings {
worktree_type: Some("bogus".to_owned()),
..Default::default()
};
assert_eq!(
resolve_worktree_type(&root, Some(&remote)),
(WorktreeType::Linked, "default")
);
}
#[test]
fn test_resolve_worktree_type_remote_none_field() {
let root: TomlValue = toml::from_str("[cli]\nauto_update = true").unwrap();
let remote = RemoteSettings {
worktree_type: None,
..Default::default()
};
assert_eq!(
resolve_worktree_type(&root, Some(&remote)),
(WorktreeType::Linked, "default")
);
}
// === restore_code config tests ===
#[test]
fn test_restore_code_from_toml_present_true() {
let root: TomlValue = toml::from_str("[cli]\nrestore_code = true").unwrap();
assert_eq!(restore_code_from_toml(&root), Some(true));
}
#[test]
fn test_restore_code_from_toml_present_false() {
let root: TomlValue = toml::from_str("[cli]\nrestore_code = false").unwrap();
assert_eq!(restore_code_from_toml(&root), Some(false));
}
#[test]
fn test_restore_code_from_toml_absent() {
let root: TomlValue = toml::from_str("[cli]\nauto_update = true").unwrap();
assert_eq!(restore_code_from_toml(&root), None);
}
#[test]
fn test_restore_code_from_toml_no_cli_section() {
let root: TomlValue = toml::from_str("[models]\ndefault = \"grok\"").unwrap();
assert_eq!(restore_code_from_toml(&root), None);
}
#[test]
fn test_restore_code_from_toml_wrong_type() {
let root: TomlValue = toml::from_str("[cli]\nrestore_code = \"yes\"").unwrap();
assert_eq!(restore_code_from_toml(&root), None);
}
#[test]
fn test_resolve_restore_code_local_wins_over_remote() {
let root: TomlValue = toml::from_str("[cli]\nrestore_code = true").unwrap();
let remote = RemoteSettings {
restore_code: Some(false),
..Default::default()
};
assert!(resolve_restore_code(&root, Some(&remote)));
}
#[test]
fn test_resolve_restore_code_remote_fallback() {
let root: TomlValue = toml::from_str("[cli]\nauto_update = true").unwrap();
let remote = RemoteSettings {
restore_code: Some(true),
..Default::default()
};
assert!(resolve_restore_code(&root, Some(&remote)));
}
#[test]
fn test_resolve_restore_code_default_false() {
let root: TomlValue = toml::from_str("[cli]\nauto_update = true").unwrap();
assert!(!resolve_restore_code(&root, None));
}
#[test]
fn test_resolve_restore_code_remote_none_falls_to_default() {
let root: TomlValue = toml::from_str("[cli]\nauto_update = true").unwrap();
let remote = RemoteSettings {
restore_code: None,
..Default::default()
};
assert!(!resolve_restore_code(&root, Some(&remote)));
}
#[test]
fn test_resolve_restore_code_local_false_overrides_remote_true() {
let root: TomlValue = toml::from_str("[cli]\nrestore_code = false").unwrap();
let remote = RemoteSettings {
restore_code: Some(true),
..Default::default()
};
assert!(!resolve_restore_code(&root, Some(&remote)));
}
// === minimum_version tests ===
}
@@ -0,0 +1,192 @@
use reqwest::RequestBuilder;
use std::sync::Arc;
/// Credentials for authenticating with grok backend services.
///
/// Two construction modes:
/// - `with_auth_manager(am)` — live mode. `resolve_async()` drives
/// `AuthManager::get_valid_token()` (memory -> disk -> OIDC refresh).
/// - `new(token)` — static mode. For one-shot callers that don't have
/// an `AuthManager` (visibility checks, bundle fetches, tests).
///
/// Deployment key (enterprise) sends bare `Bearer`, routed to management key auth.
/// User token (xAI users) sends `Bearer` + `X-XAI-Token-Auth: xai-grok-cli`.
/// Deployment key takes precedence when both are present.
#[derive(Clone)]
pub struct GrokAuthCredentials {
pub user_token: Option<String>,
pub deployment_key: Option<String>,
pub alpha_test_key: Option<String>,
/// Live auth source. When set, `resolve_async()` drives the full
/// refresh chain; `resolve()` reads the in-memory cache.
auth_manager: Option<Arc<crate::auth::AuthManager>>,
}
impl std::fmt::Debug for GrokAuthCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GrokAuthCredentials")
.field(
"user_token",
&self.user_token.as_ref().map(|_| "<redacted>"),
)
.field(
"deployment_key",
&self.deployment_key.as_ref().map(|_| "<redacted>"),
)
.field(
"mode",
&if self.auth_manager.is_some() {
"live"
} else {
"static"
},
)
.finish()
}
}
impl GrokAuthCredentials {
/// Static credentials from a snapshot token. No refresh capability.
pub fn new(user_token: Option<String>) -> Self {
Self {
user_token,
deployment_key: None,
alpha_test_key: None,
auth_manager: None,
}
}
/// Live credentials backed by an `AuthManager`. `resolve_async()`
/// drives memory -> disk -> OIDC refresh; `resolve()` reads the
/// in-memory cache for sync contexts.
pub fn with_auth_manager(mut self, am: Arc<crate::auth::AuthManager>) -> Self {
self.auth_manager = Some(am);
self
}
/// Return a reference to the internal `AuthManager`, if any.
pub fn auth_manager(&self) -> Option<&Arc<crate::auth::AuthManager>> {
self.auth_manager.as_ref()
}
/// Error hint for 401 responses, based on which credential was sent.
pub fn auth_error_hint(&self) -> &'static str {
if self.deployment_key.is_some() {
"Your KIGI_DEPLOYMENT_KEY is invalid or expired. Please contact a team admin."
} else if self.user_token.is_some() {
"Your auth token is invalid or expired. Run `grok login` to re-authenticate."
} else {
"Not authenticated."
}
}
/// Return a snapshot with the live token from the internal `AuthManager`
/// if available, falling back to the static `user_token`.
///
/// Uses `current_or_expired()` instead of `current()` so that a token
/// in the early-invalidation refresh window (expired for proactive
/// refresh but still accepted by the server) is still returned.
/// Without this, the `resolve_async()` error fallback returns
/// credentials with no token, causing requests to be sent without
/// an Authorization header.
pub fn resolve(&self) -> GrokAuthCredentials {
if let Some(ref am) = self.auth_manager
&& let Some(auth) = am.current_or_expired()
{
let mut creds = self.clone();
creds.user_token = Some(auth.key);
creds
} else {
self.clone()
}
}
/// Async resolve via the internal `AuthManager::get_valid_token()`
/// (memory -> disk -> active OIDC refresh). Falls back to sync
/// `resolve()` on error so transient refresh failures don't drop
/// the bearer.
pub async fn resolve_async(&self) -> GrokAuthCredentials {
let Some(ref am) = self.auth_manager else {
return self.clone();
};
match am.get_valid_token().await {
Ok(key) => {
let mut creds = self.clone();
creds.user_token = Some(key);
creds
}
Err(e) => {
tracing::warn!(
error = % e,
"resolve_credentials_async: active resolve failed, using cached"
);
self.resolve()
}
}
}
pub fn apply(&self, builder: RequestBuilder, base_url: &str) -> RequestBuilder {
let builder = if let Some(ref key) = self.deployment_key {
builder.header("Authorization", format!("Bearer {}", key))
} else if let Some(ref token) = self.user_token {
builder
.header("Authorization", format!("Bearer {}", token))
.header(
obfstr::obfstr!("X-XAI-Token-Auth"),
obfstr::obfstr!("xai-grok-cli"),
)
} else {
builder
};
let _ = base_url;
builder
}
}
impl kigi_auth::HttpAuth for GrokAuthCredentials {
fn apply(&self, builder: RequestBuilder, base_url: &str) -> RequestBuilder {
GrokAuthCredentials::apply(self, builder, base_url)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{AuthManager, AuthMode, GrokAuth, GrokComConfig};
use chrono::{Duration, Utc};
use std::sync::Arc;
fn make_manager_with_token(
expires_at: chrono::DateTime<Utc>,
) -> (Arc<AuthManager>, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
let auth = GrokAuth {
key: "test-bearer-token".into(),
auth_mode: AuthMode::External,
expires_at: Some(expires_at),
create_time: Utc::now(),
..GrokAuth::test_default()
};
mgr.hot_swap(auth);
(mgr, dir)
}
#[test]
fn resolve_returns_token_when_not_expired() {
let (mgr, _dir) = make_manager_with_token(Utc::now() + Duration::hours(1));
let creds = GrokAuthCredentials::new(None).with_auth_manager(mgr);
let resolved = creds.resolve();
assert_eq!(resolved.user_token.as_deref(), Some("test-bearer-token"));
}
#[test]
fn resolve_returns_token_during_early_invalidation_window() {
let (mgr, _dir) = make_manager_with_token(Utc::now() + Duration::minutes(3));
let creds = GrokAuthCredentials::new(None).with_auth_manager(mgr.clone());
assert!(mgr.current().is_none());
assert!(mgr.current_or_expired().is_some());
assert_eq!(
creds.resolve().user_token.as_deref(),
Some("test-bearer-token")
);
}
#[test]
fn resolve_returns_static_token_when_no_auth_manager() {
let creds = GrokAuthCredentials::new(Some("static-token".into()));
assert_eq!(creds.resolve().user_token.as_deref(), Some("static-token"));
}
#[test]
fn resolve_returns_none_when_no_token_at_all() {
let dir = tempfile::tempdir().unwrap();
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
let creds = GrokAuthCredentials::new(None).with_auth_manager(mgr);
assert!(creds.resolve().user_token.is_none());
}
}
+117
View File
@@ -0,0 +1,117 @@
//! Shared hook source path discovery.
use std::path::{Path, PathBuf};
use kigi_hooks::discovery::HookSource;
/// Owned paths for hook sources. Callers borrow via `as_sources()`.
pub struct HookSourcePaths {
pub global: Vec<PathBuf>,
pub project: Vec<PathBuf>,
}
impl HookSourcePaths {
/// Borrow as `HookSource` refs. Project sources are excluded when untrusted.
pub fn as_sources(&self, include_project: bool) -> (Vec<HookSource<'_>>, Vec<HookSource<'_>>) {
let global = self.global.iter().map(|p| path_to_source(p)).collect();
let project = if include_project {
self.project.iter().map(|p| path_to_source(p)).collect()
} else {
vec![]
};
(global, project)
}
}
fn path_to_source(p: &Path) -> HookSource<'_> {
if p.is_dir() {
HookSource::Directory(p)
} else {
HookSource::SettingsFile(p)
}
}
/// Build hook source paths for global (`~/`) and project (`<git_root>/`) scopes.
/// Callers gate project sources on trust via `as_sources(trusted)`.
pub fn discover_hook_source_paths(
git_root: Option<&Path>,
compat: &kigi_tools::types::compat::CompatConfig,
) -> HookSourcePaths {
// Compat gate: skip .claude hook sources when disabled.
let skip_claude_compat = !compat.claude.hooks;
// Phase 2 cutoff: if the user has imported, skip .claude/settings.json
// sources. Native .kigi/hooks/ directories are still scanned (they hold
// any hooks that were imported by /import-claude).
let skip_claude = skip_claude_compat
|| crate::claude_import::is_claude_import_marked_with_log("discover_hook_source_paths");
// Compat gate: skip Cursor hook sources when disabled.
let skip_cursor = !compat.cursor.hooks;
let home = dirs::home_dir();
// user_kigi_home() is None when no home resolves, so inspect lists the same
// sources a live session loads, instead of a cwd-relative .kigi.
let grok = kigi_config::user_kigi_home();
let mut global = Vec::new();
if !skip_claude && let Some(ref h) = home {
global.push(h.join(".claude").join("settings.json"));
global.push(h.join(".claude").join("settings.local.json"));
}
if let Some(ref grok) = grok {
global.push(grok.join("hooks"));
}
let custom_paths: Vec<PathBuf> = grok
.as_ref()
.and_then(|g| std::fs::read_to_string(g.join("hooks-paths")).ok())
.map(|content| {
content
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| PathBuf::from(l.trim()))
.collect()
})
.unwrap_or_default();
global.extend(custom_paths);
if let Some(ref h) = home
&& !skip_cursor
{
global.push(h.join(".cursor").join("hooks.json"));
}
let mut project = Vec::new();
if let Some(root) = git_root {
if !skip_claude {
project.push(root.join(".claude").join("settings.json"));
project.push(root.join(".claude").join("settings.local.json"));
}
project.push(root.join(".kigi").join("hooks"));
if !skip_cursor {
project.push(root.join(".cursor").join("hooks.json"));
}
}
HookSourcePaths { global, project }
}
/// Single load entry point: build compat-aware sources, gate project sources on
/// trust, then load. Every session-startup and mid-session reload site routes
/// through here so the source policy stays in one place. `discover_hook_source_paths`
/// and `HookSourcePaths::as_sources` stay public for the `inspect` path (which
/// enumerates sources with all vendors on) and the unit tests that assert on the
/// raw source lists.
pub fn discover_hooks(
git_root: Option<&Path>,
compat: &kigi_tools::types::compat::CompatConfig,
trusted: bool,
) -> (
kigi_hooks::discovery::HookRegistry,
Vec<kigi_hooks::error::HookError>,
) {
let source_paths = discover_hook_source_paths(git_root, compat);
let (global_sources, project_sources) = source_paths.as_sources(trusted);
kigi_hooks::discovery::load_hooks_from_sources(&global_sources, &project_sources)
}
+25
View File
@@ -0,0 +1,25 @@
pub mod agent_id;
pub mod config;
pub mod grok_auth_credentials;
pub mod hooks;
// The foundation utilities live in `kigi-shell-base` (upstream of this
// crate so they build in parallel). Re-exported at the original paths so
// existing `crate::util::…` / `kigi_shell::util::…` users compile
// unchanged.
pub use kigi_shell_base::util::*;
/// Aborts the wrapped tokio task when dropped.
///
/// Use to tie a spawned helper task's lifetime to an async scope so that
/// cancelling the parent future (e.g. a turn abort dropping the tool loop)
/// also tears down the helper instead of leaving it running detached.
/// Aborting an already-finished task is a no-op, so this is safe to hold
/// across normal scope exit too.
pub struct AbortOnDrop(pub tokio::task::JoinHandle<()>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}