1#[derive(Debug, Clone)]
3pub struct SecretFieldInfo {
4 pub name: &'static str,
6 pub category: &'static str,
8 pub is_set: bool,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PropKind {
15 String,
16 Bool,
17 Integer,
18 Float,
19 Enum,
21 StringArray,
23 ObjectArray,
30 Object,
36}
37
38pub trait HasPropKind {
42 const PROP_KIND: PropKind;
43}
44
45macro_rules! impl_prop_kind {
46 ($kind:expr, $($ty:ty),+) => {
47 $(impl HasPropKind for $ty { const PROP_KIND: PropKind = $kind; })+
48 };
49}
50
51impl_prop_kind!(PropKind::Bool, bool);
52impl_prop_kind!(PropKind::String, String);
53impl_prop_kind!(PropKind::Float, f64, f32);
54impl_prop_kind!(
55 PropKind::Integer,
56 u8,
57 u16,
58 u32,
59 u64,
60 usize,
61 i8,
62 i16,
63 i32,
64 i64,
65 isize
66);
67impl HasPropKind for Vec<String> {
68 const PROP_KIND: PropKind = PropKind::StringArray;
69}
70
71impl HasPropKind for crate::providers::ModelProviderRef {
75 const PROP_KIND: PropKind = PropKind::String;
76}
77impl HasPropKind for crate::providers::TtsProviderRef {
78 const PROP_KIND: PropKind = PropKind::String;
79}
80impl HasPropKind for crate::providers::TranscriptionProviderRef {
81 const PROP_KIND: PropKind = PropKind::String;
82}
83impl HasPropKind for crate::providers::ChannelRef {
84 const PROP_KIND: PropKind = PropKind::String;
85}
86impl HasPropKind for Vec<crate::providers::ChannelRef> {
87 const PROP_KIND: PropKind = PropKind::StringArray;
88}
89
90impl HasPropKind for crate::multi_agent::AgentAlias {
94 const PROP_KIND: PropKind = PropKind::String;
95}
96impl HasPropKind for crate::multi_agent::PeerGroupName {
97 const PROP_KIND: PropKind = PropKind::String;
98}
99impl HasPropKind for crate::multi_agent::PeerUsername {
100 const PROP_KIND: PropKind = PropKind::String;
101}
102impl HasPropKind for crate::multi_agent::AccessMode {
103 const PROP_KIND: PropKind = PropKind::Enum;
104}
105impl HasPropKind for crate::multi_agent::MemoryBackendKind {
106 const PROP_KIND: PropKind = PropKind::Enum;
107}
108impl HasPropKind for Vec<crate::multi_agent::AgentAlias> {
109 const PROP_KIND: PropKind = PropKind::StringArray;
110}
111impl HasPropKind for Vec<crate::multi_agent::PeerUsername> {
112 const PROP_KIND: PropKind = PropKind::StringArray;
113}
114impl HasPropKind
115 for std::collections::BTreeMap<crate::multi_agent::AgentAlias, crate::multi_agent::AccessMode>
116{
117 const PROP_KIND: PropKind = PropKind::Object;
119}
120
121impl HasPropKind for Vec<crate::schema::ClassificationRule> {
129 const PROP_KIND: PropKind = PropKind::ObjectArray;
130}
131impl HasPropKind for Vec<crate::schema::EmbeddingRouteConfig> {
132 const PROP_KIND: PropKind = PropKind::ObjectArray;
133}
134impl HasPropKind for Vec<crate::schema::GoogleWorkspaceAllowedOperation> {
135 const PROP_KIND: PropKind = PropKind::ObjectArray;
136}
137impl HasPropKind for Vec<crate::schema::McpServerConfig> {
138 const PROP_KIND: PropKind = PropKind::ObjectArray;
139}
140impl HasPropKind for Vec<crate::schema::ModelRouteConfig> {
141 const PROP_KIND: PropKind = PropKind::ObjectArray;
142}
143impl HasPropKind for Vec<crate::schema::NevisRoleMappingConfig> {
144 const PROP_KIND: PropKind = PropKind::ObjectArray;
145}
146impl HasPropKind for Vec<crate::schema::PeripheralBoardConfig> {
147 const PROP_KIND: PropKind = PropKind::ObjectArray;
148}
149impl HasPropKind for Vec<crate::schema::ToolFilterGroup> {
150 const PROP_KIND: PropKind = PropKind::ObjectArray;
151}
152
153#[derive(Clone)]
155pub struct PropFieldInfo {
156 pub name: String,
161 pub category: &'static str,
163 pub display_value: String,
165 pub type_hint: &'static str,
167 pub kind: PropKind,
169 pub is_secret: bool,
171 pub enum_variants: Option<fn() -> Vec<String>>,
173 pub description: &'static str,
177 pub derived_from_secret: bool,
181}
182
183impl PropFieldInfo {
184 pub fn is_enum(&self) -> bool {
185 self.enum_variants.is_some()
186 }
187}
188
189impl std::fmt::Debug for PropFieldInfo {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 f.debug_struct("PropFieldInfo")
192 .field("name", &self.name)
193 .field("kind", &self.kind)
194 .field("is_secret", &self.is_secret)
195 .finish_non_exhaustive()
196 }
197}
198
199pub trait MaskSecrets {
206 fn mask_secrets(&mut self);
207 fn restore_secrets_from(&mut self, current: &Self);
208}
209
210impl<T: MaskSecrets> MaskSecrets for std::collections::HashMap<String, T> {
211 fn mask_secrets(&mut self) {
212 for v in self.values_mut() {
213 v.mask_secrets();
214 }
215 }
216 fn restore_secrets_from(&mut self, current: &Self) {
217 for (k, v) in self.iter_mut() {
218 if let Some(cur) = current.get(k) {
219 v.restore_secrets_from(cur);
220 }
221 }
222 }
223}
224
225impl<T: MaskSecrets> MaskSecrets for Vec<T> {
226 fn mask_secrets(&mut self) {
227 for v in self.iter_mut() {
228 v.mask_secrets();
229 }
230 }
231 fn restore_secrets_from(&mut self, current: &Self) {
232 for (v, cur) in self.iter_mut().zip(current.iter()) {
233 v.restore_secrets_from(cur);
234 }
235 }
236}
237
238pub const MASKED_SECRET: &str = "***MASKED***";
239
240pub fn is_masked_secret(value: &str) -> bool {
241 value == MASKED_SECRET
242}
243
244pub trait SecretField {
257 fn mask(&mut self);
259
260 fn restore_from(&mut self, current: &Self);
265
266 fn encrypt_in_place(
268 &mut self,
269 store: &crate::security::SecretStore,
270 field: &str,
271 ) -> anyhow::Result<()>;
272
273 fn decrypt_in_place(
275 &mut self,
276 store: &crate::security::SecretStore,
277 field: &str,
278 ) -> anyhow::Result<()>;
279
280 fn is_set(&self) -> bool;
283}
284
285impl SecretField for String {
286 fn mask(&mut self) {
287 if !self.is_empty() {
288 *self = MASKED_SECRET.to_string();
289 }
290 }
291
292 fn restore_from(&mut self, current: &Self) {
293 if is_masked_secret(self) {
294 self.clone_from(current);
295 }
296 }
297
298 fn encrypt_in_place(
299 &mut self,
300 store: &crate::security::SecretStore,
301 field: &str,
302 ) -> anyhow::Result<()> {
303 use anyhow::Context;
304 if !self.is_empty() && !crate::security::SecretStore::is_encrypted(self) {
305 *self = store
306 .encrypt(self)
307 .with_context(|| format!("Failed to encrypt {field}"))?;
308 }
309 Ok(())
310 }
311
312 fn decrypt_in_place(
313 &mut self,
314 store: &crate::security::SecretStore,
315 field: &str,
316 ) -> anyhow::Result<()> {
317 use anyhow::Context;
318 if crate::security::SecretStore::is_encrypted(self) {
319 *self = store
320 .decrypt(self)
321 .with_context(|| format!("Failed to decrypt {field}"))?;
322 }
323 Ok(())
324 }
325
326 fn is_set(&self) -> bool {
327 !self.is_empty()
328 }
329}
330
331impl SecretField for Option<String> {
332 fn mask(&mut self) {
333 if let Some(inner) = self {
334 inner.mask();
335 }
336 }
337
338 fn restore_from(&mut self, current: &Self) {
339 if let (Some(inner), Some(cur)) = (self.as_mut(), current.as_ref()) {
340 inner.restore_from(cur);
341 }
342 }
343
344 fn encrypt_in_place(
345 &mut self,
346 store: &crate::security::SecretStore,
347 field: &str,
348 ) -> anyhow::Result<()> {
349 match self {
350 Some(inner) => inner.encrypt_in_place(store, field),
351 None => Ok(()),
352 }
353 }
354
355 fn decrypt_in_place(
356 &mut self,
357 store: &crate::security::SecretStore,
358 field: &str,
359 ) -> anyhow::Result<()> {
360 match self {
361 Some(inner) => inner.decrypt_in_place(store, field),
362 None => Ok(()),
363 }
364 }
365
366 fn is_set(&self) -> bool {
367 self.as_ref().is_some_and(|v| !v.is_empty())
368 }
369}
370
371impl SecretField for Vec<String> {
372 fn mask(&mut self) {
373 for element in self.iter_mut() {
374 element.mask();
375 }
376 }
377
378 fn restore_from(&mut self, current: &Self) {
379 for (element, cur) in self.iter_mut().zip(current.iter()) {
380 element.restore_from(cur);
381 }
382 }
383
384 fn encrypt_in_place(
385 &mut self,
386 store: &crate::security::SecretStore,
387 field: &str,
388 ) -> anyhow::Result<()> {
389 for (idx, element) in self.iter_mut().enumerate() {
390 element.encrypt_in_place(store, &format!("{field}[{idx}]"))?;
391 }
392 Ok(())
393 }
394
395 fn decrypt_in_place(
396 &mut self,
397 store: &crate::security::SecretStore,
398 field: &str,
399 ) -> anyhow::Result<()> {
400 for (idx, element) in self.iter_mut().enumerate() {
401 element.decrypt_in_place(store, &format!("{field}[{idx}]"))?;
402 }
403 Ok(())
404 }
405
406 fn is_set(&self) -> bool {
407 !self.is_empty()
408 }
409}
410
411impl SecretField for std::collections::HashMap<String, String> {
412 fn mask(&mut self) {
413 for value in self.values_mut() {
414 value.mask();
415 }
416 }
417
418 fn restore_from(&mut self, current: &Self) {
419 for (key, value) in self.iter_mut() {
420 if let Some(cur) = current.get(key) {
421 value.restore_from(cur);
422 }
423 }
424 }
425
426 fn encrypt_in_place(
427 &mut self,
428 store: &crate::security::SecretStore,
429 field: &str,
430 ) -> anyhow::Result<()> {
431 for (key, value) in self.iter_mut() {
432 value.encrypt_in_place(store, &format!("{field}.{key}"))?;
433 }
434 Ok(())
435 }
436
437 fn decrypt_in_place(
438 &mut self,
439 store: &crate::security::SecretStore,
440 field: &str,
441 ) -> anyhow::Result<()> {
442 for (key, value) in self.iter_mut() {
443 value.decrypt_in_place(store, &format!("{field}.{key}"))?;
444 }
445 Ok(())
446 }
447
448 fn is_set(&self) -> bool {
449 self.values().any(|v| !v.is_empty())
450 }
451}
452
453impl SecretField for Option<std::collections::HashMap<String, String>> {
454 fn mask(&mut self) {
455 if let Some(inner) = self {
456 inner.mask();
457 }
458 }
459
460 fn restore_from(&mut self, current: &Self) {
461 if let (Some(inner), Some(cur)) = (self.as_mut(), current.as_ref()) {
462 inner.restore_from(cur);
463 }
464 }
465
466 fn encrypt_in_place(
467 &mut self,
468 store: &crate::security::SecretStore,
469 field: &str,
470 ) -> anyhow::Result<()> {
471 match self {
472 Some(inner) => inner.encrypt_in_place(store, field),
473 None => Ok(()),
474 }
475 }
476
477 fn decrypt_in_place(
478 &mut self,
479 store: &crate::security::SecretStore,
480 field: &str,
481 ) -> anyhow::Result<()> {
482 match self {
483 Some(inner) => inner.decrypt_in_place(store, field),
484 None => Ok(()),
485 }
486 }
487
488 fn is_set(&self) -> bool {
489 self.as_ref()
490 .is_some_and(|m| m.values().any(|v| !v.is_empty()))
491 }
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499#[cfg_attr(
500 feature = "schema-export",
501 derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)
502)]
503#[cfg_attr(feature = "schema-export", serde(rename_all = "snake_case"))]
504pub enum MapKeyKind {
505 Map,
507 List,
510}
511
512#[derive(Debug, Clone, Copy)]
513#[cfg_attr(
514 feature = "schema-export",
515 derive(serde::Serialize, schemars::JsonSchema)
516)]
517pub struct MapKeySection {
518 pub path: &'static str,
520 pub kind: MapKeyKind,
522 pub value_type: &'static str,
524 pub description: &'static str,
527}
528
529#[derive(Debug, Clone, Copy)]
535pub struct NestedOptionEntry {
536 pub field: &'static str,
539 pub present: bool,
541 pub display_name: &'static str,
545 pub description: &'static str,
548}
549
550#[derive(Debug, Clone, Copy)]
555pub struct IntegrationDescriptor {
556 pub display_name: &'static str,
557 pub description: &'static str,
558 pub category: &'static str,
563 pub active: bool,
566}
567
568#[derive(Debug, Clone)]
570pub struct ChannelInfo {
571 pub name: &'static str,
572 pub desc: &'static str,
573 pub configured: bool,
574}
575
576pub trait ChannelConfig {
578 fn name() -> &'static str;
580 fn desc() -> &'static str;
582}
583
584#[derive(Debug, Clone)]
587pub struct SelectItem {
588 pub label: String,
589 pub badge: Option<String>,
590}
591
592impl SelectItem {
593 pub fn new(label: impl Into<String>) -> Self {
594 Self {
595 label: label.into(),
596 badge: None,
597 }
598 }
599
600 pub fn with_badge(label: impl Into<String>, badge: impl Into<String>) -> Self {
601 Self {
602 label: label.into(),
603 badge: Some(badge.into()),
604 }
605 }
606}
607
608#[derive(Debug, Clone)]
612pub enum Answer<T> {
613 Value(T),
614 Back,
615}
616
617#[async_trait::async_trait]
631pub trait OnboardUi: Send {
632 async fn confirm(&mut self, prompt: &str, default: bool) -> anyhow::Result<Answer<bool>>;
633
634 async fn string(
649 &mut self,
650 prompt: &str,
651 current: Option<&str>,
652 placeholder: Option<&str>,
653 ) -> anyhow::Result<Answer<String>>;
654
655 async fn secret(
659 &mut self,
660 prompt: &str,
661 has_current: bool,
662 ) -> anyhow::Result<Answer<Option<String>>>;
663
664 async fn select(
665 &mut self,
666 prompt: &str,
667 items: &[SelectItem],
668 current: Option<usize>,
669 ) -> anyhow::Result<Answer<usize>>;
670
671 async fn editor(&mut self, hint: &str, initial: &str) -> anyhow::Result<Answer<String>>;
672
673 fn heading(&mut self, level: u8, text: &str);
679 fn note(&mut self, msg: &str);
680 fn status(&mut self, msg: &str);
681 fn warn(&mut self, msg: &str);
682}
683
684#[cfg(test)]
685mod secret_field_tests {
686 use super::{MASKED_SECRET, SecretField};
687 use crate::security::SecretStore;
688 use std::collections::HashMap;
689 use tempfile::TempDir;
690
691 fn store() -> (TempDir, SecretStore) {
692 let tmp = TempDir::new().unwrap();
693 let store = SecretStore::new(tmp.path(), true);
694 (tmp, store)
695 }
696
697 #[test]
698 fn string_roundtrip_and_idempotent() {
699 let (_tmp, store) = store();
700 let mut s = String::from("sk-abc");
701 s.encrypt_in_place(&store, "test.s").unwrap();
702 assert!(SecretStore::is_encrypted(&s));
703 let enc1 = s.clone();
704 s.encrypt_in_place(&store, "test.s").unwrap();
706 assert_eq!(s, enc1);
707 s.decrypt_in_place(&store, "test.s").unwrap();
708 assert_eq!(s, "sk-abc");
709 }
710
711 #[test]
712 fn string_empty_stays_empty() {
713 let (_tmp, store) = store();
714 let mut s = String::new();
715 s.encrypt_in_place(&store, "test.s").unwrap();
716 assert_eq!(s, "");
717 assert!(!s.is_set());
718 }
719
720 #[test]
721 fn string_mask_and_restore() {
722 let mut s = String::from("Bearer xyz");
723 let cur = String::from("Bearer xyz");
724 s.mask();
725 assert_eq!(s, MASKED_SECRET);
726 s.restore_from(&cur);
727 assert_eq!(s, "Bearer xyz");
728 }
729
730 #[test]
731 fn option_string_none_is_noop() {
732 let (_tmp, store) = store();
733 let mut v: Option<String> = None;
734 v.encrypt_in_place(&store, "test.o").unwrap();
735 v.decrypt_in_place(&store, "test.o").unwrap();
736 v.mask();
737 assert_eq!(v, None);
738 assert!(!v.is_set());
739 }
740
741 #[test]
742 fn option_string_some_roundtrip() {
743 let (_tmp, store) = store();
744 let mut v: Option<String> = Some("Bearer xyz".into());
745 v.encrypt_in_place(&store, "test.o").unwrap();
746 assert!(SecretStore::is_encrypted(v.as_ref().unwrap()));
747 v.decrypt_in_place(&store, "test.o").unwrap();
748 assert_eq!(v.as_deref(), Some("Bearer xyz"));
749 assert!(v.is_set());
750 }
751
752 #[test]
753 fn vec_string_roundtrip_per_element() {
754 let (_tmp, store) = store();
755 let mut v: Vec<String> = vec!["one".into(), "".into(), "two".into()];
756 v.encrypt_in_place(&store, "test.v").unwrap();
757 assert!(SecretStore::is_encrypted(&v[0]));
758 assert_eq!(v[1], "", "empty element must stay empty");
759 assert!(SecretStore::is_encrypted(&v[2]));
760 v.decrypt_in_place(&store, "test.v").unwrap();
761 assert_eq!(v, vec!["one", "", "two"]);
762 }
763
764 #[test]
765 fn hashmap_string_string_roundtrip_per_value() {
766 let (_tmp, store) = store();
767 let mut h: HashMap<String, String> = HashMap::from([
768 ("Authorization".into(), "Bearer sk-abc".into()),
769 ("X-Trace".into(), "req-123".into()),
770 ]);
771 h.encrypt_in_place(&store, "mcp.servers.foo.headers")
772 .unwrap();
773 for v in h.values() {
774 assert!(SecretStore::is_encrypted(v));
775 }
776 h.decrypt_in_place(&store, "mcp.servers.foo.headers")
777 .unwrap();
778 assert_eq!(
779 h.get("Authorization").map(String::as_str),
780 Some("Bearer sk-abc")
781 );
782 assert_eq!(h.get("X-Trace").map(String::as_str), Some("req-123"));
783 assert!(h.is_set());
784 }
785
786 #[test]
787 fn hashmap_string_string_mask_and_restore() {
788 let mut h: HashMap<String, String> =
789 HashMap::from([("Authorization".into(), "Bearer xyz".into())]);
790 let cur = h.clone();
791 h.mask();
792 assert_eq!(
793 h.get("Authorization").map(String::as_str),
794 Some(MASKED_SECRET)
795 );
796 h.restore_from(&cur);
797 assert_eq!(
798 h.get("Authorization").map(String::as_str),
799 Some("Bearer xyz")
800 );
801 }
802
803 #[test]
804 fn option_hashmap_none_is_noop() {
805 let (_tmp, store) = store();
806 let mut v: Option<HashMap<String, String>> = None;
807 v.encrypt_in_place(&store, "test.oh").unwrap();
808 v.decrypt_in_place(&store, "test.oh").unwrap();
809 v.mask();
810 assert!(v.is_none());
811 assert!(!v.is_set());
812 }
813
814 #[test]
815 fn option_hashmap_some_roundtrip() {
816 let (_tmp, store) = store();
817 let mut v: Option<HashMap<String, String>> =
818 Some(HashMap::from([("k".into(), "secret".into())]));
819 v.encrypt_in_place(&store, "test.oh").unwrap();
820 assert!(SecretStore::is_encrypted(
821 v.as_ref().unwrap().get("k").unwrap()
822 ));
823 v.decrypt_in_place(&store, "test.oh").unwrap();
824 assert_eq!(
825 v.as_ref().unwrap().get("k").map(String::as_str),
826 Some("secret")
827 );
828 assert!(v.is_set());
829 }
830
831 #[test]
832 fn hashmap_empty_is_not_set() {
833 let h: HashMap<String, String> = HashMap::new();
834 assert!(!h.is_set());
835 let oh: Option<HashMap<String, String>> = Some(HashMap::new());
836 assert!(!oh.is_set());
837 }
838
839 #[test]
840 fn hashmap_with_only_empty_values_is_not_set() {
841 let h: HashMap<String, String> = HashMap::from([
847 ("Authorization".into(), String::new()),
848 ("X-Trace".into(), String::new()),
849 ]);
850 assert!(!h.is_set());
851
852 let oh: Option<HashMap<String, String>> =
853 Some(HashMap::from([("Authorization".into(), String::new())]));
854 assert!(!oh.is_set());
855
856 let mixed: HashMap<String, String> = HashMap::from([
857 ("Authorization".into(), "Bearer xyz".into()),
858 ("X-Trace".into(), String::new()),
859 ]);
860 assert!(mixed.is_set(), "any non-empty value makes the map set");
861 }
862
863 #[test]
864 fn encrypt_decrypt_failure_message_includes_field_path() {
865 let tmp = TempDir::new().unwrap();
866 let bad_store = SecretStore::new(tmp.path(), true);
867 let mut s = String::from("enc2:not-valid-hex");
869 let err = s
870 .decrypt_in_place(&bad_store, "mcp.servers.foo.headers.Authorization")
871 .expect_err("malformed ciphertext must fail");
872 let msg = format!("{err:#}");
873 assert!(
874 msg.contains("mcp.servers.foo.headers.Authorization"),
875 "error must include field path; got: {msg}"
876 );
877 }
878}