Skip to main content

lean_ctx/core/config/
proxy.rs

1//! API proxy upstream overrides (`config.toml`).
2
3use serde::{Deserialize, Serialize};
4
5/// API proxy upstream overrides. `None` = use provider default.
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7#[serde(default)]
8pub struct ProxyConfig {
9    pub anthropic_upstream: Option<String>,
10    pub openai_upstream: Option<String>,
11    pub chatgpt_upstream: Option<String>,
12    pub gemini_upstream: Option<String>,
13    /// History-pruning strategy for proxied chat requests.
14    /// "cache-aware" (default) | "rolling" | "off". See [`HistoryMode`].
15    pub history_mode: Option<String>,
16    /// Allow a non-loopback plaintext `http://` upstream (trusted local network
17    /// only). Opt-in; see [`ProxyConfig::allows_insecure_http_upstream`]. (#440)
18    pub allow_insecure_http_upstream: Option<bool>,
19    /// Allow a custom (non-allowlisted) **HTTPS** upstream host — e.g. a corporate
20    /// gateway in front of the provider API. Opt-in; see
21    /// [`ProxyConfig::allows_custom_upstream`]. Mirrors `allow_insecure_http_upstream`
22    /// so the long-lived managed proxy (LaunchAgent / systemd), which only reads
23    /// `config.toml` and never the shell's `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`, can
24    /// honor a custom upstream too (#590).
25    pub allow_custom_upstream: Option<bool>,
26    /// Inject `stream_options.include_usage = true` into streamed OpenAI Chat
27    /// Completions so the final chunk reports real token usage for the measured
28    /// spend meter. Default on; set `false` for a client that mishandles the
29    /// trailing usage chunk. Anthropic/Gemini/OpenAI-Responses report usage
30    /// without any request change, so this only affects Chat Completions.
31    pub meter_openai_usage: Option<bool>,
32    /// Opt-in "big-gap cold-prefix repack" (#480). When the proxy can confidently
33    /// predict (from idle time vs the provider cache TTL) that the client-cached
34    /// prefix has already expired, it overrides the normal "never rewrite the
35    /// cached prefix" rule for that one resume request and prunes the now-cold
36    /// prefix too, re-seeding a leaner cache. `None`/`false` (the default) keeps
37    /// the prefix always protected. See [`ProxyConfig::repacks_cold_prefix`].
38    pub cold_prefix_repack: Option<bool>,
39    /// Opt-in per-role prose compression for the proxy's frozen request region
40    /// (#710). `None` for a role (the default) leaves that role untouched —
41    /// today's behaviour. See [`RoleAggressiveness`].
42    pub role_aggressiveness: RoleAggressiveness,
43    /// Live tool-result compression on the wire (#481). `true` (the default)
44    /// keeps today's behaviour: the proxy compresses non-protected `tool_result`
45    /// content on every request. `false` turns it off so the proxy can run
46    /// **meter-only** — real billed/cache token metering with zero request
47    /// rewriting (combine with `history_mode = "off"` and no `role_aggressiveness`
48    /// for a fully byte-unchanged body). Env `LEAN_CTX_PROXY_LIVE_COMPRESS`.
49    /// See [`ProxyConfig::live_compresses`].
50    pub live_compress: Option<bool>,
51    /// Per-tool exclusion list for live tool-result compression (#481). Tool
52    /// names are matched case-insensitively as substrings (the same style as
53    /// [`crate::proxy::tool_kind::classify_tool_name`]); a match is treated as
54    /// protected, exactly like a file read. `None` (the default) protects
55    /// Serena's code-reading tools (`find_symbol`/`find_referencing_symbols`/
56    /// `search_for_pattern` return source bodies the model edits, but are
57    /// mis-bucketed as `Search` by name). Set an explicit list to narrow it, or
58    /// `[]` to disable the exclusion. See [`ProxyConfig::is_tool_live_compress_excluded`].
59    pub live_compress_exclude: Option<Vec<String>>,
60    /// File-path globs whose reads are never compressed (#1150). A read whose path
61    /// matches any of these is returned verbatim (`full`) by the read tools — for
62    /// files where exact bytes matter more than token savings: golden snapshots,
63    /// byte-asserted fixtures, security-sensitive configs. Globs (`*`/`**`/`?`,
64    /// the `glob` crate) are matched against the path and its file name, so
65    /// `*.snap`, `**/golden/**`, and `tests/fixtures/*` all work. `None`/empty (the
66    /// default) protects nothing — the lossless crushers and beneficial gate
67    /// already keep compression safe, so this is an explicit escape hatch, not a
68    /// default. See [`ProxyConfig::is_path_compress_protected`].
69    pub compress_protect: Option<Vec<String>>,
70    /// Opt-in in-band CCR retrieval for a remote proxy with no shared filesystem
71    /// (#493, follow-up to #482). When enabled, a lossy stub advertises a compact
72    /// `<lc_expand:HASH>` marker (instead of a local tee path the remote agent
73    /// can't read); when the model echoes that marker back, the proxy splices the
74    /// verbatim original — recovered from its **local** tee store — inline on the
75    /// next request, costing one turn of latency and needing no MCP/FS on the
76    /// agent host. `None`/`false` (the default) keeps the path-handle stub. The
77    /// splice is a strict no-op on marker-less turns, so it never perturbs the
78    /// provider cache prefix unless the model explicitly asked to expand. See
79    /// [`ProxyConfig::ccr_inband_enabled`].
80    pub ccr_inband: Option<bool>,
81    /// Opt-in active prompt-cache breakpoint injection for Anthropic (#939). When
82    /// enabled and the client set no `cache_control` of its own, the proxy adds a
83    /// single `cache_control: {type:"ephemeral"}` breakpoint to the `system`
84    /// field so an otherwise-uncached, stable system prompt bills later turns at
85    /// the cached rate. Anthropic-only: OpenAI/Gemini cache prefixes automatically
86    /// and ignore the marker, so those paths stay byte-unchanged. The injection is
87    /// deterministic, never adds a second breakpoint, and is skipped below
88    /// Anthropic's minimum cacheable size. `None`/`false` (the default) leaves the
89    /// request untouched. See [`ProxyConfig::cache_breakpoint_enabled`].
90    pub cache_breakpoint: Option<bool>,
91    /// Opt-in cache-aligner volatile-field telemetry (#940). When enabled, the
92    /// proxy scans each *unanchored* Anthropic system prompt for volatile,
93    /// cache-busting fields (ISO dates/datetimes, UUIDs, git SHAs) and records how
94    /// many it found on `/status` `cache_safety` — purely to quantify how much
95    /// prompt-cache the client is leaking. **Measurement only**: the request body
96    /// is never mutated, so it is strictly cache-safe. `None` (the default) enables
97    /// it — every proxy ships cache-leak visibility out of the box (#986 premium
98    /// defaults); set `false` to opt out of the per-request scan. See
99    /// [`ProxyConfig::cache_aligner_enabled`].
100    pub cache_aligner: Option<bool>,
101    /// Opt-in active cache-aligner relocate (#974). When enabled, the proxy
102    /// rewrites an *unanchored* Anthropic `system` prompt into a stable block
103    /// (volatile values — ISO dates/datetimes, UUIDs, git SHAs — replaced by
104    /// constant placeholders) carrying the `cache_control` breakpoint, plus an
105    /// *uncached* trailing block that re-states the relocated values. The cacheable
106    /// prefix then stays byte-stable turn-to-turn and finally caches; only the
107    /// small tail is reprocessed. Anthropic-only, Treatment-arm, gated on a client
108    /// that anchored nothing and on Anthropic's minimum cacheable size.
109    /// Deterministic (#498) and idempotent. `None`/`false` (the default) leaves the
110    /// request untouched. The `cache_aligner` telemetry above is the precursor that
111    /// quantifies how much this would save. See
112    /// [`ProxyConfig::cache_align_relocate_enabled`].
113    pub cache_align_relocate: Option<bool>,
114    /// Cache-economics (#986), **on by default**. Bundles two strictly-safe halves
115    /// behind one flag: (1) prompt-cache **miss attribution** telemetry — per turn,
116    /// classify why the cache hit or missed (cold start / warm reuse / TTL lapse /
117    /// prefix change) and expose cumulative gauges on `/status`
118    /// ([`crate::proxy::cache_attribution`]); and (2) a **net-cost gate** on the
119    /// cold-prefix repack ([`crate::proxy::cache_policy::worth_repacking`]) that
120    /// skips re-seeding prefixes too small to be cached. The telemetry never
121    /// touches the body and the gate only makes repacking *more* conservative, so
122    /// it can never bust a cache that would otherwise have been kept. `None` (the
123    /// default) enables both — every proxy gets the diagnosis and the safer repack
124    /// out of the box (#986 premium defaults); set `false` to opt out. See
125    /// [`ProxyConfig::cache_policy_enabled`].
126    pub cache_policy: Option<bool>,
127    /// Cache-safe, cross-provider reasoning-effort control (#834). One of
128    /// `minimal|low|medium|high` pins the model's reasoning depth across every
129    /// provider; `None`/`"off"` (the default) is a strict no-op. The value is a
130    /// constant — identical on every request — so the provider prompt-cache
131    /// prefix stays byte-stable (#448/#498) and only the model's reasoning depth
132    /// changes. lean-ctx translates it to each provider's native parameter and
133    /// only ever *fills* it (never overrides a client-set value), on models that
134    /// accept it. Per-turn effort switching is deliberately unsupported — it
135    /// would invalidate the prompt cache. Env `LEAN_CTX_PROXY_EFFORT`. See
136    /// [`ProxyConfig::resolved_effort`].
137    pub effort: Option<String>,
138    /// How the proxy squeezes prose it must shrink (#895): `"auto"` (default) and
139    /// `"extractive"` use embedding-based extractive ranking — keeping the most
140    /// central sentences instead of just the prefix — when the local embedding
141    /// engine is available, falling back to truncation otherwise; `"truncate"`
142    /// keeps the original deterministic FIFO squeeze (and no engine). Wire
143    /// rewrites are memoized per content so the engine's cold→warm transition
144    /// never changes an already-emitted frozen-region rewrite (#448/#498). Env
145    /// `LEAN_CTX_PROXY_PROSE_RANKER`. See [`ProxyConfig::resolved_prose_ranker`].
146    pub prose_ranker: Option<String>,
147    /// Fraction `0.0..=1.0` of conversations placed in the output-savings control
148    /// arm (#895 Track B). `0` (default) = no holdout (every conversation is
149    /// shaped). When `> 0`, a deterministic cohort = `blake3(system + first user
150    /// msg)` puts ~this fraction of conversations in a control arm that skips
151    /// output-shaping (effort control + verbosity steer) but is still metered —
152    /// giving an honest measured output-token reduction. The cohort is a pure
153    /// function of conversation identity, so a conversation stays in one arm
154    /// across turns (cache-safe). Env `LEAN_CTX_PROXY_OUTPUT_HOLDOUT`. See
155    /// [`ProxyConfig::output_holdout_fraction`].
156    pub output_holdout: Option<f64>,
157    /// Opt-in cache-safe wire verbosity steer (#895). When `true`, the proxy
158    /// appends a single constant "be concise" instruction to the last user turn
159    /// of each request (output-shaping for non-rules-aware API clients). The
160    /// suffix is constant and appended strictly after the last `cache_control`
161    /// breakpoint, so the provider prompt-cache prefix stays byte-stable. Default
162    /// `false`. Env `LEAN_CTX_PROXY_VERBOSITY_STEER`. See
163    /// [`ProxyConfig::verbosity_steer_enabled`].
164    pub verbosity_steer: Option<bool>,
165    /// Opt-in: route a Codex *ChatGPT-subscription* login through the proxy for
166    /// model-turn compression. Default `None`/`false` keeps Codex native (history
167    /// visible, cloud/remote intact, no #597). When `true`, Codex setup pins the
168    /// generated `leanctx-chatgpt` provider + `chatgpt_base_url`; that scopes Codex
169    /// history to the provider (#597), so it stays opt-in. Toggle durably with
170    /// `lean-ctx proxy codex-chatgpt on|off`; resolved via
171    /// [`ProxyConfig::codex_chatgpt_proxy_enabled`].
172    pub codex_chatgpt_proxy: Option<bool>,
173}
174
175/// Per-role prose-compression intensity for the proxy's frozen request region.
176///
177/// Each value is a `0.0–1.0` aggressiveness level reusing the same mapping as
178/// the `ctx_read` knob (#708): `0.0` keeps everything, `1.0` is most aggressive.
179/// `None` (the default) means "do not compress this role's prose" so the proxy
180/// stays byte-for-byte unchanged until an operator opts in. The `assistant`
181/// role is never represented here — model turns are always passed through
182/// verbatim (the #710 passthrough guarantee).
183#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
184#[serde(default)]
185pub struct RoleAggressiveness {
186    /// Aggressiveness for system prompts (Anthropic `system` / OpenAI `system`
187    /// messages / Gemini `systemInstruction`). `None` = leave untouched.
188    pub system: Option<f64>,
189    /// Aggressiveness for user prose (free-text user turns, never tool results).
190    /// `None` = leave untouched.
191    pub user: Option<f64>,
192}
193
194/// The conversation roles whose prose the proxy may compress in the frozen
195/// region. Deliberately excludes `assistant` — model turns are never rewritten.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum ProseRole {
198    System,
199    User,
200}
201
202/// How the proxy squeezes prose it must shrink (#895).
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204pub enum ProseRanker {
205    /// Extractive embedding ranking when the engine is available, else truncate.
206    /// The default — strictly better than truncation, and cache-safe via the
207    /// per-content memo in [`crate::proxy::prose_ranker`].
208    Auto,
209    /// Same engine path as `Auto` (kept distinct so an operator can express
210    /// intent / so a future "require engine" semantic has a name).
211    Extractive,
212    /// Original deterministic FIFO squeeze; never touches the embedding engine.
213    Truncate,
214}
215
216/// How the proxy prunes old tool results from conversation history.
217///
218/// Provider prompt caches (Anthropic `cache_control`, OpenAI automatic prompt
219/// caching) bill cached prefix tokens at a fraction of the base rate but only
220/// match *exact* prefixes. Any mutation whose position depends on the current
221/// conversation length (a rolling window) rewrites a previously-stable message
222/// every turn, invalidating the cache from that point — turning cheap cache
223/// reads into full-price writes.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum HistoryMode {
226    /// Prune only at frozen generation boundaries that advance in large,
227    /// deterministic steps. Between jumps the request prefix is byte-stable,
228    /// so provider prompt caches keep hitting. Content the client has marked
229    /// with a `cache_control` breakpoint is never rewritten, so an advancing
230    /// boundary can no longer invalidate the already-cached prefix (#448).
231    /// Default.
232    CacheAware,
233    /// Legacy behaviour: summarize everything older than the last N messages.
234    /// Maximum raw-token reduction, but defeats provider prompt caching.
235    Rolling,
236    /// Never prune history (tool-result compression still applies — it is
237    /// content-deterministic and therefore prefix-stable).
238    Off,
239}
240
241impl ProxyConfig {
242    /// Resolved history mode: `LEAN_CTX_PROXY_HISTORY_MODE` env var wins,
243    /// then `[proxy].history_mode` in config.toml, then cache-aware.
244    /// Unknown values fall back to the default so a typo can never silently
245    /// re-enable the cache-hostile rolling mode.
246    pub fn resolved_history_mode(&self) -> HistoryMode {
247        let raw = std::env::var("LEAN_CTX_PROXY_HISTORY_MODE")
248            .ok()
249            .or_else(|| self.history_mode.clone());
250        match raw.as_deref().map(str::trim) {
251            Some(s) if s.eq_ignore_ascii_case("rolling") => HistoryMode::Rolling,
252            Some(s) if s.eq_ignore_ascii_case("off") => HistoryMode::Off,
253            _ => HistoryMode::CacheAware,
254        }
255    }
256
257    /// Whether the proxy injects `stream_options.include_usage` into streamed
258    /// OpenAI Chat Completions to meter real spend. `[proxy] meter_openai_usage`
259    /// in config.toml, default `true`.
260    pub fn meters_openai_usage(&self) -> bool {
261        self.meter_openai_usage.unwrap_or(true)
262    }
263
264    /// Resolved prose-ranker strategy (#895). Precedence: the
265    /// `LEAN_CTX_PROXY_PROSE_RANKER` env var, then `[proxy] prose_ranker` in
266    /// config.toml, then `Auto`. Unknown values resolve to `Auto` so a typo can
267    /// never silently disable the premium path; `"truncate"`/`"off"` selects the
268    /// legacy squeeze.
269    #[must_use]
270    pub fn resolved_prose_ranker(&self) -> ProseRanker {
271        let raw = std::env::var("LEAN_CTX_PROXY_PROSE_RANKER")
272            .ok()
273            .or_else(|| self.prose_ranker.clone());
274        match raw.as_deref().map(str::trim) {
275            Some(s) if s.eq_ignore_ascii_case("truncate") || s.eq_ignore_ascii_case("off") => {
276                ProseRanker::Truncate
277            }
278            Some(s) if s.eq_ignore_ascii_case("extractive") => ProseRanker::Extractive,
279            _ => ProseRanker::Auto,
280        }
281    }
282
283    /// Resolved output-savings holdout fraction (#895 Track B), clamped to
284    /// `[0,1]`. Precedence: `LEAN_CTX_PROXY_OUTPUT_HOLDOUT` env > `[proxy]
285    /// output_holdout` > `0.0` (no holdout). An unparseable/blank env value is
286    /// ignored so a typo can never silently change the experiment fraction.
287    #[must_use]
288    pub fn output_holdout_fraction(&self) -> f64 {
289        let from_env = std::env::var("LEAN_CTX_PROXY_OUTPUT_HOLDOUT")
290            .ok()
291            .and_then(|v| v.trim().parse::<f64>().ok());
292        from_env
293            .or(self.output_holdout)
294            .unwrap_or(0.0)
295            .clamp(0.0, 1.0)
296    }
297
298    /// Whether the cache-safe wire verbosity steer (#895) is enabled. Precedence:
299    /// `LEAN_CTX_PROXY_VERBOSITY_STEER` env (`1`/`true`/`on`) > `[proxy]
300    /// verbosity_steer` > `false` (off).
301    #[must_use]
302    pub fn verbosity_steer_enabled(&self) -> bool {
303        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_VERBOSITY_STEER") {
304            let v = raw.trim();
305            return v.eq_ignore_ascii_case("1")
306                || v.eq_ignore_ascii_case("true")
307                || v.eq_ignore_ascii_case("on")
308                || v.eq_ignore_ascii_case("yes");
309        }
310        self.verbosity_steer.unwrap_or(false)
311    }
312
313    /// Resolved Codex ChatGPT-subscription proxy opt-in (default off).
314    /// `LEAN_CTX_CODEX_CHATGPT_PROXY` (any value) forces it on for the current
315    /// process, then `[proxy] codex_chatgpt_proxy` in config.toml, else `false`.
316    pub fn codex_chatgpt_proxy_enabled(&self) -> bool {
317        std::env::var("LEAN_CTX_CODEX_CHATGPT_PROXY").is_ok()
318            || self.codex_chatgpt_proxy.unwrap_or(false)
319    }
320
321    /// Whether the opt-in cold-prefix repack (#480) is enabled. A wrong "cold"
322    /// guess re-bills cache reads as writes (~12x), so this is off by default and
323    /// must be explicitly enabled. `LEAN_CTX_PROXY_COLD_PREFIX_REPACK` (any
324    /// value) wins, then `[proxy] cold_prefix_repack` in config.toml, else
325    /// `false`.
326    pub fn repacks_cold_prefix(&self) -> bool {
327        std::env::var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK").is_ok()
328            || self.cold_prefix_repack.unwrap_or(false)
329    }
330
331    /// Whether opt-in in-band CCR retrieval (#493) is enabled. Off by default:
332    /// the splice mutates provider-visible conversation content for the one turn
333    /// the model asks to expand, so it must be an explicit opt-in.
334    /// `LEAN_CTX_PROXY_CCR_INBAND` (any value) wins, then `[proxy] ccr_inband` in
335    /// config.toml, else `false`.
336    pub fn ccr_inband_enabled(&self) -> bool {
337        std::env::var("LEAN_CTX_PROXY_CCR_INBAND").is_ok() || self.ccr_inband.unwrap_or(false)
338    }
339
340    /// Whether opt-in Anthropic prompt-cache breakpoint injection (#939) is
341    /// enabled. Off by default: it mutates the provider-visible `system` shape
342    /// (string → cache-marked block array), so it must be an explicit opt-in.
343    /// `LEAN_CTX_PROXY_CACHE_BREAKPOINT` (any value) wins, then `[proxy]
344    /// cache_breakpoint` in config.toml, else `false`.
345    pub fn cache_breakpoint_enabled(&self) -> bool {
346        std::env::var("LEAN_CTX_PROXY_CACHE_BREAKPOINT").is_ok()
347            || self.cache_breakpoint.unwrap_or(false)
348    }
349
350    /// Whether opt-in cache-aligner volatile-field telemetry (#940) is enabled.
351    /// On by default (#986 premium defaults): the scan is pure measurement and
352    /// never mutates the body, so every proxy ships cache-leak visibility out of
353    /// the box. Strictly cache-safe. `LEAN_CTX_PROXY_CACHE_ALIGNER=on|off` wins,
354    /// then `[proxy] cache_aligner` in config.toml, else `true`. Opt **out** only
355    /// to drop the per-request system-prompt scan.
356    pub fn cache_aligner_enabled(&self) -> bool {
357        env_bool_or("LEAN_CTX_PROXY_CACHE_ALIGNER", self.cache_aligner, true)
358    }
359
360    /// Whether opt-in active cache-aligner relocate (#974) is enabled. Off by
361    /// default: it reshapes the provider-visible `system` field (moving volatile
362    /// values to an uncached tail block), so it must be an explicit opt-in.
363    /// `LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE` (any value) wins, then `[proxy]
364    /// cache_align_relocate` in config.toml, else `false`.
365    pub fn cache_align_relocate_enabled(&self) -> bool {
366        std::env::var("LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE").is_ok()
367            || self.cache_align_relocate.unwrap_or(false)
368    }
369
370    /// Whether cache-economics (#986) is enabled: prompt-cache miss attribution
371    /// telemetry plus the net-cost repack gate. Both are strictly safe
372    /// (measurement + a more-conservative repack that never busts a cache the
373    /// default kept), so this is **on by default** — every proxy gets the
374    /// diagnosis and the safer repack out of the box.
375    /// `LEAN_CTX_PROXY_CACHE_POLICY=on|off` wins, then `[proxy] cache_policy` in
376    /// config.toml, else `true`. Opt out to keep `/status` free of the attribution
377    /// gauges and skip the per-request prefix hash.
378    pub fn cache_policy_enabled(&self) -> bool {
379        env_bool_or("LEAN_CTX_PROXY_CACHE_POLICY", self.cache_policy, true)
380    }
381
382    /// Resolved cross-provider reasoning effort (#834), or `None` when the
383    /// feature is off (the default — a strict no-op that preserves the
384    /// byte-unchanged meter-only path). Precedence: `LEAN_CTX_PROXY_EFFORT` env
385    /// (`off` disables, a valid level wins, an unparseable/blank value is
386    /// ignored) > `[proxy] effort` in config.toml. Any unknown value resolves to
387    /// `None` so a typo can never silently enable reasoning steering.
388    #[must_use]
389    pub fn resolved_effort(&self) -> Option<super::Effort> {
390        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_EFFORT") {
391            let trimmed = raw.trim();
392            if trimmed.eq_ignore_ascii_case("off") {
393                return None;
394            }
395            if let Some(effort) = super::Effort::parse(trimmed) {
396                return Some(effort);
397            }
398            // Blank/unknown env → ignore and fall through to config, mirroring
399            // `live_compresses` so a typo never flips the configured behaviour.
400        }
401        self.effort.as_deref().and_then(super::Effort::parse)
402    }
403
404    /// Whether the proxy live-compresses non-protected `tool_result` content
405    /// (#481). `LEAN_CTX_PROXY_LIVE_COMPRESS` (`0`/`false`/`off`/`no` → off,
406    /// `1`/`true`/`on`/`yes` → on) wins, then `[proxy] live_compress` in
407    /// config.toml, else `true`. An unparseable/blank env value is ignored so a
408    /// typo can never silently flip the mode.
409    pub fn live_compresses(&self) -> bool {
410        if let Ok(raw) = std::env::var("LEAN_CTX_PROXY_LIVE_COMPRESS") {
411            match raw.trim().to_ascii_lowercase().as_str() {
412                "0" | "false" | "off" | "no" => return false,
413                "1" | "true" | "on" | "yes" => return true,
414                _ => {}
415            }
416        }
417        self.live_compress.unwrap_or(true)
418    }
419
420    /// Resolved per-tool live-compress exclusion patterns (#481). `None` in
421    /// config falls back to the built-in default (protect Serena); an explicit
422    /// list — including the empty list — is used verbatim so operators can narrow
423    /// or fully clear it.
424    #[must_use]
425    pub fn live_compress_exclude_patterns(&self) -> Vec<String> {
426        self.live_compress_exclude
427            .clone()
428            .unwrap_or_else(default_live_compress_exclude)
429    }
430
431    /// Whether `tool_name` is on the live-compress exclusion list (#481) and must
432    /// therefore reach the model intact, like a protected file read. Matching is
433    /// case-insensitive substring, mirroring `tool_kind::classify_tool_name`.
434    #[must_use]
435    pub fn is_tool_live_compress_excluded(&self, tool_name: &str) -> bool {
436        let name = tool_name.to_ascii_lowercase();
437        self.live_compress_exclude_patterns().iter().any(|p| {
438            let p = p.trim().to_ascii_lowercase();
439            !p.is_empty() && name.contains(p.as_str())
440        })
441    }
442
443    /// Compiled `compress_protect` globs (#1150), skipping any that fail to parse
444    /// so one malformed entry never disables the rest. Empty when unset — the
445    /// default — which makes [`Self::is_path_compress_protected`] a fast no-op.
446    #[must_use]
447    pub fn compress_protect_globs(&self) -> Vec<glob::Pattern> {
448        self.compress_protect
449            .as_deref()
450            .unwrap_or_default()
451            .iter()
452            .filter_map(|p| glob::Pattern::new(p.trim()).ok())
453            .collect()
454    }
455
456    /// Whether `path` is on the never-compress list (#1150) and must be returned
457    /// verbatim. Each glob is tried against both the full path (with backslashes
458    /// normalised to `/`) and the bare file name, so `*.snap` matches anywhere
459    /// while `**/golden/**` can still target a directory. Empty list → always
460    /// `false` (today's behaviour), so a default proxy pays nothing.
461    #[must_use]
462    pub fn is_path_compress_protected(&self, path: &str) -> bool {
463        let patterns = self.compress_protect_globs();
464        if patterns.is_empty() {
465            return false;
466        }
467        let norm = path.replace('\\', "/");
468        let base = norm.rsplit('/').next().unwrap_or(norm.as_str());
469        patterns.iter().any(|p| p.matches(&norm) || p.matches(base))
470    }
471
472    /// Resolved prose-compression aggressiveness for `role`, clamped to `[0,1]`,
473    /// or `None` when prose compression is off for that role (the default).
474    ///
475    /// Precedence: the role's env override (`LEAN_CTX_PROXY_SYSTEM_AGGR` /
476    /// `LEAN_CTX_PROXY_USER_AGGR`) wins, then `[proxy.role_aggressiveness]` in
477    /// config.toml. An unparseable or blank env value is ignored so a typo can
478    /// never silently disable the configured behaviour.
479    #[must_use]
480    pub fn resolved_role_aggressiveness(&self, role: ProseRole) -> Option<f64> {
481        let (env_var, configured) = match role {
482            ProseRole::System => (
483                "LEAN_CTX_PROXY_SYSTEM_AGGR",
484                self.role_aggressiveness.system,
485            ),
486            ProseRole::User => ("LEAN_CTX_PROXY_USER_AGGR", self.role_aggressiveness.user),
487        };
488        let from_env = std::env::var(env_var)
489            .ok()
490            .and_then(|v| v.trim().parse::<f64>().ok());
491        from_env.or(configured).map(|a| a.clamp(0.0, 1.0))
492    }
493
494    /// Whether a non-loopback plaintext `http://` upstream is allowed. Opt-in
495    /// only — a deliberate downgrade for a trusted local-network service such as
496    /// `http://host.docker.internal:2455` in front of codex-lb (#440).
497    /// `LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM` (any value) wins, then
498    /// `[proxy] allow_insecure_http_upstream` in config.toml, default `false`.
499    pub fn allows_insecure_http_upstream(&self) -> bool {
500        std::env::var("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM").is_ok()
501            || self.allow_insecure_http_upstream.unwrap_or(false)
502    }
503
504    /// Whether a custom (non-allowlisted) HTTPS upstream host is allowed. Opt-in
505    /// only — lifting the built-in host allowlist points the proxy at a host you
506    /// control (e.g. a corporate gateway), so it must be deliberate.
507    /// `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM` (any value) wins, then
508    /// `[proxy] allow_custom_upstream` in config.toml, default `false`.
509    ///
510    /// Unlike the env var, the **config flag reaches the managed (service-spawned)
511    /// proxy**, which only reads `config.toml` — that is the whole point of #590:
512    /// `proxy enable`/`restart` start the proxy via launchd/systemd, which never
513    /// inherits the shell's `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`.
514    pub fn allows_custom_upstream(&self) -> bool {
515        std::env::var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM").is_ok()
516            || self.allow_custom_upstream.unwrap_or(false)
517    }
518
519    /// True when any `*_upstream` configured in `config.toml` (env-independent) is a
520    /// custom HTTPS host outside the built-in allowlist — i.e. one that resolves
521    /// only with the [`Self::allows_custom_upstream`] opt-in. Plaintext-HTTP custom
522    /// hosts are governed by `allow_insecure_http_upstream` instead, so they are
523    /// excluded here. Lets `proxy enable`/`restart` persist the opt-in (so the
524    /// managed proxy honors it) and `proxy status` explain a blocked upstream,
525    /// without touching the allowlisted-host case (#590).
526    #[must_use]
527    pub fn has_custom_host_upstream(&self) -> bool {
528        [
529            self.anthropic_upstream.as_deref(),
530            self.openai_upstream.as_deref(),
531            self.chatgpt_upstream.as_deref(),
532            self.gemini_upstream.as_deref(),
533        ]
534        .into_iter()
535        .flatten()
536        .filter_map(normalize_url_opt)
537        .any(|u| is_custom_upstream_host(&u))
538    }
539
540    /// `(env var, configured value, provider default)` for one provider.
541    fn provider_spec(&self, provider: ProxyProvider) -> (&'static str, Option<&str>, &'static str) {
542        match provider {
543            ProxyProvider::Anthropic => (
544                "LEAN_CTX_ANTHROPIC_UPSTREAM",
545                self.anthropic_upstream.as_deref(),
546                "https://api.anthropic.com",
547            ),
548            ProxyProvider::OpenAi => (
549                "LEAN_CTX_OPENAI_UPSTREAM",
550                self.openai_upstream.as_deref(),
551                "https://api.openai.com",
552            ),
553            ProxyProvider::ChatGpt => (
554                "LEAN_CTX_CHATGPT_UPSTREAM",
555                self.chatgpt_upstream.as_deref(),
556                "https://chatgpt.com",
557            ),
558            ProxyProvider::Gemini => (
559                "LEAN_CTX_GEMINI_UPSTREAM",
560                self.gemini_upstream.as_deref(),
561                "https://generativelanguage.googleapis.com",
562            ),
563        }
564    }
565
566    /// Resolve one upstream with precedence `LEAN_CTX_*_UPSTREAM` env var >
567    /// `[proxy].*_upstream` (config.toml) > provider default.
568    ///
569    /// Returns `Err` when a value is *present but invalid* so a live reload can
570    /// keep the last good value instead of silently rerouting to the default; an
571    /// *absent* value resolves to the provider default (`Ok`).
572    fn resolve_upstream_checked(&self, provider: ProxyProvider) -> Result<String, String> {
573        self.resolve_upstream_inner(provider, true)
574    }
575
576    /// Shared resolver for [`resolve_upstream_checked`] and the disk-only view.
577    /// `use_env = false` ignores the `LEAN_CTX_*_UPSTREAM` override and yields
578    /// the config.toml truth a freshly (re)started managed proxy would serve.
579    fn resolve_upstream_inner(
580        &self,
581        provider: ProxyProvider,
582        use_env: bool,
583    ) -> Result<String, String> {
584        let (env_var, config_val, default) = self.provider_spec(provider);
585        let env_val = if use_env {
586            std::env::var(env_var)
587                .ok()
588                .and_then(|v| normalize_url_opt(&v))
589        } else {
590            None
591        };
592        let candidate = env_val.or_else(|| config_val.and_then(normalize_url_opt));
593        match candidate {
594            None => Ok(normalize_url(default)),
595            Some(url) => validate_upstream_url(
596                &url,
597                self.allows_insecure_http_upstream(),
598                self.allows_custom_upstream(),
599            ),
600        }
601    }
602
603    /// Effective upstream for a provider (env > config > default). An invalid
604    /// configured/env value falls back to the provider default (logged) — the
605    /// safe choice at startup.
606    pub fn resolve_upstream(&self, provider: ProxyProvider) -> String {
607        match self.resolve_upstream_checked(provider) {
608            Ok(url) => url,
609            Err(e) => {
610                tracing::warn!("upstream validation failed, using default: {e}");
611                normalize_url(self.provider_spec(provider).2)
612            }
613        }
614    }
615
616    /// Resolve all three upstreams at once (startup snapshot, env-aware).
617    pub fn resolve_all(&self) -> Upstreams {
618        Upstreams {
619            anthropic: self.resolve_upstream(ProxyProvider::Anthropic),
620            openai: self.resolve_upstream(ProxyProvider::OpenAi),
621            chatgpt: self.resolve_upstream(ProxyProvider::ChatGpt),
622            gemini: self.resolve_upstream(ProxyProvider::Gemini),
623        }
624    }
625
626    /// Resolve all upstreams from config.toml only (ignoring `LEAN_CTX_*` env) —
627    /// the values a freshly (re)started managed proxy would serve. Used by
628    /// status/doctor to detect drift from a running proxy's live upstream (#449).
629    pub fn resolve_all_disk(&self) -> Upstreams {
630        let pick = |provider: ProxyProvider| {
631            self.resolve_upstream_inner(provider, false)
632                .unwrap_or_else(|_| normalize_url(self.provider_spec(provider).2))
633        };
634        Upstreams {
635            anthropic: pick(ProxyProvider::Anthropic),
636            openai: pick(ProxyProvider::OpenAi),
637            chatgpt: pick(ProxyProvider::ChatGpt),
638            gemini: pick(ProxyProvider::Gemini),
639        }
640    }
641
642    /// Re-resolve upstreams for a *running* proxy (#449). For any provider whose
643    /// currently configured/env value fails validation, the last good value is
644    /// kept instead of rerouting live traffic to the provider default — so a typo
645    /// in config.toml can never silently redirect in-flight requests.
646    pub fn refresh_upstreams(&self, last: &Upstreams) -> Upstreams {
647        let keep = |provider: ProxyProvider, prev: &str| {
648            self.resolve_upstream_checked(provider).unwrap_or_else(|e| {
649                tracing::warn!("upstream invalid, keeping {prev}: {e}");
650                prev.to_string()
651            })
652        };
653        Upstreams {
654            anthropic: keep(ProxyProvider::Anthropic, &last.anthropic),
655            openai: keep(ProxyProvider::OpenAi, &last.openai),
656            chatgpt: keep(ProxyProvider::ChatGpt, &last.chatgpt),
657            gemini: keep(ProxyProvider::Gemini, &last.gemini),
658        }
659    }
660}
661
662/// The three resolved provider upstreams a running proxy forwards to. Published
663/// to request handlers via a `tokio::sync::watch` channel so a config change is
664/// picked up live, without a proxy restart (#449).
665#[derive(Debug, Clone, PartialEq, Eq)]
666pub struct Upstreams {
667    pub anthropic: String,
668    pub openai: String,
669    pub chatgpt: String,
670    pub gemini: String,
671}
672
673#[derive(Debug, Clone, Copy)]
674pub enum ProxyProvider {
675    Anthropic,
676    OpenAi,
677    ChatGpt,
678    Gemini,
679}
680
681/// Why a running proxy's live upstream differs from what the operator expects.
682#[derive(Debug, Clone, Copy, PartialEq, Eq)]
683pub enum UpstreamDrift {
684    /// A `LEAN_CTX_*_UPSTREAM` env var is set in *this* process but the proxy
685    /// serves a different value — the env never reached the MCP/service-spawned
686    /// proxy. This is the #449 trap: Codex (and other MCP hosts) launch the
687    /// server with a stripped, allowlisted env that omits `LEAN_CTX_*_UPSTREAM`,
688    /// so the proxy it spawns never sees it. Fix: persist it to config.toml,
689    /// which the proxy reads live.
690    EnvNotApplied,
691    /// The proxy serves a value other than config.toml resolves to: it was
692    /// started with an env override that now masks a later config edit. Fix:
693    /// `lean-ctx proxy restart`.
694    ConfigNotApplied,
695}
696
697/// The `LEAN_CTX_*_UPSTREAM` override visible to *this* process for a provider,
698/// normalized (`None` if unset/blank). Lets status/doctor explain why an env var
699/// a user exported in their shell never reaches an MCP/service-spawned proxy.
700pub fn env_upstream_override(provider: ProxyProvider) -> Option<String> {
701    let var = match provider {
702        ProxyProvider::Anthropic => "LEAN_CTX_ANTHROPIC_UPSTREAM",
703        ProxyProvider::OpenAi => "LEAN_CTX_OPENAI_UPSTREAM",
704        ProxyProvider::ChatGpt => "LEAN_CTX_CHATGPT_UPSTREAM",
705        ProxyProvider::Gemini => "LEAN_CTX_GEMINI_UPSTREAM",
706    };
707    std::env::var(var).ok().and_then(|v| normalize_url_opt(&v))
708}
709
710/// Diagnose upstream drift for one provider from the CLI-visible env override
711/// (`env`), the config.toml value (`disk`) and the proxy's live value (`live`).
712/// `None` means in sync.
713pub fn diagnose_drift(env: Option<&str>, disk: &str, live: &str) -> Option<UpstreamDrift> {
714    if let Some(env) = env {
715        // An env override is present in this process: the proxy honours it only
716        // if it was started with it. If the proxy serves something else, the env
717        // never reached it (#449). If it matches, that is consistent (no drift).
718        return (env != live).then_some(UpstreamDrift::EnvNotApplied);
719    }
720    // No env override here: the proxy should mirror config.toml.
721    (disk != live).then_some(UpstreamDrift::ConfigNotApplied)
722}
723
724/// Resolve a tri-state boolean toggle for the default-**on** proxy features: an
725/// explicit `on`/`off`-style environment variable wins, then the config
726/// `Option<bool>`, else `default`. Lets an operator force a feature on **or** off
727/// from the shell; an unparseable value is ignored so a typo can never silently
728/// flip it (mirrors [`ProxyConfig::live_compresses`]).
729fn env_bool_or(env_key: &str, configured: Option<bool>, default: bool) -> bool {
730    if let Ok(raw) = std::env::var(env_key) {
731        match raw.trim().to_ascii_lowercase().as_str() {
732            "1" | "true" | "yes" | "on" => return true,
733            "0" | "false" | "no" | "off" => return false,
734            _ => {}
735        }
736    }
737    configured.unwrap_or(default)
738}
739
740/// Built-in default live-compress exclusion (#481). Serena's code-reading tools
741/// (`find_symbol`/`find_referencing_symbols`/`search_for_pattern`) return source
742/// bodies the model edits, yet are mis-bucketed as `Search` by name, so the proxy
743/// would otherwise gut them. Protect anything namespaced `serena` by default.
744fn default_live_compress_exclude() -> Vec<String> {
745    vec!["serena".to_string()]
746}
747
748pub fn normalize_url(value: &str) -> String {
749    value.trim().trim_end_matches('/').to_string()
750}
751
752pub fn normalize_url_opt(value: &str) -> Option<String> {
753    let trimmed = normalize_url(value);
754    if trimmed.is_empty() {
755        None
756    } else {
757        Some(trimmed)
758    }
759}
760
761const ALLOWED_UPSTREAM_HOSTS: &[&str] = &[
762    "api.anthropic.com",
763    "api.openai.com",
764    "chatgpt.com",
765    "generativelanguage.googleapis.com",
766];
767
768pub(super) fn validate_upstream_url(
769    url: &str,
770    allow_insecure_http: bool,
771    allow_custom_host: bool,
772) -> Result<String, String> {
773    let normalized = normalize_url(url);
774    // Loopback HTTP never leaves the machine — always allowed.
775    if is_local_proxy_url(&normalized) {
776        return Ok(normalized);
777    }
778
779    // A non-loopback plaintext `http://` upstream is reachable only through the
780    // explicit opt-in (#440). The old code rejected it on the HTTPS check *before*
781    // any override could apply, and pointed at `LEAN_CTX_ALLOW_CUSTOM_UPSTREAM`,
782    // which never lifted the scheme restriction. Handle it up front: the opt-in
783    // implies a deliberate custom host on a trusted local network, so it needs no
784    // separate allowlist check; otherwise give a hint that actually works.
785    if normalized.starts_with("http://") {
786        if allow_insecure_http {
787            return Ok(normalized);
788        }
789        return Err(format!(
790            "upstream URL must use HTTPS: {normalized} (for a trusted local-network HTTP \
791             upstream opt in with LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM=1 or \
792             `[proxy] allow_insecure_http_upstream = true`)"
793        ));
794    }
795    let Some(host_segment) = normalized.strip_prefix("https://") else {
796        return Err(format!(
797            "upstream URL must start with http:// or https://: {normalized}"
798        ));
799    };
800
801    let host = host_segment.split('/').next().unwrap_or("");
802    let host_no_port = host.split(':').next().unwrap_or(host);
803    if ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port) || allow_custom_host {
804        Ok(normalized)
805    } else {
806        Err(format!(
807            "upstream host '{host_no_port}' not in allowlist {ALLOWED_UPSTREAM_HOSTS:?} (for a \
808             custom upstream host opt in with LEAN_CTX_ALLOW_CUSTOM_UPSTREAM=1 or \
809             `[proxy] allow_custom_upstream = true`)"
810        ))
811    }
812}
813
814/// True when `url` is an HTTPS upstream whose host is not in the built-in
815/// allowlist (and not loopback) — the case the `allow_custom_upstream` opt-in
816/// governs. Plaintext-HTTP custom hosts are governed by
817/// `allow_insecure_http_upstream` instead, so they are excluded here.
818fn is_custom_upstream_host(url: &str) -> bool {
819    let n = normalize_url(url);
820    if is_local_proxy_url(&n) {
821        return false;
822    }
823    let Some(host_segment) = n.strip_prefix("https://") else {
824        return false;
825    };
826    let host = host_segment.split('/').next().unwrap_or("");
827    let host_no_port = host.split(':').next().unwrap_or(host);
828    !host_no_port.is_empty() && !ALLOWED_UPSTREAM_HOSTS.contains(&host_no_port)
829}
830
831pub fn is_local_proxy_url(value: &str) -> bool {
832    let n = normalize_url(value);
833    n.starts_with("http://127.0.0.1:")
834        || n.starts_with("http://localhost:")
835        || n.starts_with("http://[::1]:")
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841
842    #[test]
843    fn loopback_http_is_always_allowed() {
844        assert_eq!(
845            validate_upstream_url("http://127.0.0.1:4444", false, false).unwrap(),
846            "http://127.0.0.1:4444"
847        );
848        assert_eq!(
849            validate_upstream_url("http://localhost:2455/", false, false).unwrap(),
850            "http://localhost:2455"
851        );
852    }
853
854    #[test]
855    fn https_allowlisted_host_is_allowed() {
856        assert_eq!(
857            validate_upstream_url("https://api.openai.com", false, false).unwrap(),
858            "https://api.openai.com"
859        );
860    }
861
862    #[test]
863    fn non_loopback_http_is_rejected_without_optin() {
864        let err =
865            validate_upstream_url("http://host.docker.internal:2455", false, false).unwrap_err();
866        // The hint must point at the flag that actually lifts the scheme check
867        // (#440). The old message pointed at LEAN_CTX_ALLOW_CUSTOM_UPSTREAM,
868        // which never bypassed the HTTPS requirement.
869        assert!(
870            err.contains("LEAN_CTX_ALLOW_INSECURE_HTTP_UPSTREAM"),
871            "hint must name the working opt-in, got: {err}"
872        );
873    }
874
875    #[test]
876    fn non_loopback_http_is_allowed_with_optin() {
877        assert_eq!(
878            validate_upstream_url("http://host.docker.internal:2455", true, false).unwrap(),
879            "http://host.docker.internal:2455"
880        );
881    }
882
883    #[test]
884    fn unknown_scheme_is_rejected() {
885        assert!(validate_upstream_url("ftp://example.com", true, true).is_err());
886    }
887
888    #[test]
889    fn https_custom_host_is_rejected_without_optin() {
890        // #590: a custom HTTPS host (e.g. a corporate gateway) is blocked unless
891        // the operator opts in. The hint must name BOTH the env var and the
892        // config flag — only the config flag reaches the managed proxy.
893        let err =
894            validate_upstream_url("https://gw.corp.example/anthropic", false, false).unwrap_err();
895        assert!(
896            err.contains("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM") && err.contains("allow_custom_upstream"),
897            "hint must name both opt-ins, got: {err}"
898        );
899    }
900
901    #[test]
902    fn https_custom_host_is_allowed_with_optin() {
903        // The opt-in (env or `[proxy] allow_custom_upstream`) lifts the allowlist.
904        assert_eq!(
905            validate_upstream_url("https://gw.corp.example/anthropic", false, true).unwrap(),
906            "https://gw.corp.example/anthropic"
907        );
908    }
909
910    #[test]
911    fn config_flag_enables_custom_upstream_optin() {
912        // #590: mirrors `config_flag_enables_insecure_http_optin`. `Some(true)`
913        // resolves to true regardless of the environment, so no env mutation.
914        let cfg = ProxyConfig {
915            allow_custom_upstream: Some(true),
916            ..Default::default()
917        };
918        assert!(cfg.allows_custom_upstream());
919    }
920
921    #[test]
922    fn has_custom_host_upstream_detects_only_custom_https() {
923        // A custom HTTPS host counts; an allowlisted host, a loopback URL, and an
924        // unset upstream do not (the http case is the insecure-http opt-in's job).
925        assert!(
926            ProxyConfig {
927                anthropic_upstream: Some("https://gw.corp.example/anthropic".into()),
928                ..Default::default()
929            }
930            .has_custom_host_upstream()
931        );
932        assert!(
933            !ProxyConfig {
934                openai_upstream: Some("https://api.openai.com".into()),
935                anthropic_upstream: Some("http://127.0.0.1:4444".into()),
936                ..Default::default()
937            }
938            .has_custom_host_upstream()
939        );
940        assert!(!ProxyConfig::default().has_custom_host_upstream());
941    }
942
943    #[test]
944    fn cold_prefix_repack_is_opt_in_and_config_enables() {
945        // #480: off by default (a wrong cold guess re-bills reads as writes ~12x),
946        // enabled via config. Isolate from a developer shell that may export the
947        // env override.
948        let _lock = crate::core::data_dir::test_env_lock();
949        crate::test_env::remove_var("LEAN_CTX_PROXY_COLD_PREFIX_REPACK");
950        assert!(
951            !ProxyConfig::default().repacks_cold_prefix(),
952            "cold-prefix repack must be opt-in (off by default)"
953        );
954        let cfg = ProxyConfig {
955            cold_prefix_repack: Some(true),
956            ..Default::default()
957        };
958        assert!(cfg.repacks_cold_prefix());
959    }
960
961    #[test]
962    fn ccr_inband_is_opt_in_and_config_enables() {
963        // #493: off by default (the splice mutates provider-visible content for
964        // the expand turn), enabled via config. Isolate from a developer shell
965        // that may export the env override.
966        let _lock = crate::core::data_dir::test_env_lock();
967        crate::test_env::remove_var("LEAN_CTX_PROXY_CCR_INBAND");
968        assert!(
969            !ProxyConfig::default().ccr_inband_enabled(),
970            "in-band CCR must be opt-in (off by default)"
971        );
972        let cfg = ProxyConfig {
973            ccr_inband: Some(true),
974            ..Default::default()
975        };
976        assert!(cfg.ccr_inband_enabled());
977    }
978
979    #[test]
980    fn cache_breakpoint_is_opt_in_and_config_enables() {
981        // #939: off by default (it reshapes the provider-visible system field),
982        // enabled via config. Isolate from a developer shell that may export the
983        // env override.
984        let _lock = crate::core::data_dir::test_env_lock();
985        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_BREAKPOINT");
986        assert!(
987            !ProxyConfig::default().cache_breakpoint_enabled(),
988            "cache-breakpoint injection must be opt-in (off by default)"
989        );
990        let cfg = ProxyConfig {
991            cache_breakpoint: Some(true),
992            ..Default::default()
993        };
994        assert!(cfg.cache_breakpoint_enabled());
995    }
996
997    #[test]
998    fn cache_aligner_defaults_on_and_config_disables() {
999        // #986 premium defaults: the volatile-field scan is measurement-only and
1000        // strictly cache-safe, so it ships on by default; `false` opts out.
1001        // Isolate from a developer shell that may export the env override.
1002        let _lock = crate::core::data_dir::test_env_lock();
1003        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_ALIGNER");
1004        assert!(
1005            ProxyConfig::default().cache_aligner_enabled(),
1006            "cache-aligner telemetry must be on by default (measurement-only, safe)"
1007        );
1008        let cfg = ProxyConfig {
1009            cache_aligner: Some(false),
1010            ..Default::default()
1011        };
1012        assert!(!cfg.cache_aligner_enabled(), "explicit false opts out");
1013    }
1014
1015    #[test]
1016    fn cache_aligner_legacy_opt_in_still_enables() {
1017        // An explicit `true` (a pre-#986 config) keeps working unchanged. Isolate
1018        // from a developer shell that may export the env override.
1019        let _lock = crate::core::data_dir::test_env_lock();
1020        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_ALIGNER");
1021        let cfg = ProxyConfig {
1022            cache_aligner: Some(true),
1023            ..Default::default()
1024        };
1025        assert!(cfg.cache_aligner_enabled());
1026    }
1027
1028    #[test]
1029    fn cache_align_relocate_is_opt_in_and_config_enables() {
1030        // #974: off by default (it reshapes the provider-visible system field by
1031        // relocating volatile values to the tail). Isolate from a developer shell
1032        // that may export the env override.
1033        let _lock = crate::core::data_dir::test_env_lock();
1034        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_ALIGN_RELOCATE");
1035        assert!(
1036            !ProxyConfig::default().cache_align_relocate_enabled(),
1037            "active cache-aligner relocate must be opt-in (off by default)"
1038        );
1039        let cfg = ProxyConfig {
1040            cache_align_relocate: Some(true),
1041            ..Default::default()
1042        };
1043        assert!(cfg.cache_align_relocate_enabled());
1044    }
1045
1046    #[test]
1047    fn cache_policy_defaults_on_and_can_be_disabled() {
1048        // #986 premium defaults: telemetry + a more-conservative repack gate are
1049        // both strictly safe, so cache-economics ships on by default and is
1050        // opt-out via config `false` or `LEAN_CTX_PROXY_CACHE_POLICY=off`. Isolate
1051        // from a developer shell that may export the env override.
1052        let _lock = crate::core::data_dir::test_env_lock();
1053        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_POLICY");
1054        assert!(
1055            ProxyConfig::default().cache_policy_enabled(),
1056            "cache-economics must be on by default (measurement + safe gate)"
1057        );
1058        let cfg = ProxyConfig {
1059            cache_policy: Some(false),
1060            ..Default::default()
1061        };
1062        assert!(!cfg.cache_policy_enabled(), "explicit false opts out");
1063
1064        // An explicit env `off` wins even over a config `true`.
1065        crate::test_env::set_var("LEAN_CTX_PROXY_CACHE_POLICY", "off");
1066        let on = ProxyConfig {
1067            cache_policy: Some(true),
1068            ..Default::default()
1069        };
1070        assert!(!on.cache_policy_enabled(), "env off overrides config true");
1071        crate::test_env::remove_var("LEAN_CTX_PROXY_CACHE_POLICY");
1072    }
1073
1074    #[test]
1075    fn effort_defaults_off_and_config_sets_it() {
1076        // #834: cache-safe effort control is opt-in. Isolate from a developer
1077        // shell that may export the env override.
1078        let _lock = crate::core::data_dir::test_env_lock();
1079        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
1080        assert_eq!(
1081            ProxyConfig::default().resolved_effort(),
1082            None,
1083            "effort control must be opt-in (off by default)"
1084        );
1085        let cfg = ProxyConfig {
1086            effort: Some("low".into()),
1087            ..Default::default()
1088        };
1089        assert_eq!(
1090            cfg.resolved_effort(),
1091            Some(crate::core::config::Effort::Low)
1092        );
1093        // An unknown configured value resolves to off — never a silent default.
1094        let typo = ProxyConfig {
1095            effort: Some("lowish".into()),
1096            ..Default::default()
1097        };
1098        assert_eq!(typo.resolved_effort(), None);
1099    }
1100
1101    #[test]
1102    fn effort_env_overrides_and_off_disables() {
1103        use crate::core::config::Effort;
1104        let _lock = crate::core::data_dir::test_env_lock();
1105        let cfg = ProxyConfig {
1106            effort: Some("high".into()),
1107            ..Default::default()
1108        };
1109        // A valid env level wins over config.
1110        crate::test_env::set_var("LEAN_CTX_PROXY_EFFORT", "minimal");
1111        assert_eq!(cfg.resolved_effort(), Some(Effort::Minimal));
1112        // `off` explicitly disables even a configured level.
1113        crate::test_env::set_var("LEAN_CTX_PROXY_EFFORT", "off");
1114        assert_eq!(cfg.resolved_effort(), None);
1115        // A blank/garbage env value is ignored → falls back to config.
1116        crate::test_env::set_var("LEAN_CTX_PROXY_EFFORT", "   ");
1117        assert_eq!(cfg.resolved_effort(), Some(Effort::High));
1118        crate::test_env::remove_var("LEAN_CTX_PROXY_EFFORT");
1119    }
1120
1121    #[test]
1122    fn prose_ranker_defaults_to_auto_and_config_sets_it() {
1123        // #895: premium extractive path is the default; `truncate`/`off` selects
1124        // the legacy squeeze; a typo can never silently disable the premium path.
1125        let _lock = crate::core::data_dir::test_env_lock();
1126        crate::test_env::remove_var("LEAN_CTX_PROXY_PROSE_RANKER");
1127        assert_eq!(
1128            ProxyConfig::default().resolved_prose_ranker(),
1129            ProseRanker::Auto
1130        );
1131        let truncate = ProxyConfig {
1132            prose_ranker: Some("truncate".into()),
1133            ..Default::default()
1134        };
1135        assert_eq!(truncate.resolved_prose_ranker(), ProseRanker::Truncate);
1136        let off = ProxyConfig {
1137            prose_ranker: Some("off".into()),
1138            ..Default::default()
1139        };
1140        assert_eq!(off.resolved_prose_ranker(), ProseRanker::Truncate);
1141        let extractive = ProxyConfig {
1142            prose_ranker: Some("extractive".into()),
1143            ..Default::default()
1144        };
1145        assert_eq!(extractive.resolved_prose_ranker(), ProseRanker::Extractive);
1146        let typo = ProxyConfig {
1147            prose_ranker: Some("extractiveish".into()),
1148            ..Default::default()
1149        };
1150        assert_eq!(
1151            typo.resolved_prose_ranker(),
1152            ProseRanker::Auto,
1153            "unknown value must resolve to Auto, never silently off"
1154        );
1155    }
1156
1157    #[test]
1158    fn output_holdout_defaults_off_and_clamps() {
1159        let _lock = crate::core::data_dir::test_env_lock();
1160        crate::test_env::remove_var("LEAN_CTX_PROXY_OUTPUT_HOLDOUT");
1161        assert_eq!(ProxyConfig::default().output_holdout_fraction(), 0.0);
1162        let cfg = ProxyConfig {
1163            output_holdout: Some(0.2),
1164            ..Default::default()
1165        };
1166        assert!((cfg.output_holdout_fraction() - 0.2).abs() < f64::EPSILON);
1167        let over = ProxyConfig {
1168            output_holdout: Some(5.0),
1169            ..Default::default()
1170        };
1171        assert_eq!(over.output_holdout_fraction(), 1.0, "clamped into [0,1]");
1172    }
1173
1174    #[test]
1175    fn verbosity_steer_defaults_off_and_env_overrides() {
1176        let _lock = crate::core::data_dir::test_env_lock();
1177        crate::test_env::remove_var("LEAN_CTX_PROXY_VERBOSITY_STEER");
1178        assert!(!ProxyConfig::default().verbosity_steer_enabled());
1179        let cfg = ProxyConfig {
1180            verbosity_steer: Some(true),
1181            ..Default::default()
1182        };
1183        assert!(cfg.verbosity_steer_enabled());
1184        crate::test_env::set_var("LEAN_CTX_PROXY_VERBOSITY_STEER", "on");
1185        assert!(ProxyConfig::default().verbosity_steer_enabled());
1186        crate::test_env::remove_var("LEAN_CTX_PROXY_VERBOSITY_STEER");
1187    }
1188
1189    #[test]
1190    fn codex_chatgpt_proxy_flag_reads_config_and_env() {
1191        // Isolate from a developer shell that may export the env override.
1192        let _lock = crate::core::data_dir::test_env_lock();
1193        crate::test_env::remove_var("LEAN_CTX_CODEX_CHATGPT_PROXY");
1194        assert!(
1195            !ProxyConfig::default().codex_chatgpt_proxy_enabled(),
1196            "Codex ChatGPT proxy opt-in defaults off"
1197        );
1198        let cfg = ProxyConfig {
1199            codex_chatgpt_proxy: Some(true),
1200            ..Default::default()
1201        };
1202        assert!(cfg.codex_chatgpt_proxy_enabled());
1203        // An explicit env value wins even over an unset/false config.
1204        crate::test_env::set_var("LEAN_CTX_CODEX_CHATGPT_PROXY", "1");
1205        assert!(ProxyConfig::default().codex_chatgpt_proxy_enabled());
1206        crate::test_env::remove_var("LEAN_CTX_CODEX_CHATGPT_PROXY");
1207    }
1208
1209    #[test]
1210    fn prose_ranker_env_overrides_config() {
1211        let _lock = crate::core::data_dir::test_env_lock();
1212        let cfg = ProxyConfig {
1213            prose_ranker: Some("auto".into()),
1214            ..Default::default()
1215        };
1216        crate::test_env::set_var("LEAN_CTX_PROXY_PROSE_RANKER", "truncate");
1217        assert_eq!(cfg.resolved_prose_ranker(), ProseRanker::Truncate);
1218        crate::test_env::remove_var("LEAN_CTX_PROXY_PROSE_RANKER");
1219    }
1220
1221    #[test]
1222    fn config_flag_enables_insecure_http_optin() {
1223        // `Some(true)` resolves to `true` regardless of the environment, so this
1224        // assertion is robust without mutating process-global env vars.
1225        let cfg = ProxyConfig {
1226            allow_insecure_http_upstream: Some(true),
1227            ..Default::default()
1228        };
1229        assert!(cfg.allows_insecure_http_upstream());
1230    }
1231
1232    /// `resolve_all_disk` ignores `LEAN_CTX_*_UPSTREAM` env by construction, so
1233    /// these assertions are env-independent (no lock needed). Loopback HTTP is an
1234    /// always-valid custom upstream (no allowlist / opt-in required).
1235    #[test]
1236    fn resolve_all_disk_uses_config_then_default() {
1237        let cfg = ProxyConfig {
1238            openai_upstream: Some("http://127.0.0.1:19101".into()),
1239            ..Default::default()
1240        };
1241        let up = cfg.resolve_all_disk();
1242        assert_eq!(up.openai, "http://127.0.0.1:19101");
1243        assert_eq!(up.anthropic, "https://api.anthropic.com");
1244        assert_eq!(up.chatgpt, "https://chatgpt.com");
1245        assert_eq!(up.gemini, "https://generativelanguage.googleapis.com");
1246    }
1247
1248    #[test]
1249    fn resolve_all_disk_honors_custom_upstream_via_config_flag() {
1250        // #590: `resolve_all_disk` is the env-independent view — exactly what the
1251        // managed (service-spawned) proxy serves, since it never sees the shell's
1252        // LEAN_CTX_ALLOW_CUSTOM_UPSTREAM. With the config opt-in, a custom HTTPS
1253        // host resolves; without it, it falls back to the provider default. This
1254        // is the regression guard for the reported bug.
1255        let custom = ProxyConfig {
1256            anthropic_upstream: Some("https://gw.corp.example/anthropic".into()),
1257            allow_custom_upstream: Some(true),
1258            ..Default::default()
1259        };
1260        assert_eq!(
1261            custom.resolve_all_disk().anthropic,
1262            "https://gw.corp.example/anthropic",
1263            "config flag must let the managed proxy honor the custom upstream"
1264        );
1265
1266        let blocked = ProxyConfig {
1267            anthropic_upstream: Some("https://gw.corp.example/anthropic".into()),
1268            ..Default::default()
1269        };
1270        // Isolate from a developer shell that may export the env opt-in.
1271        let _lock = crate::core::data_dir::test_env_lock();
1272        crate::test_env::remove_var("LEAN_CTX_ALLOW_CUSTOM_UPSTREAM");
1273        assert_eq!(
1274            blocked.resolve_all_disk().anthropic,
1275            "https://api.anthropic.com",
1276            "without the opt-in the custom host is rejected → provider default"
1277        );
1278    }
1279
1280    #[test]
1281    fn resolve_all_disk_normalizes_trailing_slash() {
1282        let cfg = ProxyConfig {
1283            openai_upstream: Some("http://127.0.0.1:19101/".into()),
1284            ..Default::default()
1285        };
1286        assert_eq!(cfg.resolve_all_disk().openai, "http://127.0.0.1:19101");
1287    }
1288
1289    #[test]
1290    fn refresh_keeps_last_good_on_invalid_config() {
1291        // `refresh_upstreams` is env-aware; isolate from a developer's shell that
1292        // may export LEAN_CTX_OPENAI_UPSTREAM (e.g. while reproducing #449).
1293        let _lock = crate::core::data_dir::test_env_lock();
1294        crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
1295
1296        // A typo in config.toml must never reroute a live proxy to the default.
1297        let last = Upstreams {
1298            anthropic: "https://api.anthropic.com".into(),
1299            openai: "http://127.0.0.1:19101".into(),
1300            chatgpt: "https://chatgpt.com".into(),
1301            gemini: "https://generativelanguage.googleapis.com".into(),
1302        };
1303        let cfg = ProxyConfig {
1304            openai_upstream: Some("not-a-valid-url".into()),
1305            ..Default::default()
1306        };
1307        assert_eq!(
1308            cfg.refresh_upstreams(&last).openai,
1309            "http://127.0.0.1:19101",
1310            "invalid upstream → keep last good, never silently fall to default"
1311        );
1312    }
1313
1314    #[test]
1315    fn refresh_adopts_valid_config_change() {
1316        let _lock = crate::core::data_dir::test_env_lock();
1317        crate::test_env::remove_var("LEAN_CTX_OPENAI_UPSTREAM");
1318
1319        let last = Upstreams {
1320            anthropic: "https://api.anthropic.com".into(),
1321            openai: "http://127.0.0.1:19101".into(),
1322            chatgpt: "https://chatgpt.com".into(),
1323            gemini: "https://generativelanguage.googleapis.com".into(),
1324        };
1325        let cfg = ProxyConfig {
1326            openai_upstream: Some("http://127.0.0.1:19102".into()),
1327            ..Default::default()
1328        };
1329        assert_eq!(
1330            cfg.refresh_upstreams(&last).openai,
1331            "http://127.0.0.1:19102"
1332        );
1333    }
1334
1335    #[test]
1336    fn diagnose_drift_env_set_but_proxy_serves_other() {
1337        // The exact #449 / Codex case: env exported in the shell, but the
1338        // MCP-spawned proxy serves config.toml → the env never reached it.
1339        assert_eq!(
1340            diagnose_drift(
1341                Some("http://127.0.0.1:2455"),
1342                "https://api.openai.com",
1343                "https://api.openai.com"
1344            ),
1345            Some(UpstreamDrift::EnvNotApplied)
1346        );
1347    }
1348
1349    #[test]
1350    fn diagnose_drift_env_consistent_is_in_sync() {
1351        // Proxy was started with the env value and serves it → not drift.
1352        assert_eq!(
1353            diagnose_drift(
1354                Some("http://127.0.0.1:2455"),
1355                "https://api.openai.com",
1356                "http://127.0.0.1:2455"
1357            ),
1358            None
1359        );
1360    }
1361
1362    #[test]
1363    fn diagnose_drift_config_changed_needs_restart() {
1364        assert_eq!(
1365            diagnose_drift(None, "http://127.0.0.1:2455", "https://api.openai.com"),
1366            Some(UpstreamDrift::ConfigNotApplied)
1367        );
1368    }
1369
1370    #[test]
1371    fn diagnose_drift_in_sync() {
1372        assert_eq!(
1373            diagnose_drift(None, "https://api.openai.com", "https://api.openai.com"),
1374            None
1375        );
1376    }
1377
1378    #[test]
1379    fn role_aggressiveness_defaults_to_off() {
1380        // Opt-in: a fresh config compresses no prose, so the proxy stays
1381        // byte-for-byte unchanged until an operator sets a value (#710).
1382        let cfg = ProxyConfig::default();
1383        // Isolate from a developer shell that may export the override.
1384        let _lock = crate::core::data_dir::test_env_lock();
1385        crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
1386        crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
1387        assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::System), None);
1388        assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::User), None);
1389    }
1390
1391    #[test]
1392    fn role_aggressiveness_reads_config_and_clamps() {
1393        let _lock = crate::core::data_dir::test_env_lock();
1394        crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
1395        crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
1396        let cfg = ProxyConfig {
1397            role_aggressiveness: RoleAggressiveness {
1398                system: Some(0.7),
1399                user: Some(1.5),
1400            },
1401            ..Default::default()
1402        };
1403        assert_eq!(
1404            cfg.resolved_role_aggressiveness(ProseRole::System),
1405            Some(0.7)
1406        );
1407        // Out-of-range config values are clamped into [0,1].
1408        assert_eq!(cfg.resolved_role_aggressiveness(ProseRole::User), Some(1.0));
1409    }
1410
1411    #[test]
1412    fn role_aggressiveness_env_overrides_config() {
1413        let _lock = crate::core::data_dir::test_env_lock();
1414        crate::test_env::set_var("LEAN_CTX_PROXY_SYSTEM_AGGR", "0.25");
1415        let cfg = ProxyConfig {
1416            role_aggressiveness: RoleAggressiveness {
1417                system: Some(0.9),
1418                user: None,
1419            },
1420            ..Default::default()
1421        };
1422        assert_eq!(
1423            cfg.resolved_role_aggressiveness(ProseRole::System),
1424            Some(0.25),
1425            "env override must win over the configured value"
1426        );
1427        crate::test_env::remove_var("LEAN_CTX_PROXY_SYSTEM_AGGR");
1428    }
1429
1430    #[test]
1431    fn role_aggressiveness_ignores_blank_env() {
1432        let _lock = crate::core::data_dir::test_env_lock();
1433        crate::test_env::set_var("LEAN_CTX_PROXY_USER_AGGR", "  ");
1434        let cfg = ProxyConfig {
1435            role_aggressiveness: RoleAggressiveness {
1436                system: None,
1437                user: Some(0.4),
1438            },
1439            ..Default::default()
1440        };
1441        assert_eq!(
1442            cfg.resolved_role_aggressiveness(ProseRole::User),
1443            Some(0.4),
1444            "a blank/garbage env value must fall back to config, not disable it"
1445        );
1446        crate::test_env::remove_var("LEAN_CTX_PROXY_USER_AGGR");
1447    }
1448
1449    #[test]
1450    fn live_compress_defaults_on_and_config_disables() {
1451        // #481: default ON (today's behaviour); a config `false` opts into the
1452        // meter-only mode. Isolate from a developer shell exporting the override.
1453        let _lock = crate::core::data_dir::test_env_lock();
1454        crate::test_env::remove_var("LEAN_CTX_PROXY_LIVE_COMPRESS");
1455        assert!(
1456            ProxyConfig::default().live_compresses(),
1457            "live_compress must default to true"
1458        );
1459        let cfg = ProxyConfig {
1460            live_compress: Some(false),
1461            ..Default::default()
1462        };
1463        assert!(!cfg.live_compresses());
1464    }
1465
1466    #[test]
1467    fn live_compress_env_overrides_config() {
1468        let _lock = crate::core::data_dir::test_env_lock();
1469        // env `off` wins over a config `true`.
1470        crate::test_env::set_var("LEAN_CTX_PROXY_LIVE_COMPRESS", "off");
1471        let cfg = ProxyConfig {
1472            live_compress: Some(true),
1473            ..Default::default()
1474        };
1475        assert!(!cfg.live_compresses(), "env off must win over config true");
1476        // A garbage env value is ignored → falls back to config.
1477        crate::test_env::set_var("LEAN_CTX_PROXY_LIVE_COMPRESS", "maybe");
1478        assert!(
1479            cfg.live_compresses(),
1480            "unparseable env must fall back to config, not flip the mode"
1481        );
1482        crate::test_env::remove_var("LEAN_CTX_PROXY_LIVE_COMPRESS");
1483    }
1484
1485    #[test]
1486    fn live_compress_exclude_defaults_to_serena() {
1487        // #481: an unset list protects Serena's code-reading tools, which return
1488        // source bodies but are mis-bucketed as `Search` by name.
1489        let cfg = ProxyConfig::default();
1490        assert!(cfg.is_tool_live_compress_excluded("mcp__serena__find_symbol"));
1491        assert!(cfg.is_tool_live_compress_excluded("Serena.search_for_pattern"));
1492        assert!(!cfg.is_tool_live_compress_excluded("ctx_shell"));
1493    }
1494
1495    #[test]
1496    fn live_compress_exclude_explicit_list_replaces_default() {
1497        // An explicit list narrows the exclusion (Serena no longer protected).
1498        let cfg = ProxyConfig {
1499            live_compress_exclude: Some(vec!["my_reader".into()]),
1500            ..Default::default()
1501        };
1502        assert!(cfg.is_tool_live_compress_excluded("acme_my_reader_v2"));
1503        assert!(!cfg.is_tool_live_compress_excluded("mcp__serena__find_symbol"));
1504    }
1505
1506    #[test]
1507    fn live_compress_exclude_empty_list_disables_protection() {
1508        // `[]` fully clears the exclusion (operator opts every tool back in).
1509        let cfg = ProxyConfig {
1510            live_compress_exclude: Some(vec![]),
1511            ..Default::default()
1512        };
1513        assert!(!cfg.is_tool_live_compress_excluded("mcp__serena__find_symbol"));
1514    }
1515
1516    #[test]
1517    fn compress_protect_unset_is_a_noop() {
1518        // #1150: the default protects nothing, so compression stays on for all.
1519        let cfg = ProxyConfig::default();
1520        assert!(!cfg.is_path_compress_protected("tests/golden/output.snap"));
1521        assert!(cfg.compress_protect_globs().is_empty());
1522    }
1523
1524    #[test]
1525    fn compress_protect_matches_basename_and_path_globs() {
1526        // `*.snap` matches by file name anywhere; `**/golden/**` targets a dir.
1527        let cfg = ProxyConfig {
1528            compress_protect: Some(vec!["*.snap".into(), "**/golden/**".into()]),
1529            ..Default::default()
1530        };
1531        assert!(cfg.is_path_compress_protected("a/b/c/output.snap"));
1532        assert!(cfg.is_path_compress_protected("output.snap"));
1533        assert!(cfg.is_path_compress_protected("tests/golden/case1.txt"));
1534        assert!(!cfg.is_path_compress_protected("src/main.rs"));
1535    }
1536
1537    #[test]
1538    fn compress_protect_normalises_backslashes() {
1539        // A Windows-style path still matches a forward-slash glob.
1540        let cfg = ProxyConfig {
1541            compress_protect: Some(vec!["**/fixtures/*".into()]),
1542            ..Default::default()
1543        };
1544        assert!(cfg.is_path_compress_protected("tests\\fixtures\\big.json"));
1545    }
1546
1547    #[test]
1548    fn compress_protect_skips_malformed_globs_without_disabling_rest() {
1549        // One bad pattern must not take the valid ones down with it.
1550        let cfg = ProxyConfig {
1551            compress_protect: Some(vec!["[".into(), "*.lock".into()]),
1552            ..Default::default()
1553        };
1554        assert!(cfg.is_path_compress_protected("Cargo.lock"));
1555    }
1556}