Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
391 changes: 184 additions & 207 deletions rust/src/cli/dispatch/mod.rs

Large diffs are not rendered by default.

8 changes: 3 additions & 5 deletions rust/src/cli/dispatch/network/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::{dashboard, tui};
use crate::{core, dashboard, tui};

#[cfg(feature = "gateway-server")]
mod gateway;
Expand Down Expand Up @@ -105,10 +105,8 @@ pub(super) fn cmd_dashboard(rest: &[String]) {
.iter()
.find_map(|p| p.strip_prefix("--project="))
.map(String::from);
if let Some(ref p) = project {
// SAFETY: runs during single-threaded CLI argument parsing, before the
// dashboard server (and its threads) starts.
unsafe { std::env::set_var("LEAN_CTX_DASHBOARD_PROJECT", p) };
if let Some(ref project) = project {
core::runtime_flags::set_dashboard_project(project.clone());
}
// `--base-path` / `--prefix`: mount the dashboard behind a reverse-proxy
// subpath (e.g. `/dashboard`). See dashboard::base_path (#355).
Expand Down
4 changes: 1 addition & 3 deletions rust/src/cli/dispatch/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ pub(super) fn run_mcp_server() -> Result<()> {
// process entry to the completed MCP initialize handshake.
let started_at = std::time::Instant::now();

// SAFETY: set once at MCP server startup, before the Tokio runtime is built
// and any worker/blocking threads exist (runtime is constructed below).
unsafe { std::env::set_var("LEAN_CTX_MCP_SERVER", "1") };
crate::core::runtime_flags::enable_mcp_server();

crate::core::startup_guard::crash_loop_backoff(crate::core::startup_guard::MCP_PROCESS_NAME);

Expand Down
8 changes: 2 additions & 6 deletions rust/src/cli/init_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::hooks::to_bash_compatible_path;

pub(crate) fn quiet_enabled() -> bool {
matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
crate::core::runtime_flags::quiet_enabled()
}

macro_rules! qprintln {
Expand Down Expand Up @@ -190,10 +190,6 @@ pub fn cmd_init(args: &[String]) {
}

pub fn cmd_init_quiet(args: &[String]) {
// SAFETY: the `init` CLI command is single-threaded; no other thread reads
// the environment between this set and the matching remove below.
unsafe { std::env::set_var("LEAN_CTX_QUIET", "1") };
let _quiet_guard = crate::core::runtime_flags::scoped_quiet();
cmd_init(args);
// SAFETY: single-threaded CLI command; pairs with the set above.
unsafe { std::env::remove_var("LEAN_CTX_QUIET") };
}
2 changes: 1 addition & 1 deletion rust/src/cli/read_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ pub fn cmd_read(args: &[String]) {
// The hook env (set by `mark_hook_environment`, inherited by the subprocess) forces
// verbatim content on BOTH the daemon (`fresh:true`) and standalone (skip cli_cache)
// paths. Direct CLI/MCP reads keep caching.
let force_fresh = should_force_fresh(args, std::env::var("LEAN_CTX_HOOK_CHILD").is_ok());
let force_fresh = should_force_fresh(args, crate::core::runtime_flags::hook_child_enabled());
// Whether *we* choose the mode (auto): only then do we cap framing to raw.
// An explicit mode is a deliberate view we return verbatim (#361).
let requested_auto = mode == "auto";
Expand Down
1 change: 1 addition & 0 deletions rust/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub mod rules_canonical;
pub mod rules_channel;
pub mod rules_overhead;
pub mod rules_sections;
pub mod runtime_flags;
pub mod structural_tokenizer;
pub mod structured_read;
pub mod tabular_crush;
Expand Down
6 changes: 3 additions & 3 deletions rust/src/core/ocla/builtin/compression_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ use std::sync::OnceLock;

use crate::core::compressor;
use crate::core::config::Config;
use crate::core::ocla::OclaError;
use crate::core::ocla::content_port::CompressionContentPort;
use crate::core::ocla::traits::{CompressionProvider, OclaService};
use crate::core::ocla::types::{
CompressionRequest, CompressionResult, OclaCapability, OclaCapabilityKind,
OclaCapabilityStatus, OclaResult, OCLA_API_VERSION,
CompressionRequest, CompressionResult, OCLA_API_VERSION, OclaCapability, OclaCapabilityKind,
OclaCapabilityStatus, OclaResult,
};
use crate::core::ocla::OclaError;
use crate::core::ocla_bus::{self, OclaEvent};
use crate::core::tokens;

Expand Down
5 changes: 4 additions & 1 deletion rust/src/core/pathjail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
out.push(canonicalize_secure(&expand_user_path(p)));
}

for path in crate::core::runtime_flags::allow_paths() {
out.push(canonicalize_secure(&path));
}
// Env entries are expanded too: MCP host configs pass env blocks verbatim
// (no shell), so "$HOME/code" arrives literally there as well.
let v = std::env::var("LCTX_ALLOW_PATH")
Expand Down Expand Up @@ -216,7 +219,7 @@ pub fn active_relaxations() -> Vec<JailRelaxation> {
});
}

if env_is_set("LEAN_CTX_ALLOW_PATH") || env_is_set("LCTX_ALLOW_PATH") {
if crate::core::runtime_flags::allow_path_enabled() {
out.push(JailRelaxation {
source: "LEAN_CTX_ALLOW_PATH",
detail: "widens the read/write allow-list beyond the project root",
Expand Down
112 changes: 112 additions & 0 deletions rust/src/core/runtime_flags.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};

static RAW: AtomicBool = AtomicBool::new(false);
static COMPRESS: AtomicBool = AtomicBool::new(false);
static QUIET: AtomicBool = AtomicBool::new(false);
static MCP_SERVER: AtomicBool = AtomicBool::new(false);
static HOOK_CHILD: AtomicBool = AtomicBool::new(false);
static DASHBOARD_PROJECT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
static ALLOW_PATHS: OnceLock<Mutex<Vec<PathBuf>>> = OnceLock::new();

pub struct FlagGuard {
flag: &'static AtomicBool,
previous: bool,
}

impl Drop for FlagGuard {
fn drop(&mut self) {
self.flag.store(self.previous, Ordering::Relaxed);
}
}

fn set_scoped(flag: &'static AtomicBool) -> FlagGuard {
let previous = flag.swap(true, Ordering::Relaxed);
FlagGuard { flag, previous }
}

pub fn enable_raw() {
RAW.store(true, Ordering::Relaxed);
}

pub fn enable_compress() {
COMPRESS.store(true, Ordering::Relaxed);
}

pub fn enable_mcp_server() {
MCP_SERVER.store(true, Ordering::Relaxed);
}

pub fn mark_hook_child() {
HOOK_CHILD.store(true, Ordering::Relaxed);
}

pub fn scoped_quiet() -> FlagGuard {
set_scoped(&QUIET)
}

pub fn set_dashboard_project(project: String) {
let slot = DASHBOARD_PROJECT.get_or_init(|| Mutex::new(None));
if let Ok(mut value) = slot.lock() {
*value = Some(project);
}
}

pub fn add_allow_paths(paths: Vec<PathBuf>) {
if paths.is_empty() {
return;
}
let slot = ALLOW_PATHS.get_or_init(|| Mutex::new(Vec::new()));
if let Ok(mut value) = slot.lock() {
value.extend(paths);
}
}

pub fn raw_enabled() -> bool {
RAW.load(Ordering::Relaxed) || std::env::var("LEAN_CTX_RAW").is_ok()
}

pub fn compress_enabled() -> bool {
COMPRESS.load(Ordering::Relaxed) || std::env::var("LEAN_CTX_COMPRESS").is_ok()
}

pub fn quiet_enabled() -> bool {
QUIET.load(Ordering::Relaxed)
|| matches!(std::env::var("LEAN_CTX_QUIET"), Ok(value) if value.trim() == "1")
}

pub fn mcp_server_enabled() -> bool {
MCP_SERVER.load(Ordering::Relaxed)
|| std::env::var("LEAN_CTX_MCP_SERVER").is_ok_and(|value| value == "1")
}

pub fn hook_child_enabled() -> bool {
HOOK_CHILD.load(Ordering::Relaxed) || std::env::var("LEAN_CTX_HOOK_CHILD").is_ok()
}

pub fn dashboard_project() -> Option<String> {
if let Some(value) = DASHBOARD_PROJECT
.get()
.and_then(|slot| slot.lock().ok().and_then(|value| value.clone()))
&& !value.trim().is_empty()
{
return Some(value);
}
std::env::var("LEAN_CTX_DASHBOARD_PROJECT")
.ok()
.filter(|value| !value.trim().is_empty())
}

pub fn allow_paths() -> Vec<PathBuf> {
ALLOW_PATHS
.get()
.and_then(|slot| slot.lock().ok().map(|value| value.clone()))
.unwrap_or_default()
}

pub fn allow_path_enabled() -> bool {
!allow_paths().is_empty()
|| std::env::var("LEAN_CTX_ALLOW_PATH").is_ok()
|| std::env::var("LCTX_ALLOW_PATH").is_ok()
}
3 changes: 1 addition & 2 deletions rust/src/core/tool_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,9 +666,8 @@ mod tests {
/// Best-effort: silently drops if provider is unavailable or source_ref can't be
/// constructed. This is the canonical production callsite for the compression capability.
fn project_ocla_compression(path: &str, source_tokens: u64, output_tokens: u64) {
use crate::core::ocla::traits::CompressionProvider;
use crate::core::ocla::types::{CompressionRequest, OclaRequestContext};
use crate::core::ocla::OclaRegistry;
use crate::core::ocla::types::{CompressionRequest, OclaRequestContext};

let reg = OclaRegistry::global();
let source_ref = format!("file:{path}");
Expand Down
2 changes: 1 addition & 1 deletion rust/src/daemon_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ pub fn try_daemon_tool_call_blocking(
// auto-starting one. This guard MUST stay inside `if !ready` — hoisting it
// to the top of the function would also block hooks from reusing a running
// daemon, silently regressing loop/bounce/adaptive parity.
if std::env::var("LEAN_CTX_HOOK_CHILD").is_ok() {
if crate::core::runtime_flags::hook_child_enabled() {
return None;
}

Expand Down
4 changes: 1 addition & 3 deletions rust/src/dashboard/routes/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,7 @@ pub fn is_windows_absolute_path(path: &str) -> bool {
}

pub fn detect_project_root_for_dashboard() -> String {
if let Ok(explicit) = std::env::var("LEAN_CTX_DASHBOARD_PROJECT")
&& !explicit.trim().is_empty()
{
if let Some(explicit) = crate::core::runtime_flags::dashboard_project() {
return promote_to_git_root(&explicit);
}

Expand Down
2 changes: 1 addition & 1 deletion rust/src/doctor/checks/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ pub(crate) fn cwd_looks_like_agent_dir(cwd_str: &str) -> bool {
/// .claude). This usually means the MCP client launched the process from the
/// wrong CWD, causing "path escapes project root" errors for every tool call.
pub(crate) fn mcp_server_cwd_outcome() -> Outcome {
let is_mcp = std::env::var("LEAN_CTX_MCP_SERVER").is_ok_and(|v| v == "1");
let is_mcp = crate::core::runtime_flags::mcp_server_enabled();
if !is_mcp {
return Outcome {
ok: true,
Expand Down
2 changes: 1 addition & 1 deletion rust/src/doctor/fix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ fn build_and_persist_fix_report(
PlatformInfo, SetupItem, SetupReport, SetupStepReport, doctor_report_path,
};

let _quiet_guard = quiet.then(|| crate::setup::EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
let _quiet_guard = quiet.then(crate::core::runtime_flags::scoped_quiet);
let started_at = Utc::now();
let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;

Expand Down
6 changes: 2 additions & 4 deletions rust/src/hook_handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,13 @@ fn log_shadow_intercept(tool: &str, detail: &str) {
}

fn is_quiet() -> bool {
matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1")
crate::core::runtime_flags::quiet_enabled()
}

/// Mark this process as a hook child so the daemon-client never auto-starts
/// the daemon from inside a hook (which would create zombie processes).
pub fn mark_hook_environment() {
// SAFETY: called once at hook-process startup (CLI dispatch), before any
// threads that read the environment are spawned.
unsafe { std::env::set_var("LEAN_CTX_HOOK_CHILD", "1") };
crate::core::runtime_flags::mark_hook_child();
}

/// Arms a watchdog that force-exits the process after the given duration.
Expand Down
3 changes: 1 addition & 2 deletions rust/src/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,7 @@ use support::{
};

fn mcp_server_quiet_mode() -> bool {
std::env::var_os("LEAN_CTX_MCP_SERVER").is_some()
|| matches!(std::env::var("LEAN_CTX_QUIET"), Ok(value) if value.trim() == "1")
crate::core::runtime_flags::mcp_server_enabled() || crate::core::runtime_flags::quiet_enabled()
}

/// Agents whose global shell-hook artifacts embed the binary path / command
Expand Down
2 changes: 1 addition & 1 deletion rust/src/server/call_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ impl LeanCtxServer {
arg_raw
|| arg_bypass
|| std::env::var("LEAN_CTX_DISABLED").is_ok()
|| std::env::var("LEAN_CTX_RAW").is_ok()
|| crate::core::runtime_flags::raw_enabled()
};

let pre_terse_len = result_text.len();
Expand Down
15 changes: 4 additions & 11 deletions rust/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use rmcp::model::{
InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo,
};
use rmcp::service::{RequestContext, RoleServer};
use std::path::PathBuf;

use crate::tools::{CrpMode, LeanCtxServer};
mod call_tool;
Expand Down Expand Up @@ -141,17 +142,9 @@ fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
}

if child_projects.len() >= 2 {
let existing = std::env::var("LEAN_CTX_ALLOW_PATH").unwrap_or_default();
let sep = if cfg!(windows) { ";" } else { ":" };
let merged = if existing.is_empty() {
child_projects.join(sep)
} else {
format!("{existing}{sep}{}", child_projects.join(sep))
};
// SAFETY: set during MCP `initialize` (connection bootstrap), before any
// tool-handler thread reads the jail allow-list via `pathjail`. The only
// concurrent startup tasks (proxy spawn, savings publish) never consult it.
unsafe { std::env::set_var("LEAN_CTX_ALLOW_PATH", &merged) };
crate::core::runtime_flags::add_allow_paths(
child_projects.iter().map(PathBuf::from).collect(),
);
tracing::info!(
"Multi-root workspace detected at {}: auto-allowing {} child projects",
dir.display(),
Expand Down
16 changes: 5 additions & 11 deletions rust/src/setup/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ use crate::core::portable_binary::resolve_portable_binary;
use crate::core::setup_report::{PlatformInfo, SetupItem, SetupReport, SetupStepReport};
use crate::hooks::{HookMode, recommend_hook_mode};
use chrono::Utc;
use std::ffi::OsString;
mod mcp;
pub use mcp::*;
mod helpers;
Expand All @@ -19,36 +18,31 @@ pub fn claude_config_dir(home: &std::path::Path) -> PathBuf {
crate::core::editor_registry::claude_state_dir(home)
}

#[cfg(test)]
pub(crate) struct EnvVarGuard {
key: &'static str,
previous: Option<OsString>,
previous: Option<std::ffi::OsString>,
}

#[cfg(test)]
impl EnvVarGuard {
pub(crate) fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
// SAFETY: `EnvVarGuard` is only used in single-threaded setup/doctor CLI
// flows (and serial-gated tests), so no other thread reads the
// environment while the guard mutates it.
unsafe { std::env::set_var(key, value) };
Self { key, previous }
}
}

#[cfg(test)]
impl Drop for EnvVarGuard {
fn drop(&mut self) {
if let Some(previous) = &self.previous {
// SAFETY: see `EnvVarGuard::set` — restoration runs on the same
// single-threaded setup/doctor path that created the guard.
unsafe { std::env::set_var(self.key, previous) };
} else {
// SAFETY: see `EnvVarGuard::set` — restoration runs on the same
// single-threaded setup/doctor path that created the guard.
unsafe { std::env::remove_var(self.key) };
}
}
}

/// Determine the setup level from a first-run interactive menu.
/// Returns (inject_rules, inject_skills).
fn first_run_setup_level() -> (bool, bool) {
Expand Down Expand Up @@ -795,7 +789,7 @@ pub struct SetupOptions {
}

pub fn run_setup_with_options(opts: SetupOptions) -> Result<SetupReport, String> {
let _quiet_guard = opts.json.then(|| EnvVarGuard::set("LEAN_CTX_QUIET", "1"));
let _quiet_guard = opts.json.then(crate::core::runtime_flags::scoped_quiet);
let started_at = Utc::now();
let home = dirs::home_dir().ok_or_else(|| "Cannot determine home directory".to_string())?;
let binary = resolve_portable_binary();
Expand Down
Loading
Loading