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