1use crate::core::shell_config::ShellConfig;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use std::sync::OnceLock;
5
6static PROFILE_NAME: OnceLock<Option<String>> = OnceLock::new();
7static PROVIDER_KEYS: OnceLock<BTreeMap<String, String>> = OnceLock::new();
8static IDENTITY: OnceLock<String> = OnceLock::new();
9
10pub const DEFAULT_IDENTITY: &str =
11 "You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
12
13thread_local! {
14 static IDENTITY_OVERRIDE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
16}
17
18pub fn get_identity() -> String {
22 if let Some(pinned) = IDENTITY_OVERRIDE.with(|o| o.borrow().clone()) {
23 return pinned;
24 }
25 IDENTITY
26 .get()
27 .cloned()
28 .unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
29}
30
31#[doc(hidden)]
41pub fn with_identity_for_tests<R>(identity: &str, f: impl FnOnce() -> R) -> R {
42 struct Restore(Option<String>);
43 impl Drop for Restore {
44 fn drop(&mut self) {
45 let prev = self.0.take();
46 IDENTITY_OVERRIDE.with(|o| *o.borrow_mut() = prev);
47 }
48 }
49 let prev = IDENTITY_OVERRIDE.with(|o| o.borrow_mut().replace(identity.to_string()));
50 let _restore = Restore(prev);
51 f()
52}
53
54pub fn get_provider_keys() -> BTreeMap<String, String> {
58 PROVIDER_KEYS.get().cloned().unwrap_or_default()
59}
60
61pub fn get_profile() -> Option<String> {
64 PROFILE_NAME
65 .get_or_init(|| std::env::var("SYNAPS_PROFILE").ok())
66 .clone()
67}
68
69pub fn set_profile(name: Option<String>) {
73 let _ = PROFILE_NAME.set(name);
74}
75
76pub fn base_dir() -> PathBuf {
77 if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
78 return PathBuf::from(path);
79 }
80 let home = std::env::var("HOME")
81 .or_else(|_| std::env::var("USERPROFILE"))
82 .unwrap_or_else(|_| ".".to_string());
83 PathBuf::from(home).join(".synaps-cli")
84}
85
86#[doc(hidden)]
88pub fn set_base_dir_for_tests(path: PathBuf) {
89 std::env::set_var("SYNAPS_BASE_DIR", path);
90}
91
92pub fn resolve_read_path(filename: &str) -> PathBuf {
94 let base = base_dir();
95
96 if let Some(profile) = get_profile() {
97 let profile_path = base.join(&profile).join(filename);
98 if profile_path.exists() {
99 return profile_path;
100 }
101 }
102
103 base.join(filename)
104}
105
106pub fn resolve_read_path_extended(path: &str) -> PathBuf {
108 let base = base_dir();
109
110 if let Some(profile) = get_profile() {
111 let profile_path = base.join(&profile).join(path);
112 if profile_path.exists() {
113 return profile_path;
114 }
115 }
116
117 base.join(path)
118}
119
120pub fn resolve_write_path(filename: &str) -> PathBuf {
122 let mut base = base_dir();
123
124 if let Some(profile) = get_profile() {
125 base.push(profile);
126 }
127
128 let _ = std::fs::create_dir_all(&base);
129 base.join(filename)
130}
131
132pub fn get_active_config_dir() -> PathBuf {
134 let mut base = base_dir();
135 if let Some(profile) = get_profile() {
136 base.push(profile);
137 }
138 base
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
143pub enum ContextManagementMode {
144 #[default]
145 Off,
146 Auto,
147}
148
149impl ContextManagementMode {
150 pub fn parse(value: &str) -> Option<Self> {
151 match value.trim().to_ascii_lowercase().as_str() {
152 "off" => Some(Self::Off),
153 "auto" => Some(Self::Auto),
154 _ => None,
155 }
156 }
157
158 pub fn as_str(self) -> &'static str {
159 match self {
160 Self::Off => "off",
161 Self::Auto => "auto",
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct ContextManagementConfig {
174 pub mode: ContextManagementMode,
175 pub pressure_tokens: Option<u64>,
177 pub rollover_tokens: Option<u64>,
179 pub reserve_tokens: u64,
181 pub finish_rounds: u32,
184 pub target_tokens: Option<u64>,
188}
189
190impl Default for ContextManagementConfig {
191 fn default() -> Self {
192 Self {
193 mode: ContextManagementMode::Off,
194 pressure_tokens: None,
195 rollover_tokens: None,
196 reserve_tokens: 16_000,
197 finish_rounds: 2,
198 target_tokens: None,
199 }
200 }
201}
202
203impl ContextManagementConfig {
204 pub const MAX_FINISH_ROUNDS: u32 = 8;
205 pub const MAX_RESERVE_TOKENS: u64 = 1_000_000;
206
207 pub fn validate(&self) -> Result<(), &'static str> {
210 if self.pressure_tokens == Some(0) {
211 return Err("pressure_tokens must be positive");
212 }
213 if self.rollover_tokens.is_some_and(|v| v < 2) {
214 return Err("rollover_tokens must be at least 2");
215 }
216 if let (Some(pressure), Some(rollover)) = (self.pressure_tokens, self.rollover_tokens) {
217 if pressure >= rollover {
218 return Err("pressure_tokens must be below rollover_tokens");
219 }
220 }
221 if self.target_tokens == Some(0) {
222 return Err("target_tokens must be positive");
223 }
224 if let (Some(target), Some(pressure)) = (self.target_tokens, self.pressure_tokens) {
225 if target >= pressure {
226 return Err("target_tokens must be below pressure_tokens");
227 }
228 }
229 if !(1..=Self::MAX_RESERVE_TOKENS).contains(&self.reserve_tokens) {
230 return Err("reserve_tokens must be between 1 and 1000000");
231 }
232 if self.finish_rounds > Self::MAX_FINISH_ROUNDS {
233 return Err("finish_rounds must be between 0 and 8");
234 }
235 Ok(())
236 }
237
238 pub fn validate_for_window(&self, context_window_tokens: u64) -> Result<(), &'static str> {
241 self.validate()?;
242 if context_window_tokens < 2 {
243 return Err("context window must contain at least two tokens");
244 }
245 if self
246 .pressure_tokens
247 .is_some_and(|v| v >= context_window_tokens)
248 {
249 return Err("pressure_tokens must be below the context window");
250 }
251 if self
252 .rollover_tokens
253 .is_some_and(|v| v > context_window_tokens)
254 {
255 return Err("rollover_tokens must not exceed the context window");
256 }
257 if let Some(target) = self.target_tokens {
258 let (pressure, _) =
259 crate::core::context_policy::ContextThresholds::bands(self, context_window_tokens);
260 if target >= pressure {
261 return Err("target_tokens must be below pressure_tokens");
262 }
263 }
264 Ok(())
265 }
266}
267
268fn parse_context_management_config_key(
271 config: &mut ContextManagementConfig,
272 key: &str,
273 value: &str,
274) -> Result<(), &'static str> {
275 match key {
276 "context_management.mode" => {
277 config.mode = ContextManagementMode::parse(value).ok_or("expected off or auto")?;
278 }
279 "context_management.pressure_tokens"
280 | "context_management.rollover_tokens"
281 | "context_management.target_tokens" => {
282 let tokens = if value.eq_ignore_ascii_case("auto") {
283 None
284 } else {
285 Some(
286 value
287 .parse::<u64>()
288 .map_err(|_| "expected auto or an unsigned token count")?,
289 )
290 };
291 match key {
292 "context_management.pressure_tokens" => config.pressure_tokens = tokens,
293 "context_management.rollover_tokens" => config.rollover_tokens = tokens,
294 _ => config.target_tokens = tokens,
295 }
296 }
297 "context_management.reserve_tokens" => {
298 config.reserve_tokens = value
299 .parse()
300 .map_err(|_| "expected an unsigned token count")?;
301 }
302 "context_management.finish_rounds" => {
303 config.finish_rounds = value
304 .parse()
305 .map_err(|_| "expected an unsigned round count")?;
306 }
307 _ => return Err("unknown context management key"),
308 }
309 Ok(())
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub struct StartupConfig {
316 pub quick_start: bool,
324 pub extensions_ready_timeout_secs: u64,
330}
331
332impl Default for StartupConfig {
333 fn default() -> Self {
334 Self {
335 quick_start: true,
336 extensions_ready_timeout_secs: 30,
337 }
338 }
339}
340
341#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub struct DaemonConfig {
345 pub idle_exit_secs: u64,
349 pub prompt_abandon_secs: u64,
353 pub parked_evict_secs: u64,
356}
357
358impl Default for DaemonConfig {
359 fn default() -> Self {
360 Self {
361 idle_exit_secs: 10,
362 prompt_abandon_secs: 3600,
363 parked_evict_secs: 3600,
364 }
365 }
366}
367
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub struct SubagentLimits {
375 pub max_concurrent: usize,
377 pub max_total: usize,
380 pub max_depth: u16,
383}
384
385impl Default for SubagentLimits {
386 fn default() -> Self {
387 Self {
388 max_concurrent: 16,
389 max_total: 64,
390 max_depth: 4,
391 }
392 }
393}
394
395fn parse_subagent_config_key(
398 config: &mut SubagentLimits,
399 key: &str,
400 value: &str,
401) -> Result<(), &'static str> {
402 let positive = || match value.trim().parse::<usize>() {
403 Ok(n) if n > 0 => Ok(n),
404 _ => Err("expected a whole number of at least 1"),
405 };
406 match key {
407 "subagent.max_concurrent" => config.max_concurrent = positive()?,
408 "subagent.max_total" => config.max_total = positive()?,
409 "subagent.max_depth" => {
410 config.max_depth =
411 u16::try_from(positive()?).map_err(|_| "expected a whole number from 1 to 65535")?
412 }
413 _ => return Err("unknown subagent key"),
414 }
415 Ok(())
416}
417
418fn parse_startup_config_key(
420 config: &mut StartupConfig,
421 key: &str,
422 value: &str,
423) -> Result<(), &'static str> {
424 match key {
425 "startup.quick_start" => {
426 config.quick_start = match value.trim() {
427 "true" | "1" | "on" | "yes" => true,
428 "false" | "0" | "off" | "no" => false,
429 _ => return Err("expected on or off"),
430 };
431 }
432 "startup.extensions_ready_timeout_secs" => {
433 config.extensions_ready_timeout_secs =
434 value.trim().parse().map_err(|_| "expected an unsigned second count")?;
435 }
436 _ => return Err("unknown startup key"),
437 }
438 Ok(())
439}
440
441fn parse_daemon_config_key(
443 config: &mut DaemonConfig,
444 key: &str,
445 value: &str,
446) -> Result<(), &'static str> {
447 let secs = || value.trim().parse::<u64>().map_err(|_| "expected an unsigned second count");
448 match key {
449 "daemon.idle_exit_secs" => config.idle_exit_secs = secs()?,
450 "daemon.prompt_abandon_secs" => config.prompt_abandon_secs = secs()?,
451 "daemon.parked_evict_secs" => config.parked_evict_secs = secs()?,
452 _ => return Err("unknown daemon key"),
453 }
454 Ok(())
455}
456
457#[derive(Debug, Clone, Default)]
459pub struct ServerConfig {
460 pub allowed_origins: Vec<String>,
462 pub token: Option<String>,
465 pub auto_approve_confirms: bool,
467 pub max_message_size: Option<usize>,
470}
471
472#[derive(Debug, Clone)]
478pub struct BridgeConfig {
479 pub uds_path: Option<PathBuf>,
482 pub heartbeat_mirror: bool,
485 pub heartbeat_timeout_ms: u64,
487}
488
489impl Default for BridgeConfig {
490 fn default() -> Self {
491 Self {
492 uds_path: None,
493 heartbeat_mirror: false,
494 heartbeat_timeout_ms: 250,
495 }
496 }
497}
498
499impl BridgeConfig {
500 pub fn resolved_uds_path(&self) -> PathBuf {
502 self.uds_path
503 .clone()
504 .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
505 }
506}
507
508#[derive(Debug, Clone, Default)]
512pub struct AuthConfig {
513 pub remote_endpoint: Option<String>,
517 pub machine_token: Option<String>,
520 pub accounts: BTreeMap<String, String>,
525 pub auto: BTreeMap<String, String>,
527}
528
529impl AuthConfig {
530 pub fn account_policy(&self) -> crate::core::auth::AccountPolicy {
533 crate::core::auth::AccountPolicy::from_config_map(&self.accounts).0
534 }
535
536 pub fn account_selector(
538 &self,
539 provider: crate::core::auth::OAuthProviderId,
540 ) -> crate::core::auth::AccountSelector {
541 self.account_policy().with_env_overlay().selector(provider)
542 }
543
544 pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
556 let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
557 .ok()
558 .filter(|s| !s.trim().is_empty())
559 .or_else(|| self.remote_endpoint.clone());
560 let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
561 .ok()
562 .filter(|s| !s.trim().is_empty())
563 .or_else(|| self.machine_token.clone().filter(|s| !s.trim().is_empty()));
564 crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
565 }
566}
567
568pub const MAX_CACHE_KEEPWARM_HOURS: u32 = 48;
572
573pub const DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS: u32 = 24;
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
594pub enum CacheKeepWarm {
595 #[default]
596 Off,
597 Auto,
598 Hours(u32),
599}
600
601impl CacheKeepWarm {
602 pub fn parse(val: &str) -> Option<CacheKeepWarm> {
606 let v = val.trim().to_ascii_lowercase();
607 match v.as_str() {
608 "off" | "false" | "no" | "0" => return Some(CacheKeepWarm::Off),
609 "auto" | "on" => return Some(CacheKeepWarm::Auto),
610 _ => {}
611 }
612 let digits = v.strip_suffix('h')?;
613 match digits.parse::<u32>().ok()? {
614 0 => Some(CacheKeepWarm::Off),
615 h if h <= MAX_CACHE_KEEPWARM_HOURS => Some(CacheKeepWarm::Hours(h)),
616 _ => None,
617 }
618 }
619
620 pub fn is_off(self) -> bool {
621 self == CacheKeepWarm::Off
622 }
623}
624
625impl std::fmt::Display for CacheKeepWarm {
626 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627 match self {
628 CacheKeepWarm::Off => f.write_str("off"),
629 CacheKeepWarm::Auto => f.write_str("auto"),
630 CacheKeepWarm::Hours(h) => write!(f, "{h}h"),
631 }
632 }
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
647#[serde(rename_all = "snake_case")]
648pub enum ClientInput {
649 #[default]
650 Shared,
651 Exclusive,
652}
653
654impl ClientInput {
655 pub fn parse(val: &str) -> Option<ClientInput> {
657 match val.trim().to_ascii_lowercase().as_str() {
658 "shared" => Some(ClientInput::Shared),
659 "exclusive" => Some(ClientInput::Exclusive),
660 _ => None,
661 }
662 }
663
664 pub fn wire_default() -> ClientInput {
666 ClientInput::Exclusive
667 }
668}
669
670impl std::fmt::Display for ClientInput {
671 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
672 f.write_str(match self {
673 ClientInput::Shared => "shared",
674 ClientInput::Exclusive => "exclusive",
675 })
676 }
677}
678
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
689pub enum CacheTtl {
690 #[default]
691 FiveMinutes,
692 OneHour,
693 Hybrid,
694}
695
696impl CacheTtl {
697 pub fn parse(val: &str) -> Option<CacheTtl> {
700 match val.to_ascii_lowercase().as_str() {
701 "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
702 "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
703 "hybrid" => Some(CacheTtl::Hybrid),
704 _ => None,
705 }
706 }
707}
708
709#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
712pub enum ActivationConfirm {
713 #[default]
715 Auto,
716 Prompt,
719 Deny,
721}
722
723impl ActivationConfirm {
724 pub fn parse(val: &str) -> Option<Self> {
725 match val.trim().to_ascii_lowercase().as_str() {
726 "auto" => Some(Self::Auto),
727 "prompt" => Some(Self::Prompt),
728 "deny" => Some(Self::Deny),
729 _ => None,
730 }
731 }
732
733 pub fn as_str(self) -> &'static str {
734 match self {
735 Self::Auto => "auto",
736 Self::Prompt => "prompt",
737 Self::Deny => "deny",
738 }
739 }
740}
741
742#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
751pub enum ThemeTransitionMode {
752 #[default]
753 On,
754 Off,
755 Ms(u64),
756}
757
758impl ThemeTransitionMode {
759 pub const DEFAULT_MS: u64 = 350;
762 pub const MAX_MS: u64 = 2000;
764
765 pub fn parse(val: &str) -> Option<ThemeTransitionMode> {
768 match val.to_ascii_lowercase().as_str() {
769 "on" | "true" | "yes" => Some(ThemeTransitionMode::On),
770 "off" | "false" | "no" => Some(ThemeTransitionMode::Off),
771 other => other
772 .parse::<u64>()
773 .ok()
774 .map(|ms| ThemeTransitionMode::Ms(ms.min(Self::MAX_MS))),
775 }
776 }
777
778 pub fn duration_ms(self) -> u64 {
780 match self {
781 ThemeTransitionMode::On => Self::DEFAULT_MS,
782 ThemeTransitionMode::Off => 0,
783 ThemeTransitionMode::Ms(ms) => ms.min(Self::MAX_MS),
784 }
785 }
786}
787
788#[derive(Debug, Clone)]
793pub struct EventsConfig {
794 pub auto_turn: bool,
800 pub auto_turn_cap: u32,
807}
808
809pub const DEFAULT_AUTO_TURN_CAP: u32 = 5;
811
812impl Default for EventsConfig {
813 fn default() -> Self {
814 Self {
815 auto_turn: true,
816 auto_turn_cap: DEFAULT_AUTO_TURN_CAP,
817 }
818 }
819}
820
821pub fn parse_auto_turn_cap(val: &str) -> Option<u32> {
826 let normalised = val.trim().to_lowercase();
827 match normalised.as_str() {
828 "unlimited" | "inf" | "infinite" | "infinity" | "none" => Some(0),
829 n => n.parse::<u32>().ok(),
830 }
831}
832
833fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) -> Result<(), String> {
837 if key == "events.auto_turn" {
838 let normalised = val.trim().to_lowercase();
839 match normalised.as_str() {
840 "true" | "1" | "yes" | "on" => cfg.auto_turn = true,
842 "false" | "0" | "no" | "off" => cfg.auto_turn = false,
844 other => {
846 cfg.auto_turn = false;
847 return Err(format!(
848 "warning: config: unrecognised value for events.auto_turn = {:?}; \
849 expected true/false/yes/no/on/off/1/0 — defaulting to false",
850 other
851 ));
852 }
853 }
854 } else if key == "events.auto_turn_cap" {
855 match parse_auto_turn_cap(val) {
856 Some(cap) => cfg.auto_turn_cap = cap,
857 None => {
858 return Err(format!(
859 "warning: config: unrecognised value for events.auto_turn_cap = {:?}; \
860 expected a non-negative integer or unlimited/inf/infinite (0 = unlimited) \
861 — keeping default {}",
862 val.trim(),
863 DEFAULT_AUTO_TURN_CAP
864 ));
865 }
866 }
867 } Ok(())
869}
870
871#[derive(Debug, Clone, Copy, PartialEq, Eq)]
878pub enum TurnLimit<T> {
879 Max(T),
880 Unlimited,
881}
882
883impl<T> TurnLimit<T> {
884 pub fn limit(self) -> Option<T> {
886 match self {
887 TurnLimit::Max(v) => Some(v),
888 TurnLimit::Unlimited => None,
889 }
890 }
891
892 pub fn map<U>(self, f: impl FnOnce(T) -> U) -> TurnLimit<U> {
894 match self {
895 TurnLimit::Max(v) => TurnLimit::Max(f(v)),
896 TurnLimit::Unlimited => TurnLimit::Unlimited,
897 }
898 }
899}
900
901#[derive(Debug, Clone, Default, PartialEq)]
905pub struct TurnBudgetOverrides {
906 pub max_provider_rounds: Option<TurnLimit<u32>>,
910 pub max_tool_calls: Option<TurnLimit<u32>>,
913 pub max_elapsed_secs: Option<u64>,
916 pub max_accumulated_tool_result_bytes: Option<TurnLimit<usize>>,
920 pub max_context_tokens: Option<u64>,
921 pub max_cost_usd: Option<f64>,
922 pub max_round_renewals: Option<u32>,
927}
928
929#[derive(Debug, Clone, Default, PartialEq)]
931pub struct TurnBudgetsConfig {
932 pub foreground: TurnBudgetOverrides,
933 pub autonomous: TurnBudgetOverrides,
934 pub event: TurnBudgetOverrides,
936 pub worker: TurnBudgetOverrides,
937}
938
939fn parse_turn_budget_config_key(
945 budgets: &mut TurnBudgetsConfig,
946 key: &str,
947 val: &str,
948) -> Result<Option<String>, String> {
949 let Some(rest) = key.strip_prefix("turn_budget.") else {
950 return Ok(None);
951 };
952 let Some((role, field)) = rest.split_once('.') else {
953 return Err(format!(
954 "Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)"
955 ));
956 };
957 let overrides = match role {
958 "foreground" => &mut budgets.foreground,
959 "autonomous" => &mut budgets.autonomous,
960 "event" => &mut budgets.event,
961 "worker" => &mut budgets.worker,
962 other => {
963 return Err(format!(
964 "Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|event|worker)"
965 ));
966 }
967 };
968 macro_rules! set {
969 ($slot:expr, $ty:ty) => {
970 match val.parse::<$ty>() {
971 Ok(parsed) => $slot = Some(parsed),
972 Err(_) => {
973 return Err(format!(
974 "Warning: invalid value for {key}: '{val}', using default"
975 ))
976 }
977 }
978 };
979 }
980 let mut deprecation = None;
983 macro_rules! set_limit {
984 ($slot:expr, $ty:ty) => {
985 if val.trim_matches('"').eq_ignore_ascii_case("unlimited") {
986 $slot = Some(TurnLimit::Unlimited);
987 } else {
988 match val.parse::<$ty>() {
989 Ok(0) => {
990 $slot = Some(TurnLimit::Max(0));
991 deprecation = Some(format!(
992 "{key} = 0 means \"none allowed\". `0` will be rejected in a \
993 future release; use `unlimited` for no limit, or a positive number"
994 ));
995 }
996 Ok(parsed) => $slot = Some(TurnLimit::Max(parsed)),
997 Err(_) => {
998 return Err(format!(
999 "Warning: invalid value for {key}: '{val}' (expected a positive \
1000 number or unlimited), using default"
1001 ))
1002 }
1003 }
1004 }
1005 };
1006 }
1007 match field {
1008 "max_provider_rounds" => set_limit!(overrides.max_provider_rounds, u32),
1009 "max_tool_calls" => set_limit!(overrides.max_tool_calls, u32),
1010 "max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
1011 "max_accumulated_tool_result_bytes" => {
1012 set_limit!(overrides.max_accumulated_tool_result_bytes, usize)
1013 }
1014 "max_context_tokens" => set!(overrides.max_context_tokens, u64),
1015 "max_cost_usd" => set!(overrides.max_cost_usd, f64),
1016 "max_round_renewals" => set!(overrides.max_round_renewals, u32),
1017 other => {
1018 return Err(format!("Warning: unknown turn_budget field '{other}'"))
1019 }
1020 }
1021 Ok(deprecation)
1022}
1023
1024#[derive(Debug, Clone, PartialEq)]
1033pub struct MemoryConfig {
1034 pub default_mode: String,
1040 pub default_mode_confirmed: bool,
1044 pub recall_max_records: u32,
1046 pub recall_max_tokens: u32,
1048 pub recall_timeout_ms: u64,
1050 pub capture_tools: String,
1052 pub capture_assistant: bool,
1054 pub capture_user: bool,
1056 pub auto_consolidate: String,
1058 pub local_embeddings: String,
1060 pub gliner: String,
1062}
1063
1064impl Default for MemoryConfig {
1065 fn default() -> Self {
1066 Self {
1067 default_mode: "off".to_string(),
1068 default_mode_confirmed: false,
1069 recall_max_records: 8,
1070 recall_max_tokens: 4096,
1071 recall_timeout_ms: 150,
1072 capture_tools: "summary_only".to_string(),
1073 capture_assistant: true,
1074 capture_user: true,
1075 auto_consolidate: "off".to_string(),
1076 local_embeddings: "off".to_string(),
1077 gliner: "off".to_string(),
1078 }
1079 }
1080}
1081
1082const KNOWN_MEMORY_MODES: &[&str] = &[
1087 "off",
1088 "recall_once",
1089 "recall_each_prompt",
1090 "capture_only",
1091 "capture_and_recall",
1092];
1093
1094fn parse_memory_config_key(memory: &mut MemoryConfig, key: &str, val: &str) -> Result<(), String> {
1098 let Some(field) = key.strip_prefix("memory.") else {
1099 return Ok(());
1100 };
1101 fn parse_bool(slot: &mut bool, key: &str, val: &str) -> Result<(), String> {
1104 match val {
1105 "true" | "1" | "yes" | "on" => *slot = true,
1106 "false" | "0" | "no" | "off" => *slot = false,
1107 other => {
1108 return Err(format!(
1109 "Warning: invalid value for {key}: '{other}', expected true/false — using default"
1110 ))
1111 }
1112 }
1113 Ok(())
1114 }
1115 fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) -> Result<(), String> {
1118 if allowed.contains(&val) {
1119 *slot = val.to_string();
1120 Ok(())
1121 } else {
1122 Err(format!(
1123 "Warning: invalid value for {key}: '{val}', expected one of {} — using default",
1124 allowed.join("|")
1125 ))
1126 }
1127 }
1128 macro_rules! set_num {
1129 ($slot:expr, $ty:ty) => {
1130 match val.parse::<$ty>() {
1131 Ok(parsed) => {
1132 $slot = parsed;
1133 Ok(())
1134 }
1135 Err(_) => Err(format!("Warning: invalid value for {key}: '{val}', using default")),
1136 }
1137 };
1138 }
1139 match field {
1140 "default_mode" => parse_enum(&mut memory.default_mode, key, val, KNOWN_MEMORY_MODES),
1141 "default_mode_confirmed" => parse_bool(&mut memory.default_mode_confirmed, key, val),
1142 "recall_max_records" => set_num!(memory.recall_max_records, u32),
1143 "recall_max_tokens" => set_num!(memory.recall_max_tokens, u32),
1144 "recall_timeout_ms" => set_num!(memory.recall_timeout_ms, u64),
1145 "capture_tools" => parse_enum(
1146 &mut memory.capture_tools,
1147 key,
1148 val,
1149 &["off", "summary_only"],
1150 ),
1151 "capture_assistant" => parse_bool(&mut memory.capture_assistant, key, val),
1152 "capture_user" => parse_bool(&mut memory.capture_user, key, val),
1153 "auto_consolidate" => parse_enum(&mut memory.auto_consolidate, key, val, &["off", "on"]),
1154 "local_embeddings" => parse_enum(&mut memory.local_embeddings, key, val, &["off", "on"]),
1155 "gliner" => parse_enum(&mut memory.gliner, key, val, &["off", "on"]),
1156 other => Err(format!("Warning: unknown memory field '{other}'")),
1157 }
1158}
1159
1160#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1162pub enum MemoryBackendKind {
1163 #[default]
1164 Legacy,
1165 Axel,
1166 Unavailable,
1168}
1169
1170#[derive(Debug, Clone, PartialEq, Eq, Default)]
1171pub struct MemoryBackendConfig {
1172 pub kind: MemoryBackendKind,
1173 pub executable: Option<PathBuf>,
1175 pub brain: Option<PathBuf>,
1177 pub user_scope: bool,
1179}
1180
1181#[derive(Debug, Clone)]
1183pub struct SynapsConfig {
1184 pub model: Option<String>,
1185 pub thinking_budget: Option<u32>,
1186 pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
1190 pub context_window: Option<u64>, pub context_management: ContextManagementConfig,
1192 pub startup: StartupConfig,
1194 pub daemon: DaemonConfig,
1196 pub subagent: SubagentLimits,
1198 pub compaction_model: Option<String>, pub compaction_mode: crate::core::compaction::CompactionMode,
1202 pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
1204 pub max_tool_output: usize, pub bash_timeout: u64, pub bash_max_timeout: u64, pub subagent_timeout: u64, pub api_retries: u32, pub refusal_retries: u32, pub telemetry: String, pub cache_diagnostics: bool, pub cache_ttl: CacheTtl,
1214 pub cache_keepwarm: CacheKeepWarm,
1218 pub cache_keepwarm_auto_max_hours: u32,
1223 pub client_input: ClientInput,
1228 pub max_fps: u32,
1232 pub scroll_lines: Option<u16>,
1236 pub theme: Option<String>,
1237 pub theme_transition: ThemeTransitionMode,
1240 pub tui_background_opaque: bool,
1242 pub tui_streaming_glow: bool,
1244 pub process_scopes: bool,
1250 pub agent_name: Option<String>,
1251 pub identity: Option<String>,
1252 pub disabled_plugins: Vec<String>,
1253 pub favorite_models: Vec<String>,
1254 pub disabled_skills: Vec<String>,
1255 pub progressive_tool_disclosure: bool,
1260 pub tools_activation_confirm: ActivationConfirm,
1266 pub tools_deferred_loading: bool,
1272 pub session_persistence: crate::core::session_journal::SessionPersistence,
1277 pub disabled_tools: Vec<String>,
1280 pub shell: ShellConfig,
1281 pub server: ServerConfig,
1282 pub bridge: BridgeConfig,
1283 pub auth: AuthConfig,
1284 pub events: EventsConfig,
1285 pub provider_keys: BTreeMap<String, String>,
1286 pub keybinds: std::collections::HashMap<String, String>,
1287 pub turn_budgets: TurnBudgetsConfig,
1289 pub memory: MemoryConfig,
1291 pub memory_backend: MemoryBackendConfig,
1293 pub warnings: Vec<String>,
1296}
1297
1298impl Default for SynapsConfig {
1299 fn default() -> Self {
1300 Self {
1301 model: None,
1302 thinking_budget: None,
1303 thinking_level: None,
1304 context_window: None,
1305 context_management: ContextManagementConfig::default(),
1306 startup: StartupConfig::default(),
1307 daemon: DaemonConfig::default(),
1308 subagent: SubagentLimits::default(),
1309 compaction_model: None,
1310 compaction_mode: crate::core::compaction::CompactionMode::default(),
1311 compaction_exclude: Vec::new(),
1312 max_tool_output: 30000,
1313 bash_timeout: 30,
1314 bash_max_timeout: 300,
1315 subagent_timeout: 300,
1316 api_retries: 3,
1317 refusal_retries: 2,
1318 telemetry: "off".to_string(),
1319 cache_diagnostics: false,
1320 cache_ttl: CacheTtl::default(),
1321 cache_keepwarm: CacheKeepWarm::Off,
1322 cache_keepwarm_auto_max_hours: DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS,
1323 client_input: ClientInput::Shared,
1324 max_fps: 60,
1325 scroll_lines: None,
1326 theme: None,
1327 theme_transition: ThemeTransitionMode::default(),
1328 tui_background_opaque: true,
1329 tui_streaming_glow: false,
1330 process_scopes: true,
1331 agent_name: None,
1332 identity: None,
1333 disabled_plugins: Vec::new(),
1334 favorite_models: Vec::new(),
1335 disabled_skills: Vec::new(),
1336 progressive_tool_disclosure: false,
1337 tools_activation_confirm: ActivationConfirm::default(),
1338 tools_deferred_loading: true,
1339 session_persistence: crate::core::session_journal::SessionPersistence::default(),
1340 disabled_tools: Vec::new(),
1341 shell: ShellConfig::default(),
1342 server: ServerConfig::default(),
1343 bridge: BridgeConfig::default(),
1344 auth: AuthConfig::default(),
1345 events: EventsConfig::default(),
1346 provider_keys: BTreeMap::new(),
1347 keybinds: std::collections::HashMap::new(),
1348 turn_budgets: TurnBudgetsConfig::default(),
1349 memory: MemoryConfig::default(),
1350 memory_backend: MemoryBackendConfig::default(),
1351 warnings: Vec::new(),
1352 }
1353 }
1354}
1355
1356pub const KNOWN_CONFIG_KEYS: &[&str] = &[
1360 "model",
1361 "thinking",
1362 "compaction_model",
1363 "compaction_mode",
1364 "compaction_exclude",
1365 "context_window",
1366 "context_management.mode",
1367 "context_management.pressure_tokens",
1368 "context_management.rollover_tokens",
1369 "context_management.reserve_tokens",
1370 "context_management.finish_rounds",
1371 "context_management.target_tokens",
1372 "startup.quick_start",
1373 "startup.extensions_ready_timeout_secs",
1374 "daemon.idle_exit_secs",
1375 "daemon.prompt_abandon_secs",
1376 "daemon.parked_evict_secs",
1377 "max_tool_output",
1378 "bash_timeout",
1379 "bash_max_timeout",
1380 "subagent_timeout",
1381 "subagent.max_concurrent",
1382 "subagent.max_total",
1383 "subagent.max_depth",
1384 "subagent_max_concurrent",
1386 "subagent_max_total",
1387 "api_retries",
1388 "refusal_retries",
1389 "telemetry",
1390 "cache_diagnostics",
1391 "cache_ttl",
1392 "cache_keepwarm",
1393 "cache_keepwarm_auto_max_hours",
1394 "client_input",
1395 "max_fps",
1396 "scroll_lines",
1397 "theme",
1398 "theme_transition",
1399 "tui_background_opaque",
1400 "tui_streaming_glow",
1401 "process_scopes",
1402 "agent_name",
1403 "identity",
1404 "disabled_plugins",
1405 "favorite_models",
1406 "disabled_skills",
1407 "disabled_tools",
1408 "progressive_tool_disclosure",
1409 "tools.activation_confirm",
1410 "tools.deferred_loading",
1411 "session_persistence",
1412 "sidecar_toggle_key",
1413];
1414
1415pub const FAMILY_CONFIG_KEYS: &[&str] = &[
1422 "shell.max_sessions",
1424 "shell.idle_timeout",
1425 "shell.readiness_timeout_ms",
1426 "shell.max_readiness_timeout_ms",
1427 "shell.default_rows",
1428 "shell.default_cols",
1429 "shell.readiness_strategy",
1430 "shell.max_output",
1431 "events.auto_turn",
1433 "events.auto_turn_cap",
1434 "turn_budget.foreground.max_provider_rounds",
1436 "turn_budget.foreground.max_tool_calls",
1437 "turn_budget.foreground.max_elapsed_secs",
1438 "turn_budget.foreground.max_accumulated_tool_result_bytes",
1439 "turn_budget.foreground.max_context_tokens",
1440 "turn_budget.foreground.max_cost_usd",
1441 "turn_budget.foreground.max_round_renewals",
1442 "turn_budget.autonomous.max_provider_rounds",
1443 "turn_budget.autonomous.max_tool_calls",
1444 "turn_budget.autonomous.max_elapsed_secs",
1445 "turn_budget.autonomous.max_accumulated_tool_result_bytes",
1446 "turn_budget.autonomous.max_context_tokens",
1447 "turn_budget.autonomous.max_cost_usd",
1448 "turn_budget.autonomous.max_round_renewals",
1449 "turn_budget.event.max_provider_rounds",
1450 "turn_budget.event.max_tool_calls",
1451 "turn_budget.event.max_elapsed_secs",
1452 "turn_budget.event.max_accumulated_tool_result_bytes",
1453 "turn_budget.event.max_context_tokens",
1454 "turn_budget.event.max_cost_usd",
1455 "turn_budget.event.max_round_renewals",
1456 "turn_budget.worker.max_provider_rounds",
1457 "turn_budget.worker.max_tool_calls",
1458 "turn_budget.worker.max_elapsed_secs",
1459 "turn_budget.worker.max_accumulated_tool_result_bytes",
1460 "turn_budget.worker.max_context_tokens",
1461 "turn_budget.worker.max_cost_usd",
1462 "turn_budget.worker.max_round_renewals",
1463 "memory.backend",
1465 "memory.user_scope",
1466 "memory.axel.executable",
1467 "memory.axel.brain",
1468 "memory.default_mode",
1470 "memory.default_mode_confirmed",
1471 "memory.recall_max_records",
1472 "memory.recall_max_tokens",
1473 "memory.recall_timeout_ms",
1474 "memory.capture_tools",
1475 "memory.capture_assistant",
1476 "memory.capture_user",
1477 "memory.auto_consolidate",
1478 "memory.local_embeddings",
1479 "memory.gliner",
1480 "server.allowed_origins",
1482 "server.token",
1483 "server.auto_approve_confirms",
1484 "server.max_message_size",
1485 "bridge.uds_path",
1487 "bridge.heartbeat_mirror",
1488 "bridge.heartbeat_timeout_ms",
1489 "auth.remote_endpoint",
1491 "auth.machine_token",
1492 "provider.*",
1494 "keybind.*",
1495];
1496
1497fn levenshtein(a: &str, b: &str) -> usize {
1499 let a: Vec<char> = a.chars().collect();
1500 let b: Vec<char> = b.chars().collect();
1501 let mut prev: Vec<usize> = (0..=b.len()).collect();
1502 let mut cur = vec![0; b.len() + 1];
1503 for (i, ca) in a.iter().enumerate() {
1504 cur[0] = i + 1;
1505 for (j, cb) in b.iter().enumerate() {
1506 let cost = if ca == cb { 0 } else { 1 };
1507 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1508 }
1509 std::mem::swap(&mut prev, &mut cur);
1510 }
1511 prev[b.len()]
1512}
1513
1514fn did_you_mean(key: &str) -> Option<&'static str> {
1516 KNOWN_CONFIG_KEYS
1517 .iter()
1518 .map(|k| (*k, levenshtein(key, k)))
1519 .filter(|(_, d)| *d <= 2)
1520 .min_by_key(|(_, d)| *d)
1521 .map(|(k, _)| k)
1522}
1523
1524fn parse_comma_list(val: &str) -> Vec<String> {
1527 val.split(',')
1528 .map(|s| s.trim().to_string())
1529 .filter(|s| !s.is_empty())
1530 .collect()
1531}
1532
1533fn write_comma_list_locked(key: &str, values: &[String]) -> std::io::Result<()> {
1534 write_config_value_locked(key, &values.join(", "))
1535}
1536
1537fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) -> Result<(), String> {
1540 let invalid = || format!("Warning: invalid value for {key}: '{val}', using default");
1541 match key {
1542 "shell.max_sessions" => {
1543 shell_config.max_sessions = val.parse::<usize>().map_err(|_| invalid())?;
1544 }
1545 "shell.idle_timeout" => {
1546 let timeout = val.parse::<u64>().map_err(|_| invalid())?;
1547 shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
1548 }
1549 "shell.readiness_timeout_ms" => {
1550 shell_config.readiness_timeout_ms = val.parse::<u64>().map_err(|_| invalid())?;
1551 }
1552 "shell.max_readiness_timeout_ms" => {
1553 shell_config.max_readiness_timeout_ms = val.parse::<u64>().map_err(|_| invalid())?;
1554 }
1555 "shell.default_rows" => {
1556 shell_config.default_rows = val.parse::<u16>().map_err(|_| invalid())?;
1557 }
1558 "shell.default_cols" => {
1559 shell_config.default_cols = val.parse::<u16>().map_err(|_| invalid())?;
1560 }
1561 "shell.readiness_strategy" => {
1562 let val_lower = val.to_lowercase();
1563 match val_lower.as_str() {
1564 "timeout" | "prompt" | "hybrid" => {
1565 shell_config.readiness_strategy = val.to_string();
1566 }
1567 _ => return Err(invalid()),
1568 }
1569 }
1570 "shell.max_output" => {
1571 shell_config.max_output = val.parse::<usize>().map_err(|_| invalid())?;
1572 }
1573 _ => {
1574 }
1576 }
1577 Ok(())
1578}
1579
1580#[allow(clippy::collapsible_match)]
1582fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
1583 match key {
1584 "server.allowed_origins" => {
1585 server_config.allowed_origins = parse_comma_list(val);
1586 }
1587 "server.token" => {
1588 if !val.is_empty() {
1589 server_config.token = Some(val.to_string());
1590 }
1591 }
1592 "server.auto_approve_confirms" => {
1593 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
1594 }
1595 "server.max_message_size" => {
1596 if let Ok(size) = val.parse::<usize>() {
1597 server_config.max_message_size = Some(size);
1598 } else {
1599 eprintln!(
1600 "Warning: invalid value for server.max_message_size: '{}', ignored",
1601 val
1602 );
1603 }
1604 }
1605 _ => {
1606 }
1608 }
1609}
1610
1611fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
1613 match key {
1614 "bridge.uds_path" => {
1615 if val.is_empty() {
1616 bridge_config.uds_path = None;
1617 } else {
1618 bridge_config.uds_path = Some(PathBuf::from(val));
1619 }
1620 }
1621 "bridge.heartbeat_mirror" => {
1622 bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
1623 }
1624 "bridge.heartbeat_timeout_ms" => {
1625 if let Ok(ms) = val.parse::<u64>() {
1626 bridge_config.heartbeat_timeout_ms = ms;
1627 } else {
1628 eprintln!(
1629 "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
1630 val
1631 );
1632 }
1633 }
1634 _ => {
1635 }
1637 }
1638}
1639
1640fn unknown_auth_key_warning(key: &str) -> Option<String> {
1643 const EXACT: [&str; 2] = ["auth.remote_endpoint", "auth.machine_token"];
1644 const FAMILIES: [&str; 2] = ["auth.account", "auth.auto"];
1645 const PLACEHOLDERS: [&str; 2] = ["<provider>", "<key>"];
1646 if EXACT.contains(&key) {
1647 return None;
1648 }
1649 for (family, placeholder) in FAMILIES.iter().zip(PLACEHOLDERS) {
1650 match key.strip_prefix(family) {
1651 Some("") | Some(".") => {
1653 return Some(format!(
1654 "unknown key '{key}' (did you mean '{family}.{placeholder}'?)"
1655 ))
1656 }
1657 Some(rest) if rest.starts_with('.') && !rest[1..].trim().is_empty() => return None,
1658 _ => {}
1659 }
1660 }
1661 let (head, rest) = match key.match_indices('.').nth(1) {
1663 Some((i, _)) => key.split_at(i),
1664 None => (key, ""),
1665 };
1666 let suggestion = EXACT
1667 .iter()
1668 .map(|k| (levenshtein(key, k), k.to_string()))
1669 .chain(
1670 FAMILIES
1671 .iter()
1672 .filter(|_| !rest.is_empty())
1673 .map(|f| (levenshtein(head, f), format!("{f}{rest}"))),
1674 )
1675 .filter(|(d, _)| *d <= 2)
1676 .min_by_key(|(d, _)| *d)
1677 .map(|(_, k)| k);
1678 Some(match suggestion {
1679 Some(s) => format!("unknown key '{key}' (did you mean '{s}'?)"),
1680 None => format!("unknown key '{key}' — ignored"),
1681 })
1682}
1683
1684fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
1686 let v = val.trim();
1687 match key {
1688 "auth.remote_endpoint" => {
1689 auth_config.remote_endpoint = if v.is_empty() {
1690 None
1691 } else {
1692 Some(v.to_string())
1693 };
1694 }
1695 "auth.machine_token" => {
1696 auth_config.machine_token = if v.is_empty() {
1697 None
1698 } else {
1699 Some(v.to_string())
1700 };
1701 }
1702 _ => {
1703 if let Some(provider) = key.strip_prefix("auth.account.") {
1704 let provider = provider.trim();
1705 if !provider.is_empty() {
1706 auth_config
1708 .accounts
1709 .insert(provider.to_string(), v.to_string());
1710 }
1711 }
1712 if let Some(key) = key.strip_prefix("auth.auto.").filter(|k| !k.trim().is_empty()) {
1713 auth_config.auto.insert(key.to_string(), v.to_string());
1714 }
1715 }
1717 }
1718}
1719
1720pub fn load_config_from_str(content: &str) -> SynapsConfig {
1723 let mut config = SynapsConfig::default();
1724 apply_config_content(&mut config, content);
1725 config
1726}
1727
1728pub fn config_content_problems(content: &str) -> Vec<String> {
1733 let mut config = SynapsConfig::default();
1734 let mut problems = Vec::new();
1735 apply_config_content_with(&mut config, content, &mut |diagnostic: String| problems.push(diagnostic));
1736 problems.extend(config.warnings);
1737 problems
1738}
1739
1740pub fn validate_config_edit(key: &str, value: &str) -> Result<(), String> {
1749 if key.contains(['=', '\n', '\r'])
1750 || value.contains(['\n', '\r'])
1751 {
1752 return Err("value must be a single line".to_string());
1753 }
1754 let target = get_active_config_dir().join("config");
1757 let existing = std::fs::read_to_string(&target).unwrap_or_default();
1758 let before = config_content_problems(&existing);
1759 let after = config_content_problems(&replace_config_line(&existing, key, value));
1760 let introduced: Vec<String> = after.into_iter().filter(|p| !before.contains(p)).collect();
1761 if introduced.is_empty() {
1762 Ok(())
1763 } else {
1764 Err(introduced.join("; "))
1765 }
1766}
1767
1768pub fn load_config() -> SynapsConfig {
1771 let path = resolve_read_path("config");
1772 let mut config = SynapsConfig::default();
1773
1774 let Ok(content) = std::fs::read_to_string(&path) else {
1775 return config;
1776 };
1777
1778 apply_config_content(&mut config, &content);
1779
1780 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
1783
1784 let identity_val = config
1786 .identity
1787 .clone()
1788 .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
1789 let _ = IDENTITY.set(identity_val);
1790
1791 config
1792}
1793
1794fn apply_config_content(config: &mut SynapsConfig, content: &str) {
1797 apply_config_content_with(config, content, &mut |diagnostic: String| eprintln!("{diagnostic}"));
1800}
1801
1802fn apply_config_content_with(
1806 config: &mut SynapsConfig,
1807 content: &str,
1808 family_diagnostic: &mut dyn FnMut(String),
1809) {
1810 let mut invalid_context_management = false;
1811 let mut invalid_memory_backend = false;
1812 for line in content.lines() {
1813 let line = line.trim();
1814 if line.is_empty() || line.starts_with('#') {
1815 continue;
1816 }
1817 let Some((key, val)) = line.split_once('=') else {
1818 continue;
1819 };
1820 let key = key.trim();
1821 let val = val.trim();
1822 match key {
1823 "model" => config.model = Some(val.to_string()),
1824 "thinking" => {
1825 use crate::core::reasoning::ThinkingSpec;
1826 match ThinkingSpec::parse(val) {
1827 Some(ThinkingSpec::Named(level)) => {
1828 config.thinking_level = Some(level);
1829 config.thinking_budget = level.to_legacy_budget();
1830 }
1831 Some(ThinkingSpec::Budget(budget)) => {
1832 config.thinking_level = None;
1835 config.thinking_budget = Some(budget);
1836 }
1837 None => {
1838 config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
1839 }
1840 }
1841 }
1842 "compaction_model" => config.compaction_model = Some(val.to_string()),
1843 "compaction_mode" => match val {
1844 "remote" => {
1845 config.compaction_mode = crate::core::compaction::CompactionMode::Remote
1846 }
1847 "local" | "local_only" | "local-only" => {
1848 config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
1849 }
1850 _ => {
1851 config.warnings.push(format!(
1852 "compaction_mode = {val} — expected remote or local; ignored"
1853 ));
1854 }
1855 },
1856 "compaction_exclude" => {
1857 let mut classes = Vec::new();
1858 for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
1859 match crate::core::compaction::ContentClass::parse(part) {
1860 Some(class) => {
1861 if !classes.contains(&class) {
1862 classes.push(class);
1863 }
1864 }
1865 None => config.warnings.push(format!(
1866 "compaction_exclude — unknown content class '{part}'; \
1867 expected one of: user_text, assistant_text, thinking, \
1868 tool_calls, tool_results, file_paths, event_data"
1869 )),
1870 }
1871 }
1872 config.compaction_exclude = classes;
1873 }
1874 "context_window" if val.eq_ignore_ascii_case("auto") => config.context_window = None,
1877 "context_window" => {
1878 let parsed = match val {
1879 "200k" | "200K" => Some(200_000),
1880 "1m" | "1M" => Some(1_000_000),
1881 _ => val.parse::<u64>().ok(),
1882 };
1883 if parsed.is_none() {
1884 config.warnings.push(format!(
1885 "context_window = {val} — expected 200k, 1m, auto, or a token count; ignored"
1886 ));
1887 }
1888 config.context_window = parsed;
1889 }
1890 "max_tool_output" => match val.parse::<usize>() {
1891 Ok(size) => config.max_tool_output = size,
1892 Err(_) => config.warnings.push(format!(
1893 "max_tool_output = {val} — not a number; using {}",
1894 config.max_tool_output
1895 )),
1896 },
1897 "bash_timeout" => match val.parse::<u64>() {
1898 Ok(t) if t >= 1 => config.bash_timeout = t,
1899 Ok(_) => config.warnings.push(format!(
1900 "bash_timeout = {val} — below minimum (1s); using {}",
1901 config.bash_timeout
1902 )),
1903 Err(_) => config.warnings.push(format!(
1904 "bash_timeout = {val} — not a number; using {}",
1905 config.bash_timeout
1906 )),
1907 },
1908 "bash_max_timeout" => {
1909 if let Ok(timeout) = val.parse::<u64>() {
1910 config.bash_max_timeout = timeout;
1911 }
1912 }
1913 "subagent_timeout" => {
1914 if let Ok(timeout) = val.parse::<u64>() {
1915 config.subagent_timeout = timeout;
1916 }
1917 }
1918 "subagent_max_concurrent" | "subagent_max_total" => {
1920 let dotted = key.replacen("subagent_", "subagent.", 1);
1921 if let Err(reason) =
1922 parse_subagent_config_key(&mut config.subagent, &dotted, val)
1923 {
1924 config.warnings.push(format!("{key} — {reason}"));
1925 }
1926 }
1927 "api_retries" => {
1928 if let Ok(retries) = val.parse::<u32>() {
1929 config.api_retries = retries;
1930 }
1931 }
1932 "refusal_retries" => {
1933 if let Ok(retries) = val.parse::<u32>() {
1934 config.refusal_retries = retries;
1935 }
1936 }
1937 "telemetry" => config.telemetry = val.to_string(),
1938 "cache_diagnostics" => {
1939 config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
1940 }
1941 "cache_ttl" => match CacheTtl::parse(val) {
1942 Some(ttl) => config.cache_ttl = ttl,
1943 None => config.warnings.push(format!(
1944 "cache_ttl = {val} — expected 5m, 1h, or hybrid; using 5m"
1945 )),
1946 },
1947 "cache_keepwarm" => match CacheKeepWarm::parse(val) {
1948 Some(mode) => config.cache_keepwarm = mode,
1949 None => config.warnings.push(format!(
1950 "cache_keepwarm = {val} — expected off, auto, or 1h–{MAX_CACHE_KEEPWARM_HOURS}h; keep-warm stays off"
1951 )),
1952 },
1953 "client_input" => match ClientInput::parse(val) {
1954 Some(mode) => config.client_input = mode,
1955 None => config.warnings.push(format!(
1956 "client_input = {val} — expected shared or exclusive; using shared"
1957 )),
1958 },
1959 "cache_keepwarm_auto_max_hours" => {
1960 match val.trim().trim_end_matches(['h', 'H']).parse::<u32>() {
1961 Ok(h) if (1..=MAX_CACHE_KEEPWARM_HOURS).contains(&h) => {
1962 config.cache_keepwarm_auto_max_hours = h
1963 }
1964 _ => config.warnings.push(format!(
1965 "cache_keepwarm_auto_max_hours = {val} — expected 1–{MAX_CACHE_KEEPWARM_HOURS}; using {DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS}"
1966 )),
1967 }
1968 }
1969 "max_fps" => match val.parse::<u32>() {
1970 Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
1971 Ok(_) => config.warnings.push(format!(
1972 "max_fps = {val} — expected 1–1000; using {}",
1973 config.max_fps
1974 )),
1975 Err(_) => config.warnings.push(format!(
1976 "max_fps = {val} — not a number; using {}",
1977 config.max_fps
1978 )),
1979 },
1980 "scroll_lines" => match val.parse::<u16>() {
1981 Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
1982 Ok(_) => config
1983 .warnings
1984 .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
1985 Err(_) => config
1986 .warnings
1987 .push(format!("scroll_lines = {val} — not a number; ignoring")),
1988 },
1989 "theme" => config.theme = Some(val.to_string()),
1990 "theme_transition" => match ThemeTransitionMode::parse(val) {
1991 Some(mode) => config.theme_transition = mode,
1992 None => config.warnings.push(format!(
1993 "theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
1994 )),
1995 },
1996 "tui_background_opaque" => match val {
1997 "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
1998 "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
1999 _ => config.warnings.push(format!(
2000 "tui_background_opaque = {val} — expected opaque or invisible; using {}",
2001 if config.tui_background_opaque {
2002 "opaque"
2003 } else {
2004 "invisible"
2005 }
2006 )),
2007 },
2008 "tui_streaming_glow" => match val {
2009 "true" | "1" | "on" | "yes" => config.tui_streaming_glow = true,
2010 "false" | "0" | "off" | "no" => config.tui_streaming_glow = false,
2011 _ => config.warnings.push(format!(
2012 "tui_streaming_glow = {val} — expected on or off; using off"
2013 )),
2014 },
2015 "process_scopes" => match val {
2016 "true" | "1" | "on" | "yes" | "auto" => config.process_scopes = true,
2017 "false" | "0" | "off" | "no" => config.process_scopes = false,
2018 _ => config.warnings.push(format!(
2019 "process_scopes = {val} — expected auto or off; using auto"
2020 )),
2021 },
2022 "sidecar_toggle_key" => {}
2026 "agent_name" => config.agent_name = Some(val.to_string()),
2027 "identity" => config.identity = Some(val.to_string()),
2028 "disabled_plugins" => {
2029 config.disabled_plugins = parse_comma_list(val);
2030 }
2031 "favorite_models" => {
2032 config.favorite_models = parse_comma_list(val);
2033 }
2034 "disabled_skills" => {
2035 config.disabled_skills = parse_comma_list(val);
2036 }
2037 "progressive_tool_disclosure" => {
2038 config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
2039 }
2040 "tools.deferred_loading" => match val {
2041 "on" | "true" | "1" | "yes" => config.tools_deferred_loading = true,
2042 "off" | "false" | "0" | "no" => config.tools_deferred_loading = false,
2043 _ => config.warnings.push(format!(
2044 "tools.deferred_loading = {val} — expected on or off; keeping the default (on)"
2045 )),
2046 },
2047 "tools.activation_confirm" => match ActivationConfirm::parse(val) {
2048 Some(mode) => config.tools_activation_confirm = mode,
2049 None => config.warnings.push(format!(
2050 "tools.activation_confirm = {val} — expected auto, prompt or deny; \
2051 keeping the default (auto)"
2052 )),
2053 },
2054 "session_persistence" => {
2055 match crate::core::session_journal::SessionPersistence::parse(val) {
2056 Some(mode) => config.session_persistence = mode,
2057 None => config.warnings.push(format!(
2058 "session_persistence = {val} — expected json or journal; \
2059 keeping the default (json)"
2060 )),
2061 }
2062 }
2063 "disabled_tools" => {
2064 config.disabled_tools = parse_comma_list(val);
2065 }
2066 "memory.backend" => {
2067 config.memory_backend.kind = match val {
2068 "legacy" => MemoryBackendKind::Legacy,
2069 "axel" => MemoryBackendKind::Axel,
2070 _ => {
2071 invalid_memory_backend = true;
2072 config.warnings.push(format!(
2073 "memory.backend = {val} — expected legacy or axel; memory backend unavailable"
2074 ));
2075 MemoryBackendKind::Unavailable
2076 }
2077 };
2078 }
2079 "memory.user_scope" => match val {
2080 "true" => config.memory_backend.user_scope = true,
2081 "false" => config.memory_backend.user_scope = false,
2082 _ => {
2083 invalid_memory_backend = true;
2084 config.warnings.push("memory.user_scope — expected true or false; memory backend unavailable".into());
2085 }
2086 },
2087 "memory.axel.executable" | "memory.axel.brain" => {
2088 let path = PathBuf::from(val);
2089 if !path.is_absolute() {
2090 invalid_memory_backend = true;
2091 config.warnings.push(format!(
2092 "{key} — expected an absolute path; memory backend unavailable"
2093 ));
2094 }
2095 if key == "memory.axel.executable" {
2098 config.memory_backend.executable = Some(path);
2099 } else {
2100 config.memory_backend.brain = Some(path);
2101 }
2102 }
2103 _ => {
2104 if key.starts_with("shell.") {
2106 if let Err(diagnostic) = parse_shell_config_key(&mut config.shell, key, val) {
2107 family_diagnostic(diagnostic);
2108 }
2109 } else if key.starts_with("server.") {
2110 parse_server_config_key(&mut config.server, key, val);
2111 } else if key.starts_with("bridge.") {
2112 parse_bridge_config_key(&mut config.bridge, key, val);
2113 } else if key.starts_with("auth.") {
2114 parse_auth_config_key(&mut config.auth, key, val);
2115 if let Some(key) = key.strip_prefix("auth.auto.").filter(|k| !k.trim().is_empty()) {
2116 let (_, warnings) = crate::auth::AutoSelectionPolicy::from_config_map(
2117 &BTreeMap::from([(key.to_string(), val.trim().to_string())]));
2118 config.warnings.extend(warnings);
2119 }
2120 if let Some(provider) =
2121 key.strip_prefix("auth.account.").filter(|p| !p.trim().is_empty())
2122 {
2123 let mut one = BTreeMap::new();
2124 one.insert(provider.trim().to_string(), val.trim().to_string());
2125 let (_, warnings) =
2126 crate::core::auth::AccountPolicy::from_config_map(&one);
2127 config.warnings.extend(warnings);
2128 }
2129 if let Some(warning) = unknown_auth_key_warning(key) {
2130 config.warnings.push(warning);
2131 }
2132 } else if key.starts_with("events.") {
2133 if let Err(diagnostic) = parse_events_config_key(&mut config.events, key, val)
2134 {
2135 family_diagnostic(diagnostic);
2136 }
2137 } else if key.starts_with("turn_budget.") {
2138 match parse_turn_budget_config_key(&mut config.turn_budgets, key, val) {
2139 Ok(None) => {}
2140 Ok(Some(warning)) => config.warnings.push(warning),
2141 Err(diagnostic) => family_diagnostic(diagnostic),
2142 }
2143 } else if key.starts_with("memory.") {
2144 if let Err(diagnostic) = parse_memory_config_key(&mut config.memory, key, val)
2145 {
2146 family_diagnostic(diagnostic);
2147 }
2148 } else if key.starts_with("startup.") {
2149 if let Err(reason) =
2150 parse_startup_config_key(&mut config.startup, key, val)
2151 {
2152 config.warnings.push(format!("{key} — {reason}"));
2153 }
2154 } else if key.starts_with("daemon.") {
2155 if let Err(reason) =
2156 parse_daemon_config_key(&mut config.daemon, key, val)
2157 {
2158 config.warnings.push(format!("{key} — {reason}"));
2159 }
2160 } else if key.starts_with("subagent.") {
2161 if let Err(reason) =
2162 parse_subagent_config_key(&mut config.subagent, key, val)
2163 {
2164 config.warnings.push(format!("{key} — {reason}"));
2165 }
2166 } else if key.starts_with("context_management.") {
2167 if let Err(reason) = parse_context_management_config_key(
2168 &mut config.context_management, key, val,
2169 ) {
2170 invalid_context_management = true;
2171 config.warnings.push(format!("{key} — {reason}"));
2172 }
2173 } else if let Some(provider_key) = key.strip_prefix("provider.") {
2174 config
2175 .provider_keys
2176 .insert(provider_key.to_string(), val.to_string());
2177 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
2178 config
2179 .keybinds
2180 .insert(keybind_key.to_string(), val.to_string());
2181 } else if key.contains('.') {
2182 } else {
2186 match did_you_mean(key) {
2188 Some(suggestion) => config.warnings.push(format!(
2189 "unknown key '{key}' (did you mean '{suggestion}'?)"
2190 )),
2191 None => config
2192 .warnings
2193 .push(format!("unknown key '{key}' — ignored")),
2194 }
2195 }
2196 }
2197 }
2198 }
2199
2200 let context_validation = match config.context_window {
2203 Some(window) => config.context_management.validate_for_window(window),
2204 None => config.context_management.validate(),
2205 };
2206 if let Err(reason) = context_validation {
2207 invalid_context_management = true;
2208 config
2209 .warnings
2210 .push(format!("context_management — {reason}"));
2211 }
2212 if config.subagent.max_concurrent > config.subagent.max_total {
2216 config.warnings.push(format!(
2217 "subagent.max_concurrent ({}) is above subagent.max_total ({}); using {} for both",
2218 config.subagent.max_concurrent, config.subagent.max_total, config.subagent.max_total
2219 ));
2220 config.subagent.max_concurrent = config.subagent.max_total;
2221 }
2222 if invalid_context_management {
2223 config.context_management = ContextManagementConfig::default();
2224 config.warnings.push(
2225 "invalid context_management configuration — using defaults (mode off)".to_string(),
2226 );
2227 }
2228
2229 if invalid_memory_backend {
2232 config.memory_backend.kind = MemoryBackendKind::Unavailable;
2233 }
2234
2235 if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
2240 config.warnings.push(format!(
2241 "memory.default_mode = {} requires memory.default_mode_confirmed = true \
2242 in the same config; reverting to off",
2243 config.memory.default_mode
2244 ));
2245 config.memory.default_mode = "off".to_string();
2246 }
2247
2248 if config.server.max_message_size.is_none() {
2251 if let Some(ctx_tokens) = config.context_window {
2252 config.server.max_message_size = Some((ctx_tokens as usize) * 4);
2253 }
2254 }
2255}
2256
2257pub fn read_config_value(key: &str) -> Option<String> {
2259 let path = resolve_read_path("config");
2260 let content = std::fs::read_to_string(&path).ok()?;
2261 for line in content.lines() {
2262 let line = line.trim();
2263 if line.is_empty() || line.starts_with('#') {
2264 continue;
2265 }
2266 let Some((k, v)) = line.split_once('=') else {
2267 continue;
2268 };
2269 if k.trim() == key.trim() {
2270 return Some(v.trim().to_string());
2271 }
2272 }
2273 None
2274}
2275
2276struct ConfigLock {
2288 _file: Option<std::fs::File>,
2289}
2290
2291impl ConfigLock {
2292 fn acquire() -> std::io::Result<Self> {
2293 if std::env::var("SYNAPS_CONFIG_LOCK").as_deref() == Ok("0") {
2294 return Ok(Self { _file: None });
2295 }
2296 use fs4::fs_std::FileExt;
2297 let lock_path = resolve_write_path("config").with_extension("lock");
2298 if let Some(parent) = lock_path.parent() {
2299 std::fs::create_dir_all(parent)?;
2300 }
2301 let mut open = std::fs::OpenOptions::new();
2302 open.create(true).write(true).truncate(false);
2303 #[cfg(unix)]
2304 {
2305 use std::os::unix::fs::OpenOptionsExt;
2306 open.mode(0o600);
2307 }
2308 let file = open.open(&lock_path)?;
2309 FileExt::lock_exclusive(&file)?;
2310 Ok(Self { _file: Some(file) })
2311 }
2312}
2313
2314pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
2318 let _guard = ConfigLock::acquire()?;
2319 write_config_value_locked(key, value)
2320}
2321
2322fn write_config_value_locked(key: &str, value: &str) -> std::io::Result<()> {
2324 let path = resolve_write_path("config");
2325 let existing = std::fs::read_to_string(&path).unwrap_or_default();
2326 let out = replace_config_line(&existing, key, value);
2327
2328 let tmp = path.with_extension("tmp");
2329 std::fs::write(&tmp, out)?;
2330 #[cfg(unix)]
2332 {
2333 use std::os::unix::fs::PermissionsExt;
2334 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
2335 }
2336 std::fs::rename(&tmp, &path)?;
2337 Ok(())
2338}
2339
2340fn replace_config_line(existing: &str, key: &str, value: &str) -> String {
2344 let key_trimmed = key.trim();
2345 let replacement = format!("{} = {}", key_trimmed, value);
2346
2347 let mut found = false;
2348 let mut new_lines: Vec<String> = existing
2349 .lines()
2350 .map(|line| {
2351 if found {
2352 return line.to_string();
2353 }
2354 let t = line.trim_start();
2355 if t.starts_with('#') || t.is_empty() {
2356 return line.to_string();
2357 }
2358 if let Some((k, _)) = t.split_once('=') {
2359 if k.trim() == key_trimmed {
2360 found = true;
2361 return replacement.clone();
2362 }
2363 }
2364 line.to_string()
2365 })
2366 .collect();
2367
2368 if !found {
2369 new_lines.push(replacement);
2370 }
2371
2372 let mut out = new_lines.join("\n");
2373 if !out.ends_with('\n') {
2374 out.push('\n');
2375 }
2376 out
2377}
2378
2379pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
2381 let trimmed = id.trim();
2382 if trimmed.is_empty() {
2383 return Ok(());
2384 }
2385 let _guard = ConfigLock::acquire()?;
2386 let mut values = load_config().favorite_models;
2387 if !values.iter().any(|v| v == trimmed) {
2388 values.push(trimmed.to_string());
2389 values.sort();
2390 }
2391 write_comma_list_locked("favorite_models", &values)
2392}
2393
2394pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
2396 let _guard = ConfigLock::acquire()?;
2397 let mut values = load_config().favorite_models;
2398 values.retain(|v| v != id.trim());
2399 write_comma_list_locked("favorite_models", &values)
2400}
2401
2402pub fn is_favorite_model(id: &str) -> bool {
2404 load_config().favorite_models.iter().any(|v| v == id.trim())
2405}
2406
2407pub fn is_secret_key(key: &str) -> bool {
2411 let upper = key.to_ascii_uppercase();
2412 upper.ends_with("_KEY")
2413 || upper.ends_with("_TOKEN")
2414 || upper.ends_with("_CREDENTIALS")
2415 || upper.ends_with("_API_KEY")
2416 || upper.contains("SECRET")
2417 || upper.contains("PASSWORD")
2418 || upper.contains("PASSWD")
2419 || upper.ends_with("_PAT")
2420 || upper.ends_with("_DSN")
2421}
2422
2423pub fn is_secret_env_value(value: &str) -> bool {
2431 let mut rest = value;
2432 while let Some(i) = rest.find("://") {
2433 let scheme_ok = rest[..i]
2434 .chars()
2435 .next_back()
2436 .is_some_and(|c| c.is_ascii_alphanumeric());
2437 let after = &rest[i + 3..];
2438 let end = after
2441 .find(|c: char| {
2442 matches!(c, '/' | '?' | '#' | ',' | ';' | '"' | '\'') || c.is_whitespace()
2443 })
2444 .unwrap_or(after.len());
2445 if scheme_ok && after[..end].rfind('@').is_some_and(|at| at > 0) {
2446 return true;
2447 }
2448 rest = after;
2449 }
2450 false
2451}
2452
2453pub fn is_secret_env(key: &str, value: &str) -> bool {
2456 is_secret_key(key) || is_secret_env_value(value)
2457}
2458
2459pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
2462 #[cfg(windows)]
2463 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal on Windows. \
2464 You have access to powershell, bash, read, and write tools. \
2465 Prefer the powershell tool for system commands and filesystem operations — it is native \
2466 to Windows and avoids quoting/path translation issues. \
2467 Use bash only when the task specifically requires WSL or a Unix environment. \
2468 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
2469 #[cfg(not(windows))]
2470 const DEFAULT_PROMPT: &str = "You are a helpful AI agent running in a terminal. \
2471 You have access to bash, read, and write tools. \
2472 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.";
2473
2474 if let Some(val) = explicit {
2475 let path = std::path::Path::new(val);
2476 if path.exists() && path.is_file() {
2477 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
2478 }
2479 return val.to_string();
2480 }
2481
2482 let system_path = resolve_read_path("system.md");
2483 if system_path.exists() {
2484 return std::fs::read_to_string(&system_path).unwrap_or_default();
2485 }
2486
2487 DEFAULT_PROMPT.to_string()
2488}
2489
2490#[cfg(test)]
2491mod tests {
2492 #[test]
2493 fn tools_deferred_loading_defaults_on_and_parses() {
2494 assert!(super::load_config_from_str("").tools_deferred_loading);
2495 assert!(
2496 !super::load_config_from_str("tools.deferred_loading = off\n").tools_deferred_loading
2497 );
2498 let bad = super::load_config_from_str("tools.deferred_loading = maybe\n");
2499 assert!(
2500 bad.tools_deferred_loading,
2501 "invalid value keeps the default"
2502 );
2503 assert!(bad
2504 .warnings
2505 .iter()
2506 .any(|w| w.contains("tools.deferred_loading")));
2507 }
2508
2509 #[test]
2510 fn secret_env_value_detects_credential_urls() {
2511 use super::is_secret_env_value as v;
2512 assert!(v("postgres://app:hunter2@db.internal:5432/prod"));
2514 assert!(v("redis://:pw@cache:6379/0"));
2515 assert!(v("http://user:pass@proxy.corp:3128"));
2516 assert!(v(
2517 "mongodb+srv://u:p@cluster0.example.net/db?retryWrites=true"
2518 ));
2519 assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
2521 assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
2523 assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
2524 assert!(!v("https://api.example.com/v1/items?q=a@b"));
2526 assert!(!v("http://host/path/with@sign"));
2527 assert!(!v("https://@host/empty-userinfo"));
2528 assert!(!v("git@github.com:org/repo.git"));
2529 assert!(!v("mailto:someone@example.com"));
2530 assert!(!v("someone@example.com"));
2531 assert!(!v("/usr/local/bin:/usr/bin"));
2532 assert!(!v(""));
2533 }
2534
2535 #[test]
2536 fn secret_key_covers_pat_and_dsn() {
2537 assert!(super::is_secret_key("GITHUB_PAT"));
2538 assert!(super::is_secret_key("gh_pat"));
2539 assert!(super::is_secret_key("SENTRY_DSN"));
2540 assert!(!super::is_secret_key("PATH"));
2541 assert!(!super::is_secret_key("PATTERN_DIR"));
2542 }
2543
2544 #[test]
2545 fn secret_env_is_key_or_value() {
2546 assert!(super::is_secret_env(
2547 "DATABASE_URL",
2548 "postgres://app:hunter2@db/prod"
2549 ));
2550 assert!(super::is_secret_env("GH_TOKEN", "anything"));
2551 assert!(!super::is_secret_env("DATABASE_URL", "postgres://db/prod"));
2552 assert!(!super::is_secret_env("HOME", "/home/u"));
2553 }
2554 #[test]
2555 fn startup_and_daemon_defaults() {
2556 let c = super::load_config_from_str("");
2557 assert!(c.startup.quick_start, "quick_start defaults ON");
2558 assert_eq!(c.startup.extensions_ready_timeout_secs, 30);
2559 assert_eq!(c.daemon.idle_exit_secs, 10);
2560 assert_eq!(c.daemon.prompt_abandon_secs, 3600);
2561 assert_eq!(c.daemon.parked_evict_secs, 3600);
2562 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2564 }
2565
2566 #[test]
2567 fn startup_and_daemon_parse_all_keys() {
2568 let c = super::load_config_from_str(concat!(
2569 "startup.quick_start = off\n",
2570 "startup.extensions_ready_timeout_secs = 45\n",
2571 "daemon.idle_exit_secs = 20\n",
2572 "daemon.prompt_abandon_secs = 0\n",
2573 "daemon.parked_evict_secs = 120\n",
2574 ));
2575 assert!(!c.startup.quick_start);
2576 assert_eq!(c.startup.extensions_ready_timeout_secs, 45);
2577 assert_eq!(c.daemon.idle_exit_secs, 20);
2578 assert_eq!(c.daemon.prompt_abandon_secs, 0);
2579 assert_eq!(c.daemon.parked_evict_secs, 120);
2580 assert!(
2581 !c.warnings.iter().any(|w| w.contains("unknown")),
2582 "no unknown-key warning: {:?}",
2583 c.warnings
2584 );
2585 }
2586
2587 #[test]
2588 fn subagent_limits_default_to_16_64_4() {
2589 let c = super::load_config_from_str("");
2590 assert_eq!(c.subagent, super::SubagentLimits::default());
2591 assert_eq!(c.subagent.max_concurrent, 16);
2592 assert_eq!(c.subagent.max_total, 64);
2593 assert_eq!(c.subagent.max_depth, 4);
2594 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2595 }
2596
2597 #[test]
2598 fn subagent_limits_parse_and_are_known_keys() {
2599 let c = super::load_config_from_str(concat!(
2600 "subagent.max_concurrent = 24\n",
2601 "subagent.max_total = 100\n",
2602 "subagent.max_depth = 2\n",
2603 "subagent_timeout = 600\n",
2604 ));
2605 assert_eq!(c.subagent.max_concurrent, 24);
2606 assert_eq!(c.subagent.max_total, 100);
2607 assert_eq!(c.subagent.max_depth, 2);
2608 assert_eq!(c.subagent_timeout, 600, "the old flat key is unaffected");
2609 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2610 for k in [
2611 "subagent.max_concurrent",
2612 "subagent.max_total",
2613 "subagent.max_depth",
2614 ] {
2615 assert!(
2616 super::KNOWN_CONFIG_KEYS.contains(&k),
2617 "{k} not in KNOWN_CONFIG_KEYS"
2618 );
2619 }
2620 }
2621
2622 #[test]
2623 fn legacy_flat_subagent_limit_keys_alias_the_dotted_keys() {
2624 let c = super::load_config_from_str(concat!(
2625 "subagent_max_concurrent = 256\n",
2626 "subagent_max_total = 1024\n",
2627 ));
2628 assert_eq!(c.subagent.max_concurrent, 256);
2629 assert_eq!(c.subagent.max_total, 1024);
2630 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2631 let c = super::load_config_from_str("subagent_max_concurrent = 0\n");
2632 assert_eq!(c.subagent, super::SubagentLimits::default());
2633 assert!(c.warnings.iter().any(|w| w.starts_with("subagent_max_concurrent")));
2634 }
2635
2636 #[test]
2637 fn subagent_limits_reject_zero_and_junk_and_keep_defaults() {
2638 let c = super::load_config_from_str(concat!(
2639 "subagent.max_concurrent = 0\n",
2640 "subagent.max_total = lots\n",
2641 "subagent.max_depth = 70000\n",
2642 "subagent.max_children = 4\n",
2643 ));
2644 assert_eq!(c.subagent, super::SubagentLimits::default());
2645 for key in [
2646 "subagent.max_concurrent",
2647 "subagent.max_total",
2648 "subagent.max_depth",
2649 "subagent.max_children",
2650 ] {
2651 assert!(
2652 c.warnings.iter().any(|w| w.starts_with(key)),
2653 "expected a warning for {key}: {:?}",
2654 c.warnings
2655 );
2656 }
2657 }
2658
2659 #[test]
2660 fn subagent_concurrency_above_total_uses_the_stricter_number() {
2661 for text in [
2663 "subagent.max_total = 10\nsubagent.max_concurrent = 32\n",
2664 "subagent.max_concurrent = 32\nsubagent.max_total = 10\n",
2665 ] {
2666 let c = super::load_config_from_str(text);
2667 assert_eq!(c.subagent.max_concurrent, 10);
2668 assert_eq!(c.subagent.max_total, 10);
2669 assert!(
2670 c.warnings
2671 .iter()
2672 .any(|w| w.contains("subagent.max_concurrent (32)")),
2673 "{:?}",
2674 c.warnings
2675 );
2676 }
2677 let c = super::load_config_from_str("subagent.max_total = 5\n");
2679 assert_eq!(c.subagent.max_concurrent, 5);
2680 }
2681
2682 #[test]
2683 fn process_scopes_defaults_on_and_parses_auto_and_off() {
2684 assert!(super::load_config_from_str("").process_scopes);
2685 assert!(super::load_config_from_str("process_scopes = auto\n").process_scopes);
2686 let off = super::load_config_from_str("process_scopes = off\n");
2687 assert!(!off.process_scopes);
2688 assert!(off.warnings.is_empty(), "{:?}", off.warnings);
2689 let bad = super::load_config_from_str("process_scopes = sometimes\n");
2690 assert!(bad.process_scopes, "an invalid value keeps the default");
2691 assert!(bad.warnings.iter().any(|w| w.starts_with("process_scopes")));
2692 assert!(super::KNOWN_CONFIG_KEYS.contains(&"process_scopes"));
2693 }
2694
2695 #[test]
2696 fn startup_quick_start_accepts_on() {
2697 let c = super::load_config_from_str("startup.quick_start = on\n");
2698 assert!(c.startup.quick_start);
2699 }
2700
2701 #[test]
2702 fn startup_and_daemon_keys_are_known() {
2703 for k in [
2704 "startup.quick_start",
2705 "startup.extensions_ready_timeout_secs",
2706 "daemon.idle_exit_secs",
2707 "daemon.prompt_abandon_secs",
2708 "daemon.parked_evict_secs",
2709 ] {
2710 assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
2711 }
2712 }
2713
2714 #[test]
2715 fn startup_daemon_bad_values_warn_and_keep_defaults() {
2716 let c = super::load_config_from_str(concat!(
2717 "startup.quick_start = maybe\n",
2718 "daemon.idle_exit_secs = soon\n",
2719 ));
2720 assert!(c.startup.quick_start, "invalid bool keeps default");
2721 assert_eq!(c.daemon.idle_exit_secs, 10, "invalid u64 keeps default");
2722 assert!(c.warnings.iter().any(|w| w.contains("startup.quick_start")));
2723 assert!(c.warnings.iter().any(|w| w.contains("daemon.idle_exit_secs")));
2724 }
2725
2726 #[test]
2727 fn context_management_defaults_off_and_parses_all_keys() {
2728 use super::{ContextManagementConfig, ContextManagementMode};
2729 assert_eq!(
2730 super::load_config_from_str("").context_management,
2731 ContextManagementConfig::default()
2732 );
2733 let parsed = super::load_config_from_str(
2734 "context_management.mode = AUTO\n\
2735 context_management.pressure_tokens = 250000\n\
2736 context_management.rollover_tokens = 400000\n\
2737 context_management.reserve_tokens = 24000\n\
2738 context_management.finish_rounds = 3\n\
2739 context_window = 1m\n",
2740 );
2741 assert_eq!(
2742 parsed.context_management,
2743 ContextManagementConfig {
2744 mode: ContextManagementMode::Auto,
2745 pressure_tokens: Some(250_000),
2746 rollover_tokens: Some(400_000),
2747 reserve_tokens: 24_000,
2748 finish_rounds: 3,
2749 target_tokens: None,
2750 }
2751 );
2752 assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
2753 assert!(parsed.provider_keys.is_empty());
2754 assert!(super::load_config_from_str("context_management.mode = off")
2755 .warnings
2756 .is_empty());
2757 }
2758
2759 #[test]
2760 fn context_management_auto_clears_overrides_and_allows_bounded_extremes() {
2761 for rounds in [0, super::ContextManagementConfig::MAX_FINISH_ROUNDS] {
2762 let parsed = super::load_config_from_str(&format!(
2763 "context_management.mode = auto\n\
2764 context_management.pressure_tokens = 300000\n\
2765 context_management.rollover_tokens = 450000\n\
2766 context_management.pressure_tokens = auto\n\
2767 context_management.rollover_tokens = AUTO\n\
2768 context_management.finish_rounds = {rounds}\n"
2769 ));
2770 assert_eq!(parsed.context_management.pressure_tokens, None);
2771 assert_eq!(parsed.context_management.rollover_tokens, None);
2772 assert_eq!(parsed.context_management.finish_rounds, rounds);
2773 assert!(parsed.warnings.is_empty());
2774 }
2775 }
2776
2777 #[test]
2778 fn context_management_invalid_values_reset_entire_policy() {
2779 for (key, value) in [
2780 ("mode", "on"),
2781 ("pressure_tokens", "0"),
2782 ("pressure_tokens", "-1"),
2783 ("pressure_tokens", "250k"),
2784 ("pressure_tokens", "18446744073709551616"),
2785 ("rollover_tokens", "1"),
2786 ("rollover_tokens", "nope"),
2787 ("reserve_tokens", "0"),
2788 ("reserve_tokens", "1000001"),
2789 ("reserve_tokens", "auto"),
2790 ("finish_rounds", "9"),
2791 ("finish_rounds", "-1"),
2792 ("finish_rounds", "4294967296"),
2793 ("finish_rounds", ""),
2794 ("mod", "auto"),
2795 ] {
2796 let parsed = super::load_config_from_str(&format!(
2797 "context_management.mode = auto\ncontext_management.{key} = {value}\n"
2798 ));
2799 assert_eq!(
2800 parsed.context_management,
2801 super::ContextManagementConfig::default(),
2802 "{key}={value}"
2803 );
2804 assert!(!parsed.warnings.is_empty(), "{key}={value}");
2805 assert!(parsed.provider_keys.is_empty());
2806 }
2807 let duplicate = super::load_config_from_str(
2808 "context_management.mode = auto\ncontext_management.finish_rounds = bad\ncontext_management.finish_rounds = 2"
2809 );
2810 assert_eq!(
2811 duplicate.context_management,
2812 super::ContextManagementConfig::default()
2813 );
2814 }
2815
2816 #[test]
2817 fn context_management_target_tokens_parses_and_validates() {
2818 assert_eq!(
2819 super::load_config_from_str("")
2820 .context_management
2821 .target_tokens,
2822 None
2823 );
2824 let parsed = super::load_config_from_str(
2825 "context_management.mode = auto\ncontext_management.target_tokens = 120000\ncontext_window = 1m\n",
2826 );
2827 assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
2828 assert_eq!(parsed.context_management.target_tokens, Some(120_000));
2829 let auto = super::load_config_from_str(
2830 "context_management.target_tokens = 5000\ncontext_management.target_tokens = auto\n",
2831 );
2832 assert_eq!(auto.context_management.target_tokens, None);
2833 for fields in [
2834 "target_tokens = 0",
2835 "target_tokens = nope",
2836 "target_tokens = 250000",
2837 "pressure_tokens = 200000\ncontext_management.target_tokens = 200000",
2838 ] {
2839 let parsed = super::load_config_from_str(&format!(
2840 "context_management.mode = auto\ncontext_management.{fields}\ncontext_window = 1m\n"
2841 ));
2842 assert_eq!(
2843 parsed.context_management,
2844 super::ContextManagementConfig::default(),
2845 "{fields}"
2846 );
2847 assert!(!parsed.warnings.is_empty(), "{fields}");
2848 }
2849 }
2850
2851 #[test]
2852 fn context_management_validates_final_threshold_order_and_window() {
2853 for fields in [
2854 "pressure_tokens = 400000\ncontext_management.rollover_tokens = 400000",
2855 "pressure_tokens = 500000\ncontext_management.rollover_tokens = 400000",
2856 "pressure_tokens = 1000000",
2857 "rollover_tokens = 1000001",
2858 ] {
2859 for window_first in [false, true] {
2860 let policy =
2861 format!("context_management.mode = auto\ncontext_management.{fields}\n");
2862 let content = if window_first {
2863 format!("context_window = 1m\n{policy}")
2864 } else {
2865 format!("{policy}context_window = 1m\n")
2866 };
2867 let parsed = super::load_config_from_str(&content);
2868 assert_eq!(
2869 parsed.context_management,
2870 super::ContextManagementConfig::default()
2871 );
2872 assert!(!parsed.warnings.is_empty());
2873 }
2874 }
2875 let parsed = super::load_config_from_str(
2877 "context_management.mode = auto\n\
2878 context_management.rollover_tokens = 100000\n\
2879 context_management.pressure_tokens = 300000\n\
2880 context_management.rollover_tokens = 450000\ncontext_window = 1m",
2881 );
2882 assert!(parsed.warnings.is_empty());
2883 assert_eq!(parsed.context_management.rollover_tokens, Some(450_000));
2884 }
2885
2886 #[test]
2887 fn context_management_parsed_finish_override_bounds_rollover_extension() {
2888 use crate::core::context_policy::{
2889 assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2890 };
2891 let config = super::load_config_from_str(
2892 "context_management.mode = auto\n\
2893 context_management.finish_rounds = 1\ncontext_window = 1m",
2894 );
2895 let budget = ContextBudget {
2896 context_window_tokens: 1_000_000,
2897 used_tokens: 400_000,
2898 hard_remaining_tokens: 600_000,
2899 required_next_round_tokens: 8_000,
2900 };
2901 let mut state = ContextState::default();
2902 state.report_phase(WorkPhase::Plan);
2903 let first = assess_context(&config.context_management, &state, budget);
2904 assert_eq!(
2905 first.action,
2906 ContextAction::FinishBounded {
2907 rounds_remaining: 0
2908 }
2909 );
2910 state = first.next_state;
2911 state.report_phase(WorkPhase::WrapUp);
2912 assert_eq!(
2913 assess_context(&config.context_management, &state, budget).action,
2914 ContextAction::Rollover
2915 );
2916 }
2917
2918 #[test]
2919 fn context_management_keys_are_known_for_suggestions() {
2920 for key in [
2921 "mode",
2922 "pressure_tokens",
2923 "rollover_tokens",
2924 "reserve_tokens",
2925 "finish_rounds",
2926 ] {
2927 let full = format!("context_management.{key}");
2928 assert!(super::KNOWN_CONFIG_KEYS.contains(&full.as_str()));
2929 assert_eq!(
2930 super::did_you_mean(&format!("{full}x")),
2931 Some(full.as_str())
2932 );
2933 }
2934 }
2935
2936 #[test]
2937 fn context_management_parsed_policy_rolls_over_before_executing_350k_plan() {
2938 use crate::core::context_policy::{
2939 assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
2940 };
2941 let config =
2942 super::load_config_from_str("context_management.mode = auto\ncontext_window = 1m");
2943 let budget = ContextBudget {
2944 context_window_tokens: config.context_window.unwrap(),
2945 used_tokens: 350_000,
2946 hard_remaining_tokens: 650_000,
2947 required_next_round_tokens: 8_000,
2948 };
2949 let mut state = ContextState::default();
2950 state.report_phase(WorkPhase::Plan);
2951 let plan = assess_context(&config.context_management, &state, budget);
2952 assert_eq!(plan.action, ContextAction::Advisory);
2953 state = plan.next_state;
2954 state.report_phase(WorkPhase::Execute);
2955 assert_eq!(
2956 assess_context(&config.context_management, &state, budget).action,
2957 ContextAction::Rollover
2958 );
2959 }
2960
2961 #[test]
2962 fn tui_streaming_glow_defaults_off_and_parses() {
2963 assert!(!super::load_config_from_str("").tui_streaming_glow);
2964 assert!(super::load_config_from_str("tui_streaming_glow = on\n").tui_streaming_glow);
2965 assert!(!super::load_config_from_str("tui_streaming_glow = off\n").tui_streaming_glow);
2966 let invalid = super::load_config_from_str("tui_streaming_glow = sparkly\n");
2967 assert!(
2968 !invalid.tui_streaming_glow,
2969 "invalid keeps the default (off)"
2970 );
2971 assert!(invalid
2972 .warnings
2973 .iter()
2974 .any(|w| w.contains("tui_streaming_glow")));
2975 }
2976
2977 #[test]
2978 fn tui_background_opaque_defaults_and_parses_supported_values() {
2979 assert!(super::load_config_from_str("").tui_background_opaque);
2980 assert!(
2981 super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
2982 );
2983 assert!(
2984 !super::load_config_from_str("tui_background_opaque = invisible\n")
2985 .tui_background_opaque
2986 );
2987 let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
2988 assert!(invalid.tui_background_opaque);
2989 assert!(invalid
2990 .warnings
2991 .iter()
2992 .any(|w| w.contains("tui_background_opaque")));
2993 }
2994
2995 #[test]
2996 fn turn_budget_keys_parse_typed_per_role() {
2997 let config = super::load_config_from_str(
2998 "turn_budget.worker.max_provider_rounds = 3\n\
2999 turn_budget.worker.max_cost_usd = 0.25\n\
3000 turn_budget.autonomous.max_elapsed_secs = 90\n\
3001 turn_budget.foreground.max_tool_calls = 7\n\
3002 turn_budget.foreground.max_round_renewals = 12\n\
3003 turn_budget.worker.max_provider_rounds_bogus = 1\n\
3004 turn_budget.nosuchrole.max_tool_calls = 1\n",
3005 );
3006 assert_eq!(
3007 config.turn_budgets.worker.max_provider_rounds,
3008 Some(super::TurnLimit::Max(3))
3009 );
3010 assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
3011 assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
3012 assert_eq!(
3013 config.turn_budgets.foreground.max_tool_calls,
3014 Some(super::TurnLimit::Max(7))
3015 );
3016 assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
3017 assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
3019 let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
3021 assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
3022 }
3023
3024 #[test]
3025 fn turn_budget_count_limits_accept_unlimited() {
3026 let config = super::load_config_from_str(
3027 "turn_budget.autonomous.max_provider_rounds = unlimited\n\
3028 turn_budget.autonomous.max_tool_calls = \"unlimited\"\n\
3029 turn_budget.worker.max_accumulated_tool_result_bytes = Unlimited\n",
3030 );
3031 assert!(config.warnings.is_empty(), "{:?}", config.warnings);
3032 let unlimited = Some(super::TurnLimit::Unlimited);
3033 assert_eq!(config.turn_budgets.autonomous.max_provider_rounds, unlimited);
3034 assert_eq!(config.turn_budgets.autonomous.max_tool_calls, unlimited);
3035 assert_eq!(
3036 config.turn_budgets.worker.max_accumulated_tool_result_bytes,
3037 Some(super::TurnLimit::Unlimited)
3038 );
3039 let bad = super::load_config_from_str("turn_budget.worker.max_tool_calls = lots\n");
3040 assert_eq!(bad.turn_budgets.worker.max_tool_calls, None);
3041 }
3042
3043 #[test]
3044 fn turn_budget_zero_count_limits_keep_none_allowed_with_a_deprecation_warning() {
3045 for role in ["foreground", "autonomous", "event", "worker"] {
3049 for field in [
3050 "max_provider_rounds",
3051 "max_tool_calls",
3052 "max_accumulated_tool_result_bytes",
3053 ] {
3054 let key = format!("turn_budget.{role}.{field}");
3055 let content = format!("{key} = 0\n");
3056 let config = super::load_config_from_str(&content);
3057 let overrides = match role {
3058 "foreground" => &config.turn_budgets.foreground,
3059 "autonomous" => &config.turn_budgets.autonomous,
3060 "event" => &config.turn_budgets.event,
3061 _ => &config.turn_budgets.worker,
3062 };
3063 let parsed = match field {
3064 "max_provider_rounds" => overrides.max_provider_rounds.map(|l| l.map(|v| v as usize)),
3065 "max_tool_calls" => overrides.max_tool_calls.map(|l| l.map(|v| v as usize)),
3066 _ => overrides.max_accumulated_tool_result_bytes,
3067 };
3068 assert_eq!(parsed, Some(super::TurnLimit::Max(0)), "{key}");
3069 assert!(
3070 config.warnings.iter().any(|w| w.contains(&key)
3071 && w.contains("none allowed")
3072 && w.contains(
3073 "`0` will be rejected in a future release; use `unlimited` for no limit, or a positive number"
3074 )),
3075 "{key}: {:?}",
3076 config.warnings
3077 );
3078 assert!(!super::config_content_problems(&content).is_empty());
3079 }
3080 }
3081 }
3082
3083 #[test]
3084 fn memory_keys_all_parse_with_consent() {
3085 let config = super::load_config_from_str(
3086 "memory.default_mode = capture_and_recall\n\
3087 memory.default_mode_confirmed = true\n\
3088 memory.recall_max_records = 12\n\
3089 memory.recall_max_tokens = 2048\n\
3090 memory.recall_timeout_ms = 250\n\
3091 memory.capture_tools = off\n\
3092 memory.capture_assistant = false\n\
3093 memory.capture_user = false\n\
3094 memory.auto_consolidate = on\n\
3095 memory.local_embeddings = on\n\
3096 memory.gliner = on\n",
3097 );
3098 assert_eq!(config.memory.default_mode, "capture_and_recall");
3099 assert!(config.memory.default_mode_confirmed);
3100 assert_eq!(config.memory.recall_max_records, 12);
3101 assert_eq!(config.memory.recall_max_tokens, 2048);
3102 assert_eq!(config.memory.recall_timeout_ms, 250);
3103 assert_eq!(config.memory.capture_tools, "off");
3104 assert!(!config.memory.capture_assistant);
3105 assert!(!config.memory.capture_user);
3106 assert_eq!(config.memory.auto_consolidate, "on");
3107 assert_eq!(config.memory.local_embeddings, "on");
3108 assert_eq!(config.memory.gliner, "on");
3109 }
3110
3111 #[test]
3112 fn memory_defaults_match_spec_12() {
3113 let config = super::load_config_from_str("");
3114 assert_eq!(config.memory, super::MemoryConfig::default());
3115 assert_eq!(config.memory.default_mode, "off");
3116 assert!(!config.memory.default_mode_confirmed);
3117 assert_eq!(config.memory.recall_max_records, 8);
3118 assert_eq!(config.memory.recall_max_tokens, 4096);
3119 assert_eq!(config.memory.recall_timeout_ms, 150);
3120 assert_eq!(config.memory.capture_tools, "summary_only");
3121 assert!(config.memory.capture_assistant);
3122 assert!(config.memory.capture_user);
3123 assert_eq!(config.memory.auto_consolidate, "off");
3124 assert_eq!(config.memory.local_embeddings, "off");
3125 assert_eq!(config.memory.gliner, "off");
3126 }
3127
3128 #[test]
3129 fn memory_unknown_values_fail_closed_to_defaults() {
3130 let config = super::load_config_from_str(
3131 "memory.default_mode = recall_everything_forever\n\
3132 memory.default_mode_confirmed = true\n\
3133 memory.recall_max_records = lots\n\
3134 memory.recall_max_tokens = -5\n\
3135 memory.recall_timeout_ms = fast\n\
3136 memory.capture_tools = full\n\
3137 memory.capture_assistant = maybe\n\
3138 memory.capture_user = sometimes\n\
3139 memory.auto_consolidate = aggressive\n\
3140 memory.local_embeddings = auto\n\
3141 memory.gliner = download\n\
3142 memory.nosuchfield = 1\n",
3143 );
3144 assert_eq!(config.memory.default_mode, "off");
3146 assert_eq!(config.memory.recall_max_records, 8);
3147 assert_eq!(config.memory.recall_max_tokens, 4096);
3148 assert_eq!(config.memory.recall_timeout_ms, 150);
3149 assert_eq!(config.memory.capture_tools, "summary_only");
3150 assert!(config.memory.capture_assistant);
3151 assert!(config.memory.capture_user);
3152 assert_eq!(config.memory.auto_consolidate, "off");
3153 assert_eq!(config.memory.local_embeddings, "off");
3154 assert_eq!(config.memory.gliner, "off");
3155 }
3156
3157 #[test]
3158 fn memory_default_mode_without_consent_reverts_to_off() {
3159 let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
3160 assert_eq!(config.memory.default_mode, "off");
3161 assert!(
3162 config
3163 .warnings
3164 .iter()
3165 .any(|w| w.contains("memory.default_mode_confirmed")),
3166 "reversion must surface a warning; got: {:?}",
3167 config.warnings
3168 );
3169
3170 let denied = super::load_config_from_str(
3172 "memory.default_mode = capture_only\n\
3173 memory.default_mode_confirmed = false\n",
3174 );
3175 assert_eq!(denied.memory.default_mode, "off");
3176
3177 let garbage = super::load_config_from_str(
3179 "memory.default_mode = capture_only\n\
3180 memory.default_mode_confirmed = definitely\n",
3181 );
3182 assert_eq!(garbage.memory.default_mode, "off");
3183 }
3184
3185 #[test]
3186 fn memory_consent_is_order_independent() {
3187 let before = super::load_config_from_str(
3189 "memory.default_mode_confirmed = true\n\
3190 memory.default_mode = recall_once\n",
3191 );
3192 assert_eq!(before.memory.default_mode, "recall_once");
3193 let after = super::load_config_from_str(
3195 "memory.default_mode = recall_once\n\
3196 memory.default_mode_confirmed = true\n",
3197 );
3198 assert_eq!(after.memory.default_mode, "recall_once");
3199 }
3200
3201 #[test]
3202 fn memory_backend_defaults_are_independent_of_consent() {
3203 let config = super::load_config_from_str("");
3204 assert_eq!(config.memory_backend, super::MemoryBackendConfig::default());
3205 assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Legacy);
3206 let axel = super::load_config_from_str("memory.backend = axel\n");
3207 assert_eq!(axel.memory_backend.kind, super::MemoryBackendKind::Axel);
3208 assert_eq!(axel.memory, config.memory);
3209 assert!(axel.warnings.is_empty());
3210 }
3211
3212 #[test]
3213 fn user_wide_notes_require_explicit_valid_opt_in() {
3214 let default = super::load_config_from_str("memory.backend = axel\n");
3215 assert!(!default.memory_backend.user_scope);
3216 let enabled =
3217 super::load_config_from_str("memory.backend = axel\nmemory.user_scope = true\n");
3218 assert!(enabled.memory_backend.user_scope);
3219 assert_eq!(enabled.memory, default.memory);
3220 for bad in ["yes", "", "1", "TRUE"] {
3221 let config = super::load_config_from_str(&format!(
3222 "memory.user_scope = {bad}\nmemory.backend = axel\n"
3223 ));
3224 assert_eq!(
3225 config.memory_backend.kind,
3226 super::MemoryBackendKind::Unavailable
3227 );
3228 }
3229 }
3230
3231 #[test]
3232 fn memory_backend_invalid_selector_fails_closed() {
3233 for content in [
3234 "memory.backend = typo\n",
3235 "memory.backend = \n",
3236 "memory.backend = unavailable\n",
3237 "memory.backend = axel\nmemory.backend = typo\n",
3238 "memory.backend = typo\nmemory.backend = legacy\n",
3239 ] {
3240 let config = super::load_config_from_str(content);
3241 assert_eq!(
3242 config.memory_backend.kind,
3243 super::MemoryBackendKind::Unavailable
3244 );
3245 assert!(config
3246 .warnings
3247 .iter()
3248 .any(|w| w.contains("memory backend unavailable")));
3249 }
3250 }
3251
3252 #[test]
3253 fn memory_backend_absolute_paths_are_preserved() {
3254 let root = std::env::current_dir().unwrap();
3255 let executable = root.join("fixture bin/axel");
3256 let brain = root.join("fixture brain");
3257 let config = super::load_config_from_str(&format!(
3258 "memory.axel.brain = {}\nmemory.backend = axel\nmemory.axel.executable = {}\n",
3259 brain.display(),
3260 executable.display()
3261 ));
3262 assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Axel);
3263 assert_eq!(config.memory_backend.executable, Some(executable));
3264 assert_eq!(config.memory_backend.brain, Some(brain));
3265 assert!(config.warnings.is_empty());
3266 }
3267
3268 #[test]
3269 fn memory_backend_relative_or_empty_paths_fail_closed_in_any_order() {
3270 for key in ["memory.axel.executable", "memory.axel.brain"] {
3271 for path in ["relative/path", "~/brain", ""] {
3272 for content in [
3273 format!("memory.backend = axel\n{key} = {path}\n"),
3274 format!("{key} = {path}\nmemory.backend = legacy\n"),
3275 ] {
3276 let config = super::load_config_from_str(&content);
3277 assert_eq!(
3278 config.memory_backend.kind,
3279 super::MemoryBackendKind::Unavailable
3280 );
3281 assert!(config.warnings.iter().any(|w| w.contains("absolute path")));
3282 }
3283 }
3284 }
3285 }
3286
3287 #[test]
3288 fn memory_config_surface_is_closed_by_construction() {
3289 let _closed_surface = super::MemoryConfig {
3297 default_mode: "off".to_string(),
3298 default_mode_confirmed: false,
3299 recall_max_records: 8,
3300 recall_max_tokens: 4096,
3301 recall_timeout_ms: 150,
3302 capture_tools: "summary_only".to_string(),
3303 capture_assistant: true,
3304 capture_user: true,
3305 auto_consolidate: "off".to_string(),
3306 local_embeddings: "off".to_string(),
3307 gliner: "off".to_string(),
3308 };
3309 }
3310
3311 use super::*;
3312 use serial_test::serial;
3313
3314 #[test]
3315 fn test_levenshtein_basics() {
3316 assert_eq!(levenshtein("model", "model"), 0);
3317 assert_eq!(levenshtein("modle", "model"), 2);
3318 assert_eq!(levenshtein("them", "theme"), 1);
3319 }
3320
3321 #[test]
3322 fn test_did_you_mean_close_typos() {
3323 assert_eq!(did_you_mean("modle"), Some("model"));
3324 assert_eq!(did_you_mean("them"), Some("theme"));
3325 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
3326 assert_eq!(did_you_mean("completely_unrelated_key"), None);
3327 }
3328
3329 #[test]
3330 #[serial]
3331 fn test_config_warnings_unknown_key_and_bad_values() {
3332 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
3333 let dir = home.join(".synaps-cli");
3334 std::fs::create_dir_all(&dir).unwrap();
3335 std::fs::write(dir.join("config"), "modle = claude-opus-4-6\nthinking = hgih\nbash_timeout = 0\nknowledge.jawz_notes = ~/Jawz/notes\ncustom.plugin.key = 42\n").unwrap();
3336
3337 with_home(&home, || {
3338 let config = load_config();
3339 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
3341 assert!(
3342 !config.warnings.iter().any(|w| w.contains("knowledge")),
3343 "{:?}",
3344 config.warnings
3345 );
3346 assert!(
3347 config
3348 .warnings
3349 .iter()
3350 .any(|w| w.contains("did you mean 'model'")),
3351 "{:?}",
3352 config.warnings
3353 );
3354 assert!(
3355 config.warnings.iter().any(|w| w.contains("thinking")),
3356 "{:?}",
3357 config.warnings
3358 );
3359 assert!(
3360 config.warnings.iter().any(|w| w.contains("below minimum")),
3361 "{:?}",
3362 config.warnings
3363 );
3364 assert_eq!(config.bash_timeout, 30);
3366 assert_eq!(config.thinking_budget, None);
3367 });
3368 let _ = std::fs::remove_dir_all(&home);
3369 }
3370
3371 #[test]
3372 fn test_client_input_is_shared_by_default_and_parses() {
3373 assert_eq!(SynapsConfig::default().client_input, ClientInput::Shared);
3374 assert_eq!(load_config_from_str("").client_input, ClientInput::Shared);
3375 assert_eq!(load_config_from_str("client_input = exclusive\n").client_input, ClientInput::Exclusive);
3376 assert_eq!(load_config_from_str("client_input = Shared\n").client_input, ClientInput::Shared);
3377 let c = load_config_from_str("client_input = mine\n");
3378 assert_eq!(c.client_input, ClientInput::Shared);
3379 assert!(c.warnings.iter().any(|w| w.contains("client_input")), "{:?}", c.warnings);
3380 assert_eq!(ClientInput::wire_default(), ClientInput::Exclusive, "an older daemon enforces the owner");
3381 }
3382
3383 #[test]
3386 fn test_cache_keepwarm_is_off_by_default() {
3387 assert_eq!(SynapsConfig::default().cache_keepwarm, CacheKeepWarm::Off);
3388 assert_eq!(load_config_from_str("").cache_keepwarm, CacheKeepWarm::Off);
3389 }
3390
3391 #[test]
3392 fn test_cache_keepwarm_parse_table() {
3393 for (raw, want) in [
3394 ("off", Some(CacheKeepWarm::Off)),
3395 ("OFF", Some(CacheKeepWarm::Off)),
3396 ("0h", Some(CacheKeepWarm::Off)),
3397 ("auto", Some(CacheKeepWarm::Auto)),
3398 (" Auto ", Some(CacheKeepWarm::Auto)),
3399 ("1h", Some(CacheKeepWarm::Hours(1))),
3400 ("12H", Some(CacheKeepWarm::Hours(12))),
3401 ("48h", Some(CacheKeepWarm::Hours(MAX_CACHE_KEEPWARM_HOURS))),
3402 ("49h", None),
3403 ("12", None),
3404 ("h", None),
3405 ("-1h", None),
3406 ("lots", None),
3407 ] {
3408 assert_eq!(CacheKeepWarm::parse(raw), want, "{raw:?}");
3409 }
3410 for mode in [CacheKeepWarm::Off, CacheKeepWarm::Auto, CacheKeepWarm::Hours(6)] {
3411 assert_eq!(CacheKeepWarm::parse(&mode.to_string()), Some(mode));
3412 }
3413 }
3414
3415 #[test]
3416 fn test_cache_keepwarm_config_key() {
3417 let c = load_config_from_str("cache_keepwarm = auto\ncache_ttl = 5m\n");
3418 assert_eq!(c.cache_keepwarm, CacheKeepWarm::Auto);
3419 assert_eq!(c.cache_ttl, CacheTtl::FiveMinutes);
3420 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3421
3422 let c = load_config_from_str("cache_keepwarm = 6h\n");
3423 assert_eq!(c.cache_keepwarm, CacheKeepWarm::Hours(6));
3424
3425 assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "default auto cap");
3426
3427 let c = load_config_from_str("cache_keepwarm_auto_max_hours = 8\n");
3428 assert_eq!(c.cache_keepwarm_auto_max_hours, 8);
3429 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3430 let c = load_config_from_str("cache_keepwarm_auto_max_hours = 12h\n");
3431 assert_eq!(c.cache_keepwarm_auto_max_hours, 12);
3432 for bad in ["0", "49", "lots"] {
3433 let c = load_config_from_str(&format!("cache_keepwarm_auto_max_hours = {bad}\n"));
3434 assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "{bad}");
3435 assert!(
3436 c.warnings.iter().any(|w| w.contains("auto_max_hours")),
3437 "{bad}"
3438 );
3439 }
3440
3441 let c = load_config_from_str("cache_keepwarm = forever\n");
3442 assert_eq!(c.cache_keepwarm, CacheKeepWarm::Off, "garbage keeps keep-warm off");
3443 assert!(c.warnings.iter().any(|w| w.contains("cache_keepwarm")));
3444 }
3445
3446 #[test]
3449 fn test_cache_ttl_parse_table() {
3450 assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
3452 assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
3453 assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
3454 assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
3456 assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
3457 assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
3458 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
3460 assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
3462 assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
3463 assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
3464 assert_eq!(CacheTtl::parse("2h"), None);
3466 assert_eq!(CacheTtl::parse(""), None);
3467 assert_eq!(CacheTtl::parse("forever"), None);
3468 }
3469
3470 #[test]
3471 fn test_cache_ttl_default_is_five_minutes() {
3472 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
3473 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtl::FiveMinutes);
3474 }
3475
3476 #[test]
3477 fn test_max_fps_default_is_60() {
3478 assert_eq!(SynapsConfig::default().max_fps, 60);
3479 }
3480
3481 #[test]
3484 fn test_theme_transition_parse_table() {
3485 use ThemeTransitionMode as M;
3486 assert_eq!(M::parse("on"), Some(M::On));
3487 assert_eq!(M::parse("ON"), Some(M::On));
3488 assert_eq!(M::parse("off"), Some(M::Off));
3489 assert_eq!(M::parse("Off"), Some(M::Off));
3490 assert_eq!(M::parse("500"), Some(M::Ms(500)));
3492 assert_eq!(M::parse("0"), Some(M::Ms(0)));
3493 assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
3494 assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
3495 assert_eq!(M::parse("fast"), None);
3497 assert_eq!(M::parse(""), None);
3498 assert_eq!(M::parse("-5"), None);
3499 assert_eq!(M::parse("1.5"), None);
3500 }
3501
3502 #[test]
3503 fn test_theme_transition_duration_ms() {
3504 use ThemeTransitionMode as M;
3505 assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
3506 assert_eq!(M::DEFAULT_MS, 350);
3507 assert_eq!(M::Off.duration_ms(), 0);
3508 assert_eq!(M::Ms(120).duration_ms(), 120);
3509 assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
3510 assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
3511 }
3512
3513 #[test]
3514 fn test_theme_transition_config_key_parses_and_defaults() {
3515 use ThemeTransitionMode as M;
3516 assert_eq!(SynapsConfig::default().theme_transition, M::On);
3517 assert_eq!(
3518 super::load_config_from_str("theme_transition = off\n").theme_transition,
3519 M::Off
3520 );
3521 assert_eq!(
3522 super::load_config_from_str("theme_transition = 750\n").theme_transition,
3523 M::Ms(750)
3524 );
3525 let bad = super::load_config_from_str("theme_transition = warp\n");
3526 assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
3527 assert!(
3528 bad.warnings.iter().any(|w| w.contains("theme_transition")),
3529 "{:?}",
3530 bad.warnings
3531 );
3532 }
3533
3534 #[test]
3535 #[serial]
3536 fn test_max_fps_config_parse_and_validation() {
3537 let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
3538 let dir = home.join(".synaps-cli");
3539 std::fs::create_dir_all(&dir).unwrap();
3540
3541 std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
3543 with_home(&home, || {
3544 let config = load_config();
3545 assert_eq!(config.max_fps, 144);
3546 assert!(
3547 config.warnings.is_empty(),
3548 "warnings: {:?}",
3549 config.warnings
3550 );
3551 });
3552
3553 std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
3555 with_home(&home, || {
3556 let config = load_config();
3557 assert_eq!(config.max_fps, 60);
3558 assert!(
3559 config.warnings.iter().any(|w| w.contains("max_fps")),
3560 "warnings: {:?}",
3561 config.warnings
3562 );
3563 });
3564
3565 std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
3567 with_home(&home, || {
3568 let config = load_config();
3569 assert_eq!(config.max_fps, 60);
3570 assert!(
3571 config.warnings.iter().any(|w| w.contains("max_fps")),
3572 "warnings: {:?}",
3573 config.warnings
3574 );
3575 });
3576
3577 let _ = std::fs::remove_dir_all(&home);
3578 }
3579
3580 #[test]
3581 #[serial]
3582 fn test_cache_ttl_config_parse_and_garbage_warning() {
3583 let home =
3584 std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
3585 let dir = home.join(".synaps-cli");
3586 std::fs::create_dir_all(&dir).unwrap();
3587
3588 std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
3590 with_home(&home, || {
3591 let config = load_config();
3592 assert_eq!(config.cache_ttl, CacheTtl::Hybrid);
3593 assert!(
3594 config.warnings.is_empty(),
3595 "warnings: {:?}",
3596 config.warnings
3597 );
3598 });
3599
3600 std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
3602 with_home(&home, || {
3603 let config = load_config();
3604 assert_eq!(config.cache_ttl, CacheTtl::FiveMinutes);
3605 assert!(
3606 config.warnings.iter().any(|w| w.contains("cache_ttl")),
3607 "warnings: {:?}",
3608 config.warnings
3609 );
3610 });
3611
3612 let _ = std::fs::remove_dir_all(&home);
3613 }
3614
3615 #[test]
3616 fn test_parse_thinking_budget() {
3617 use crate::core::reasoning::ThinkingSpec;
3618 assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
3620 assert_eq!(
3621 ThinkingSpec::parse("medium").unwrap().to_budget(),
3622 Some(4096)
3623 );
3624 assert_eq!(
3625 ThinkingSpec::parse("high").unwrap().to_budget(),
3626 Some(16384)
3627 );
3628 assert_eq!(
3629 ThinkingSpec::parse("xhigh").unwrap().to_budget(),
3630 Some(32768)
3631 );
3632 assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
3633 let config = load_config_from_str("thinking = 8192\n");
3634 assert_eq!(config.thinking_level, None);
3635 assert_eq!(config.thinking_budget, Some(8192));
3636 assert_eq!(ThinkingSpec::parse("invalid"), None);
3637 }
3638
3639 #[test]
3640 fn test_base_dir() {
3641 let path = base_dir();
3642 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
3643 }
3644
3645 #[test]
3646 fn test_resolve_system_prompt_explicit() {
3647 let result = resolve_system_prompt(Some("test prompt"));
3648 assert_eq!(result, "test prompt");
3649 }
3650
3651 #[test]
3652 fn test_resolve_system_prompt_none() {
3653 let result = resolve_system_prompt(None);
3654 assert!(result.contains("helpful AI agent"));
3655 }
3656
3657 #[test]
3662 fn test_synaps_config_default() {
3663 let config = SynapsConfig::default();
3664 assert_eq!(config.model, None);
3665 assert_eq!(config.thinking_budget, None);
3666 assert_eq!(config.max_tool_output, 30000);
3667 assert_eq!(config.bash_timeout, 30);
3668 assert_eq!(config.bash_max_timeout, 300);
3669 assert_eq!(config.subagent_timeout, 300);
3670 assert_eq!(config.api_retries, 3);
3671 assert_eq!(config.theme, None);
3672 assert!(config.disabled_plugins.is_empty());
3673 assert!(config.favorite_models.is_empty());
3674 assert!(config.disabled_skills.is_empty());
3675 assert!(!config.progressive_tool_disclosure);
3676 assert_eq!(config.tools_activation_confirm, ActivationConfirm::Auto);
3677 assert_eq!(config.shell.max_sessions, 5);
3678 assert_eq!(config.shell.idle_timeout.as_secs(), 600);
3679 assert!(config.server.allowed_origins.is_empty());
3681 assert_eq!(config.server.token, None);
3682 assert!(!config.server.auto_approve_confirms);
3683 assert_eq!(config.server.max_message_size, None);
3684 assert!(config.bridge.uds_path.is_none());
3686 assert!(!config.bridge.heartbeat_mirror);
3687 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
3688 }
3689
3690 #[test]
3691 #[serial]
3692 fn test_load_config_tools_activation_confirm_modes() {
3693 for (raw, expected, warns) in [
3694 ("prompt", ActivationConfirm::Prompt, false),
3695 ("deny", ActivationConfirm::Deny, false),
3696 ("AUTO", ActivationConfirm::Auto, false),
3697 ("garbage", ActivationConfirm::Auto, true),
3698 ] {
3699 let home = make_test_home(&format!("activation-confirm-{raw}"));
3700 let cfg = home.join(".synaps-cli/config");
3701 std::fs::write(&cfg, format!("tools.activation_confirm = {raw}\n")).unwrap();
3702 with_home(&home, || {
3703 let config = load_config();
3704 assert_eq!(config.tools_activation_confirm, expected, "{raw}");
3705 assert_eq!(
3706 config.warnings.iter().any(|w| w.contains("tools.activation_confirm")),
3707 warns,
3708 "{raw}: {:?}",
3709 config.warnings
3710 );
3711 });
3712 let _ = std::fs::remove_dir_all(&home);
3713 }
3714 }
3715
3716 #[test]
3717 #[serial]
3718 fn test_load_config_progressive_tool_disclosure_is_opt_in() {
3719 let home = make_test_home("progressive-tool-disclosure");
3720 let cfg = home.join(".synaps-cli/config");
3721 std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
3722
3723 with_home(&home, || {
3724 let config = load_config();
3725 assert!(config.progressive_tool_disclosure);
3726 assert!(config.warnings.is_empty());
3727 });
3728
3729 let _ = std::fs::remove_dir_all(&home);
3730 }
3731
3732 #[test]
3733 #[serial]
3734 fn test_load_config_bridge_keys() {
3735 let home = make_test_home("bridge-keys");
3736 let cfg = home.join(".synaps-cli/config");
3737 std::fs::write(
3738 &cfg,
3739 "\
3740bridge.uds_path = /tmp/some/control.sock\n\
3741bridge.heartbeat_mirror = true\n\
3742bridge.heartbeat_timeout_ms = 750\n\
3743",
3744 )
3745 .unwrap();
3746
3747 with_home(&home, || {
3748 let config = load_config();
3749 assert_eq!(
3750 config.bridge.uds_path,
3751 Some(std::path::PathBuf::from("/tmp/some/control.sock")),
3752 );
3753 assert!(config.bridge.heartbeat_mirror);
3754 assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
3755 assert_eq!(
3756 config.bridge.resolved_uds_path(),
3757 std::path::PathBuf::from("/tmp/some/control.sock"),
3758 );
3759 });
3760
3761 let _ = std::fs::remove_dir_all(&home);
3762 }
3763
3764 #[test]
3765 fn test_bridge_config_defaults() {
3766 let cfg = BridgeConfig::default();
3767 assert!(cfg.uds_path.is_none());
3768 assert!(!cfg.heartbeat_mirror);
3769 assert_eq!(cfg.heartbeat_timeout_ms, 250);
3770 let resolved = cfg.resolved_uds_path();
3772 assert!(resolved.ends_with("bridge/control.sock"));
3773 }
3774
3775 #[test]
3776 #[serial]
3777 fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
3778 let home = make_test_home("bridge-default-off");
3779 let cfg = home.join(".synaps-cli/config");
3780 std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
3781
3782 with_home(&home, || {
3783 let config = load_config();
3784 assert!(!config.bridge.heartbeat_mirror);
3785 assert!(config.bridge.uds_path.is_none());
3786 assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
3787 });
3788
3789 let _ = std::fs::remove_dir_all(&home);
3790 }
3791
3792 #[test]
3793 fn compaction_disclosure_keys_parse_with_typed_warnings() {
3794 let config = load_config_from_str(
3795 "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
3796 );
3797 assert_eq!(
3798 config.compaction_mode,
3799 crate::core::compaction::CompactionMode::LocalOnly
3800 );
3801 assert_eq!(
3802 config.compaction_exclude,
3803 vec![
3804 crate::core::compaction::ContentClass::Thinking,
3805 crate::core::compaction::ContentClass::ToolResults,
3806 ]
3807 );
3808 assert!(
3809 config.warnings.iter().any(|w| w.contains("bogus")),
3810 "unknown class must warn: {:?}",
3811 config.warnings
3812 );
3813
3814 let defaults = load_config_from_str("");
3815 assert_eq!(
3816 defaults.compaction_mode,
3817 crate::core::compaction::CompactionMode::Remote
3818 );
3819 assert!(defaults.compaction_exclude.is_empty());
3820
3821 let bad_mode = load_config_from_str("compaction_mode = cloud\n");
3822 assert_eq!(
3823 bad_mode.compaction_mode,
3824 crate::core::compaction::CompactionMode::Remote
3825 );
3826 assert!(bad_mode
3827 .warnings
3828 .iter()
3829 .any(|w| w.contains("compaction_mode")));
3830 }
3831
3832 #[test]
3833 #[serial]
3834 fn test_load_config_server_keys() {
3835 let home = make_test_home("server-keys");
3836 let cfg = home.join(".synaps-cli/config");
3837 std::fs::write(
3838 &cfg,
3839 "\
3840server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
3841server.token = my-secret-token\n\
3842server.auto_approve_confirms = true\n\
3843server.max_message_size = 65536\n\
3844context_window = 200k\n\
3845",
3846 )
3847 .unwrap();
3848
3849 with_home(&home, || {
3850 let config = load_config();
3851 assert_eq!(
3852 config.server.allowed_origins,
3853 vec![
3854 "http://localhost:3000".to_string(),
3855 "http://localhost:5193".to_string(),
3856 ]
3857 );
3858 assert_eq!(config.server.token, Some("my-secret-token".to_string()));
3859 assert!(config.server.auto_approve_confirms);
3860 assert_eq!(config.server.max_message_size, Some(65536));
3862 });
3863
3864 let _ = std::fs::remove_dir_all(&home);
3865 }
3866
3867 #[test]
3868 #[serial]
3869 fn test_server_max_message_size_derived_from_context_window() {
3870 let home = make_test_home("server-derive");
3871 let cfg = home.join(".synaps-cli/config");
3872 std::fs::write(&cfg, "context_window = 200k\n").unwrap();
3873
3874 with_home(&home, || {
3875 let config = load_config();
3876 assert_eq!(config.server.max_message_size, Some(800_000));
3878 });
3879
3880 let _ = std::fs::remove_dir_all(&home);
3881 }
3882
3883 fn make_test_home(subdir: &str) -> std::path::PathBuf {
3884 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
3885 let _ = std::fs::remove_dir_all(&dir);
3886 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
3887 dir
3888 }
3889
3890 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
3891 let original = std::env::var("HOME").ok();
3894 let original_base = std::env::var("SYNAPS_BASE_DIR").ok();
3895 std::env::set_var("HOME", home);
3896 std::env::set_var("SYNAPS_BASE_DIR", home.join(".synaps-cli"));
3897 f();
3898 if let Some(h) = original {
3899 std::env::set_var("HOME", h);
3900 } else {
3901 std::env::remove_var("HOME");
3902 }
3903 if let Some(b) = original_base {
3904 std::env::set_var("SYNAPS_BASE_DIR", b);
3905 } else {
3906 std::env::remove_var("SYNAPS_BASE_DIR");
3907 }
3908 }
3909
3910 #[test]
3911 #[serial]
3912 fn write_config_value_replaces_existing_key() {
3913 let home = make_test_home("replace");
3914 let cfg = home.join(".synaps-cli/config");
3915 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
3916
3917 with_home(&home, || {
3918 write_config_value("model", "claude-sonnet-4-6").unwrap();
3919 });
3920
3921 let contents = std::fs::read_to_string(&cfg).unwrap();
3922 assert!(contents.contains("model = claude-sonnet-4-6"));
3923 assert!(contents.contains("thinking = low"));
3924 let _ = std::fs::remove_dir_all(&home);
3925 }
3926
3927 #[test]
3928 #[serial]
3929 fn write_config_value_appends_when_missing() {
3930 let home = make_test_home("append");
3931 let cfg = home.join(".synaps-cli/config");
3932 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
3933
3934 with_home(&home, || {
3935 write_config_value("theme", "dracula").unwrap();
3936 });
3937
3938 let contents = std::fs::read_to_string(&cfg).unwrap();
3939 assert!(contents.contains("model = claude-opus-4-6"));
3940 assert!(contents.contains("theme = dracula"));
3941 let _ = std::fs::remove_dir_all(&home);
3942 }
3943
3944 #[test]
3945 #[serial]
3946 fn write_config_value_concurrent_writers_lose_nothing() {
3947 let home = make_test_home("concurrent");
3948 let cfg = home.join(".synaps-cli/config");
3949 std::fs::write(&cfg, "# keep\n").unwrap();
3950
3951 with_home(&home, || {
3953 let a = std::thread::spawn(|| {
3954 for i in 0..200 {
3955 write_config_value("a_key", &i.to_string()).unwrap();
3956 }
3957 });
3958 let b = std::thread::spawn(|| {
3959 for i in 0..200 {
3960 write_config_value("b_key", &i.to_string()).unwrap();
3961 }
3962 });
3963 a.join().unwrap();
3964 b.join().unwrap();
3965 });
3966
3967 let contents = std::fs::read_to_string(&cfg).unwrap();
3968 assert!(contents.contains("# keep"), "{contents}");
3969 assert!(contents.contains("a_key = 199"), "{contents}");
3970 assert!(contents.contains("b_key = 199"), "{contents}");
3971 assert_eq!(contents.matches("a_key").count(), 1);
3972 assert_eq!(contents.matches("b_key").count(), 1);
3973
3974 #[cfg(unix)]
3975 {
3976 use std::os::unix::fs::PermissionsExt;
3977 let lock = home.join(".synaps-cli/config.lock");
3978 let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
3979 assert_eq!(mode, 0o600);
3980 }
3981 let _ = std::fs::remove_dir_all(&home);
3982 }
3983
3984 #[test]
3985 #[serial]
3986 fn config_lock_kill_switch_still_writes() {
3987 let home = make_test_home("lock-off");
3988 let cfg = home.join(".synaps-cli/config");
3989 std::env::set_var("SYNAPS_CONFIG_LOCK", "0");
3990 with_home(&home, || {
3991 write_config_value("model", "claude-sonnet-4-6").unwrap();
3992 });
3993 std::env::remove_var("SYNAPS_CONFIG_LOCK");
3994 let contents = std::fs::read_to_string(&cfg).unwrap();
3995 assert!(contents.contains("model = claude-sonnet-4-6"));
3996 assert!(!home.join(".synaps-cli/config.lock").exists());
3997 let _ = std::fs::remove_dir_all(&home);
3998 }
3999
4000 #[test]
4001 #[serial]
4002 fn add_favorite_model_end_to_end_does_not_deadlock() {
4003 let home = make_test_home("favorite-lock");
4004 let cfg = home.join(".synaps-cli/config");
4005 with_home(&home, || {
4006 add_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
4007 add_favorite_model("openai/gpt-5").unwrap();
4008 remove_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
4009 });
4010 let contents = std::fs::read_to_string(&cfg).unwrap();
4011 assert!(contents.contains("favorite_models = openai/gpt-5"), "{contents}");
4012 let _ = std::fs::remove_dir_all(&home);
4013 }
4014
4015 #[test]
4016 #[serial]
4017 fn write_config_value_preserves_comments() {
4018 let home = make_test_home("comments");
4019 let cfg = home.join(".synaps-cli/config");
4020 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
4021
4022 with_home(&home, || {
4023 write_config_value("model", "claude-sonnet-4-6").unwrap();
4024 });
4025
4026 let contents = std::fs::read_to_string(&cfg).unwrap();
4027 assert!(contents.contains("# user comment"));
4028 assert!(contents.contains("# another"));
4029 assert!(contents.contains("model = claude-sonnet-4-6"));
4030 let _ = std::fs::remove_dir_all(&home);
4031 }
4032
4033 #[test]
4034 #[serial]
4035 fn write_config_value_preserves_unknown_keys() {
4036 let home = make_test_home("unknown");
4037 let cfg = home.join(".synaps-cli/config");
4038 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
4039
4040 with_home(&home, || {
4041 write_config_value("model", "claude-sonnet-4-6").unwrap();
4042 });
4043
4044 let contents = std::fs::read_to_string(&cfg).unwrap();
4045 assert!(contents.contains("custom_thing = 42"));
4046 let _ = std::fs::remove_dir_all(&home);
4047 }
4048
4049 #[test]
4050 #[serial]
4051 fn write_config_value_creates_file_if_absent() {
4052 let home = make_test_home("create");
4053 let cfg = home.join(".synaps-cli/config");
4054 assert!(!cfg.exists());
4055
4056 with_home(&home, || {
4057 write_config_value("model", "claude-sonnet-4-6").unwrap();
4058 });
4059
4060 let contents = std::fs::read_to_string(&cfg).unwrap();
4061 assert!(contents.contains("model = claude-sonnet-4-6"));
4062 let _ = std::fs::remove_dir_all(&home);
4063 }
4064
4065 #[test]
4066 #[serial]
4067 fn load_config_parses_theme_key() {
4068 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
4069 let _ = std::fs::create_dir_all(&dir);
4070 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
4071
4072 let original_home = std::env::var("HOME").ok();
4073 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
4074
4075 let config = load_config();
4076
4077 if let Some(home) = original_home {
4078 std::env::set_var("HOME", home);
4079 } else {
4080 std::env::remove_var("HOME");
4081 }
4082 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
4083
4084 assert_eq!(config.theme.as_deref(), Some("dracula"));
4085 }
4086
4087 #[test]
4088 #[serial]
4089 fn test_load_config_disable_lists() {
4090 let test_dir =
4091 std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
4092 let _ = std::fs::create_dir_all(&test_dir);
4093 let config_path = test_dir.join("config");
4094
4095 let config_content = r#"
4096# Test config with disable lists
4097favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
4098
4099disabled_plugins = foo, bar
4100disabled_skills = baz, plug:qual
4101"#;
4102 std::fs::write(&config_path, config_content).unwrap();
4103
4104 let original_home = std::env::var("HOME").ok();
4105 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
4106
4107 let config = load_config();
4108
4109 if let Some(home) = original_home {
4110 std::env::set_var("HOME", home);
4111 } else {
4112 std::env::remove_var("HOME");
4113 }
4114
4115 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
4116
4117 assert_eq!(
4118 config.disabled_plugins,
4119 vec!["foo".to_string(), "bar".to_string()]
4120 );
4121 assert_eq!(
4122 config.favorite_models,
4123 vec![
4124 "claude/claude-opus-4-7".to_string(),
4125 "groq/llama-3.3-70b-versatile".to_string(),
4126 ]
4127 );
4128 assert_eq!(
4129 config.disabled_skills,
4130 vec!["baz".to_string(), "plug:qual".to_string()]
4131 );
4132 }
4133
4134 #[test]
4135 #[serial]
4136 fn favorite_model_helpers_round_trip_through_config_file() {
4137 let home = make_test_home("favorite-models");
4138 let cfg = home.join(".synaps-cli/config");
4139 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
4140
4141 with_home(&home, || {
4142 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
4143 add_favorite_model("claude/claude-opus-4-7").unwrap();
4144 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
4145 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
4146 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
4147 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
4148 assert!(is_favorite_model("claude/claude-opus-4-7"));
4149 });
4150
4151 let contents = std::fs::read_to_string(&cfg).unwrap();
4152 assert!(contents.contains("model = claude-opus-4-7"));
4153 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
4154 let _ = std::fs::remove_dir_all(&home);
4155 }
4156
4157 #[test]
4158 #[serial]
4159 fn test_load_config_new_keys() {
4160 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
4162 let _ = std::fs::create_dir_all(&test_dir);
4163 let config_path = test_dir.join("config");
4164
4165 let config_content = r#"
4166# Test config with new keys
4167model = claude-haiku
4168thinking = medium
4169max_tool_output = 50000
4170bash_timeout = 45
4171bash_max_timeout = 600
4172subagent_timeout = 120
4173api_retries = 5
4174"#;
4175 std::fs::write(&config_path, config_content).unwrap();
4176
4177 let original_home = std::env::var("HOME").ok();
4179 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
4180
4181 let config = load_config();
4182
4183 if let Some(home) = original_home {
4185 std::env::set_var("HOME", home);
4186 } else {
4187 std::env::remove_var("HOME");
4188 }
4189
4190 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
4192
4193 assert_eq!(config.model, Some("claude-haiku".to_string()));
4194 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
4196 assert_eq!(config.bash_timeout, 45);
4197 assert_eq!(config.bash_max_timeout, 600);
4198 assert_eq!(config.subagent_timeout, 120);
4199 assert_eq!(config.api_retries, 5);
4200 }
4201
4202 #[test]
4207 fn events_auto_turn_explicit_true_values() {
4208 for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
4209 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
4210 assert!(
4211 cfg.events.auto_turn,
4212 "expected true for events.auto_turn = {val}"
4213 );
4214 }
4215 }
4216
4217 #[test]
4218 fn events_auto_turn_explicit_false_values() {
4219 for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
4220 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
4221 assert!(
4222 !cfg.events.auto_turn,
4223 "expected false for events.auto_turn = {val}"
4224 );
4225 }
4226 }
4227
4228 #[test]
4229 fn events_auto_turn_typo_fails_safe_false() {
4230 let cfg = load_config_from_str("events.auto_turn = fales");
4232 assert!(
4233 !cfg.events.auto_turn,
4234 "typo 'fales' must fail safe to false"
4235 );
4236 }
4237
4238 #[test]
4241 fn events_auto_turn_cap_default_is_five() {
4242 let cfg = load_config_from_str("");
4243 assert_eq!(cfg.events.auto_turn_cap, 5);
4244 assert_eq!(DEFAULT_AUTO_TURN_CAP, 5);
4245 }
4246
4247 #[test]
4248 fn events_auto_turn_cap_zero_is_unlimited() {
4249 let cfg = load_config_from_str("events.auto_turn_cap = 0");
4250 assert_eq!(cfg.events.auto_turn_cap, 0);
4251 }
4252
4253 #[test]
4254 fn events_auto_turn_cap_unlimited_aliases() {
4255 for val in ["unlimited", "inf", "infinite", "Infinity", "UNLIMITED", " inf "] {
4256 let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
4257 assert_eq!(
4258 cfg.events.auto_turn_cap, 0,
4259 "expected 0 for events.auto_turn_cap = {val:?}"
4260 );
4261 }
4262 }
4263
4264 #[test]
4265 fn events_auto_turn_cap_explicit_number() {
4266 let cfg = load_config_from_str("events.auto_turn_cap = 12");
4267 assert_eq!(cfg.events.auto_turn_cap, 12);
4268 }
4269
4270 #[test]
4271 fn events_auto_turn_cap_garbage_keeps_default() {
4272 for val in ["banana", "-3", "5.5", "", "true"] {
4273 let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
4274 assert_eq!(
4275 cfg.events.auto_turn_cap, 5,
4276 "garbage {val:?} must keep default 5"
4277 );
4278 }
4279 }
4280
4281 #[test]
4282 fn events_auto_turn_cap_independent_of_auto_turn() {
4283 let cfg = load_config_from_str("events.auto_turn = false\nevents.auto_turn_cap = 0");
4284 assert!(!cfg.events.auto_turn);
4285 assert_eq!(cfg.events.auto_turn_cap, 0);
4286 }
4287
4288 #[test]
4289 fn events_auto_turn_default_is_true() {
4290 let cfg = load_config_from_str("model = claude-haiku");
4291 assert!(cfg.events.auto_turn, "default must be true when key absent");
4292 }
4293
4294 #[test]
4295 #[serial]
4296 fn test_auth_config_parses_endpoint_and_token() {
4297 let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
4298 let dir = home.join(".synaps-cli");
4299 std::fs::create_dir_all(&dir).unwrap();
4300 std::fs::write(
4301 dir.join("config"),
4302 "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
4303 )
4304 .unwrap();
4305 with_home(&home, || {
4306 let config = load_config();
4307 assert_eq!(
4308 config.auth.remote_endpoint.as_deref(),
4309 Some("https://jade.jade:8181")
4310 );
4311 assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
4312 assert!(
4314 !config.warnings.iter().any(|w| w.contains("auth.")),
4315 "{:?}",
4316 config.warnings
4317 );
4318 });
4319 let _ = std::fs::remove_dir_all(&home);
4320 }
4321
4322 #[test]
4323 #[serial]
4324 fn test_credential_source_local_by_default() {
4325 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
4326 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
4327 assert!(!AuthConfig::default().credential_source().is_remote());
4328 }
4329
4330 #[test]
4331 #[serial]
4332 fn test_credential_source_remote_from_config() {
4333 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
4334 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
4335 let auth = AuthConfig {
4336 remote_endpoint: Some("https://b".into()),
4337 machine_token: Some("m".into()),
4338 accounts: BTreeMap::new(),
4339 auto: BTreeMap::new(),
4340 };
4341 assert_eq!(
4342 auth.credential_source(),
4343 crate::core::auth::CredentialSource::Remote {
4344 endpoint: "https://b".into(),
4345 machine_token: "m".into()
4346 }
4347 );
4348 }
4349
4350 #[test]
4351 fn auth_account_keys_parse_and_invalid_fail_closed_with_warning() {
4352 let config = load_config_from_str(
4353 "auth.account.openai-codex = astra2\nauth.account.anthropic = auto\nauth.account.kimi-code = Bad Label\nauth.account.groq = x\n",
4354 );
4355 assert_eq!(
4356 config.auth.accounts.get("openai-codex").map(String::as_str),
4357 Some("astra2")
4358 );
4359 assert_eq!(
4360 config.auth.accounts.get("anthropic").map(String::as_str),
4361 Some("auto")
4362 );
4363 let policy = config.auth.account_policy();
4364 use crate::core::auth::{AccountSelector, OAuthProviderId};
4365 assert!(matches!(
4366 policy.selector(OAuthProviderId::OpenAiCodex),
4367 AccountSelector::Account(_)
4368 ));
4369 assert_eq!(
4370 policy.selector(OAuthProviderId::Anthropic),
4371 AccountSelector::Auto
4372 );
4373 assert!(
4374 matches!(
4375 policy.selector(OAuthProviderId::KimiCode),
4376 AccountSelector::Invalid { .. }
4377 ),
4378 "malformed label must fail closed"
4379 );
4380 assert!(
4381 config
4382 .warnings
4383 .iter()
4384 .any(|w| w.contains("auth.account.kimi-code")),
4385 "{:?}",
4386 config.warnings
4387 );
4388 assert!(
4389 config
4390 .warnings
4391 .iter()
4392 .any(|w| w.contains("auth.account.groq")),
4393 "{:?}",
4394 config.warnings
4395 );
4396 assert!(
4397 !config
4398 .warnings
4399 .iter()
4400 .any(|w| w.contains("auth.account.openai-codex")),
4401 "{:?}",
4402 config.warnings
4403 );
4404 }
4405
4406 #[test]
4407 #[serial]
4408 fn test_credential_source_env_overrides_config() {
4409 let auth = AuthConfig {
4410 remote_endpoint: Some("https://config-host".into()),
4411 machine_token: Some("config-tok".into()),
4412 accounts: BTreeMap::new(),
4413 auto: BTreeMap::new(),
4414 };
4415 std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
4416 std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
4417 let src = auth.credential_source();
4418 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
4419 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
4420 assert_eq!(
4421 src,
4422 crate::core::auth::CredentialSource::Remote {
4423 endpoint: "https://env-host".into(),
4424 machine_token: "env-tok".into()
4425 }
4426 );
4427 }
4428 #[test]
4431 #[serial]
4432 fn test_credential_source_records_token_provenance_and_file_is_resolved_lazily() {
4433 use crate::core::auth::MachineToken;
4434 let dir = tempfile::tempdir().unwrap();
4435 let prev = std::env::var_os("SYNAPS_BASE_DIR");
4436 std::env::set_var("SYNAPS_BASE_DIR", dir.path());
4437 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
4438 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
4439 std::fs::write(dir.path().join("auth-broker.token"), "local-file-tok\n").unwrap();
4440 let source = |endpoint: &str, token: Option<&str>| {
4441 AuthConfig {
4442 remote_endpoint: Some(endpoint.into()),
4443 machine_token: token.map(str::to_string),
4444 accounts: BTreeMap::new(),
4445 auto: BTreeMap::new(),
4446 }
4447 .credential_source()
4448 };
4449 let remote_tok = |s: crate::core::auth::CredentialSource| match s {
4450 crate::core::auth::CredentialSource::Remote { machine_token, .. } => machine_token,
4451 other => panic!("{other:?}"),
4452 };
4453 let loopback = remote_tok(source("http://127.0.0.1:8181", None));
4455 let explicit = remote_tok(source("http://127.0.0.1:8181", Some("cfg-tok")));
4456 let remote = remote_tok(source("https://jade.jade:8181", None));
4457 assert_eq!(loopback, "");
4458 assert_eq!(explicit, "cfg-tok");
4459 assert_eq!(remote, "");
4460 let auto = MachineToken::for_endpoint("http://localhost:8181", &loopback);
4462 let fixed = MachineToken::for_endpoint("http://127.0.0.1:8181", &explicit);
4463 let far = MachineToken::for_endpoint("https://jade.jade:8181", &remote);
4464 match prev {
4465 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
4466 None => std::env::remove_var("SYNAPS_BASE_DIR"),
4467 }
4468 assert_eq!(auto.get(), "local-file-tok");
4469 assert_eq!(fixed.get(), "cfg-tok", "explicit token is never replaced");
4470 assert_eq!(far.get(), "", "never read for a non-loopback broker");
4471 }
4472
4473 #[test]
4474 fn config_parses_ultracode_as_distinct_canonical_level() {
4475 let cfg = load_config_from_str("thinking = ultracode\n");
4476 assert_eq!(
4477 cfg.thinking_level,
4478 Some(crate::reasoning::ReasoningLevel::UltraCode)
4479 );
4480 assert_eq!(cfg.thinking_budget, None);
4481 for other in [
4482 crate::reasoning::ReasoningLevel::Ultra,
4483 crate::reasoning::ReasoningLevel::Max,
4484 crate::reasoning::ReasoningLevel::XHigh,
4485 ] {
4486 assert_ne!(cfg.thinking_level, Some(other));
4487 }
4488 }
4489
4490 #[test]
4495 fn context_window_auto_is_model_default_without_warning() {
4496 for spelling in ["auto", "AUTO", "Auto"] {
4497 let cfg = load_config_from_str(&format!("context_window = {spelling}\n"));
4498 assert_eq!(cfg.context_window, None, "{spelling}");
4499 assert!(cfg.warnings.is_empty(), "{spelling}: {:?}", cfg.warnings);
4500 }
4501 let cfg = load_config_from_str("context_window = 1m\ncontext_window = auto\n");
4503 assert_eq!(cfg.context_window, None);
4504 assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
4505 assert_eq!(
4507 load_config_from_str("context_window = 200k\n").context_window,
4508 Some(200_000)
4509 );
4510 let bad = load_config_from_str("context_window = huge\n");
4511 assert_eq!(bad.context_window, None);
4512 assert!(bad.warnings.iter().any(|w| w.contains("context_window")));
4513 }
4514
4515 #[test]
4518 fn sidecar_toggle_key_is_a_known_key() {
4519 let cfg = load_config_from_str("sidecar_toggle_key = F2\n");
4520 assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
4521 assert!(KNOWN_CONFIG_KEYS.contains(&"sidecar_toggle_key"));
4522 assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_mode"));
4523 assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_exclude"));
4524 }
4525
4526 #[test]
4530 fn config_content_problems_covers_family_diagnostics() {
4531 for bad in [
4532 "shell.max_sessions = lots",
4533 "shell.readiness_strategy = eventually",
4534 "events.auto_turn = maybe",
4535 "events.auto_turn_cap = many",
4536 "turn_budget.worker.max_cost_usd = cheap",
4537 "turn_budget.nobody.max_tool_calls = 3",
4538 "memory.capture_tools = everything",
4539 "memory.recall_max_records = -1",
4540 "memory.user_scope = yes",
4541 "cache_ttl = forever",
4542 ] {
4543 let problems = config_content_problems(bad);
4544 assert!(!problems.is_empty(), "{bad} should be reported");
4545 }
4546 for good in [
4547 "shell.max_sessions = 8",
4548 "shell.readiness_strategy = prompt",
4549 "events.auto_turn = off",
4550 "events.auto_turn_cap = unlimited",
4551 "turn_budget.worker.max_cost_usd = 2.5",
4552 "memory.capture_tools = off",
4553 "memory.recall_max_records = 4",
4554 "memory.user_scope = true",
4555 "cache_ttl = hybrid",
4556 ] {
4557 let problems = config_content_problems(good);
4558 assert!(problems.is_empty(), "{good}: {problems:?}");
4559 }
4560 let cfg = load_config_from_str("shell.max_sessions = 8\nevents.auto_turn = maybe\n");
4562 assert_eq!(cfg.shell.max_sessions, 8);
4563 assert!(!cfg.events.auto_turn, "unrecognised auto_turn still fails safe to false");
4564 }
4565
4566 #[test]
4567 fn bare_or_empty_auth_family_keys_warn() {
4568 let cfg = load_config_from_str(
4569 "auth.account = work\nauth.account. = work\nauth.auto = on\nauth.auto. = 1\n",
4570 );
4571 let w = &cfg.warnings;
4572 for key in ["auth.account", "auth.account."] {
4573 assert!(
4574 w.contains(&format!("unknown key '{key}' (did you mean 'auth.account.<provider>'?)")),
4575 "{key}: {w:?}"
4576 );
4577 }
4578 for key in ["auth.auto", "auth.auto."] {
4579 assert!(
4580 w.contains(&format!("unknown key '{key}' (did you mean 'auth.auto.<key>'?)")),
4581 "{key}: {w:?}"
4582 );
4583 }
4584 assert!(cfg.auth.accounts.is_empty() && cfg.auth.auto.is_empty());
4585 }
4586
4587 #[test]
4588 fn unknown_auth_keys_warn_with_a_suggestion() {
4589 let cfg = load_config_from_str(
4590 "auth.acount.anthropic = work\nauth.remote_endpont = x\nauth.bogus = 1\n\
4591 auth.account.anthropic = work\nauth.machine_token = t\n",
4592 );
4593 let w = &cfg.warnings;
4594 assert!(
4595 w.contains(&"unknown key 'auth.acount.anthropic' (did you mean 'auth.account.anthropic'?)".into()),
4596 "{w:?}"
4597 );
4598 assert!(
4599 w.contains(&"unknown key 'auth.remote_endpont' (did you mean 'auth.remote_endpoint'?)".into()),
4600 "{w:?}"
4601 );
4602 assert!(w.contains(&"unknown key 'auth.bogus' — ignored".into()), "{w:?}");
4603 assert_eq!(w.iter().filter(|w| w.contains("auth.")).count(), 3, "{w:?}");
4604 assert_eq!(cfg.auth.accounts.get("anthropic").map(String::as_str), Some("work"));
4605 assert!(!cfg.auth.accounts.contains_key("acount.anthropic"));
4606 }
4607
4608 #[test]
4611 fn family_config_keys_are_real_parser_keys() {
4612 let sample = |key: &str| -> &'static str {
4613 match key {
4614 "shell.readiness_strategy" => "hybrid",
4615 "memory.backend" => "legacy",
4616 "memory.user_scope" | "memory.default_mode_confirmed" => "true",
4617 "memory.default_mode" => "off",
4618 "memory.capture_tools" => "summary_only",
4619 "memory.capture_assistant" | "memory.capture_user" => "on",
4620 "memory.auto_consolidate" | "memory.local_embeddings" | "memory.gliner" => "off",
4621 "memory.axel.executable" | "memory.axel.brain" => "/opt/axel",
4622 "events.auto_turn" => "on",
4623 "server.allowed_origins" | "server.token" => "x",
4624 "server.auto_approve_confirms" | "bridge.heartbeat_mirror" => "false",
4625 "bridge.uds_path" => "/tmp/x.sock",
4626 "auth.remote_endpoint" | "auth.machine_token" => "x",
4627 _ => "7",
4628 }
4629 };
4630 for key in FAMILY_CONFIG_KEYS.iter().filter(|k| !k.ends_with(".*")) {
4631 assert!(!KNOWN_CONFIG_KEYS.contains(key), "{key} is listed twice");
4632 let content = format!("{key} = {}\n", sample(key));
4633 let problems = config_content_problems(&content);
4634 assert!(problems.is_empty(), "{key}: {problems:?}");
4635 }
4636 }
4637
4638 #[test]
4639 #[serial]
4640 fn validate_config_edit_uses_the_real_parser_and_whole_file() {
4641 let home = make_test_home("validate-edit");
4642 let cfg = home.join(".synaps-cli/config");
4643 with_home(&home, || {
4644 assert!(validate_config_edit("cache_ttl", "1h").is_ok());
4646 assert!(validate_config_edit("cache_ttl", "2h").is_err());
4647 assert!(validate_config_edit("shell.max_output", "big").is_err());
4648 assert!(validate_config_edit("memory.axel.brain", "relative/brain").is_err());
4649 assert!(validate_config_edit("memory.axel.brain", "/abs/brain.r8").is_ok());
4650 assert!(validate_config_edit("model", "a\nb").is_err());
4651
4652 std::fs::write(&cfg, "context_management.rollover_tokens = 100000\n").unwrap();
4654 assert!(validate_config_edit("context_management.pressure_tokens", "90000").is_ok());
4655 assert!(validate_config_edit("context_management.pressure_tokens", "150000").is_err());
4656 std::fs::write(&cfg, "subagent.max_total = 8\n").unwrap();
4657 assert!(validate_config_edit("subagent.max_concurrent", "8").is_ok());
4658 assert!(validate_config_edit("subagent.max_concurrent", "9").is_err());
4659 std::fs::write(&cfg, "").unwrap();
4660 assert!(validate_config_edit("memory.default_mode", "recall_once").is_err());
4661 assert!(validate_config_edit("memory.default_mode", "off").is_ok());
4662 std::fs::write(&cfg, "memory.default_mode_confirmed = true\n").unwrap();
4663 assert!(validate_config_edit("memory.default_mode", "recall_once").is_ok());
4664
4665 std::fs::write(&cfg, "cache_ttl = forever\n").unwrap();
4667 assert!(validate_config_edit("max_fps", "120").is_ok());
4668 assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "cache_ttl = forever\n");
4670 });
4671 let _ = std::fs::remove_dir_all(&home);
4672 }
4673}