1use crate::companion::{Formality, Relationship};
5use crate::deps::ProgramDep;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
15pub struct SkillCardEntry {
16 pub name: String,
17 #[serde(default, skip_serializing_if = "String::is_empty")]
18 pub version: String,
19 #[serde(default, skip_serializing_if = "String::is_empty")]
20 pub publisher: String,
21 #[serde(default, skip_serializing_if = "String::is_empty")]
22 pub description: String,
23 #[serde(default, skip_serializing_if = "String::is_empty")]
24 pub category: String,
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub tags: Vec<String>,
27 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub triggers: Vec<SkillCardTrigger>,
29 #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
32 pub abstract_text: String,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub transfer_chain: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
40pub struct SkillCardTrigger {
41 #[serde(rename = "type")]
42 pub kind: String,
43 #[serde(default, skip_serializing_if = "String::is_empty")]
44 pub pattern: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct AgentProfile {
49 pub schema: u32,
50 pub id: String, pub name: String,
52 pub display_name: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub role: Option<String>,
59 pub version: String,
60 pub persona: Persona,
61 pub sys_prompt_file: String,
62 pub model: ModelConfig,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub model_ref: Option<String>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub fallback_chain: Vec<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub routing: Option<crate::config::RoutingConfig>,
75 #[serde(default)]
76 pub mcp_servers: Vec<McpServerEntry>,
77 #[serde(default)]
78 pub skills: Vec<String>,
79 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 pub installed_skills: Vec<SkillCardEntry>,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
89 pub disabled_skills: Vec<String>,
90
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub disabled_mcp: Vec<String>,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub addons: Vec<AddonRef>,
101 pub transport: TransportConfig,
102 pub communication: CommunicationConfig,
103 #[serde(default)]
104 pub capabilities: Vec<String>,
105 pub entitlements: Entitlements,
106 #[serde(default)]
107 pub notifications: NotificationsConfig,
108 pub retry: RetryConfig,
109 pub lifecycle: LifecycleConfig,
110 #[serde(default)]
113 pub identity: IdentityConfig,
114 #[serde(default)]
115 pub file_transfer: FileTransferConfig,
116 #[serde(default)]
117 pub deployment: DeploymentConfig,
118 #[serde(default)]
121 pub companion: CompanionConfig,
122 #[serde(default)]
124 pub hitl: HitlConfig,
125 #[serde(default)]
127 pub voice: VoiceConfig,
128 #[serde(default)]
130 pub hooks: crate::HooksConfig,
131 #[serde(default)]
134 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
135 pub created_at: String,
136 pub updated_at: String,
137 #[serde(default)]
139 pub appearance: AgentAppearance,
140 #[serde(default)]
142 pub federation: FederationConfig,
143
144 #[serde(default)]
148 pub file_actions: Vec<crate::action::FileAction>,
149
150 #[serde(default)]
152 pub action_pipeline: crate::action::ActionPipelineConfig,
153
154 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub requires_programs: Vec<ProgramDep>,
158
159 #[serde(default, skip_serializing_if = "Vec::is_empty")]
162 pub requires_capabilities: Vec<String>,
163}
164
165fn default_algorithm() -> String {
166 "ed25519".into()
167}
168
169pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
173pub struct IdentityConfig {
174 #[serde(default)]
177 pub pubkey: String,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub owner: Option<String>,
181
182 #[serde(default = "default_algorithm")]
185 pub algorithm: String,
186 #[serde(default)]
188 pub key_version: u32,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub created_at_key: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub previous_pubkey: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub previous_key_version: Option<u32>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub grace_expires_at: Option<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub rotated_at: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub emergency_rekey_at: Option<String>,
208}
209
210impl Default for IdentityConfig {
211 fn default() -> Self {
212 Self {
213 pubkey: String::new(),
214 owner: None,
215 algorithm: default_algorithm(),
216 key_version: 0,
217 created_at_key: None,
218 previous_pubkey: None,
219 previous_key_version: None,
220 grace_expires_at: None,
221 rotated_at: None,
222 emergency_rekey_at: None,
223 }
224 }
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
228pub struct Persona {
229 pub category: PersonaCategory,
230 pub description: String,
231 pub traits: PersonaTraits,
232}
233
234#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
235#[serde(rename_all = "lowercase")]
236pub enum PersonaCategory {
237 Research,
238 Automation,
239 Monitor,
240 Notify,
241 Commerce,
242 Custom,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
246pub struct PersonaTraits {
247 pub tone: String,
248 pub risk: String,
249 pub verbosity: String,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
253pub struct ModelConfig {
254 pub provider: String,
255 pub name: String,
256 #[serde(default)]
257 pub params: BTreeMap<String, serde_yaml_ng::Value>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
261pub struct McpServerEntry {
262 pub name: String,
263 pub command: String,
264 #[serde(default)]
265 pub args: Vec<String>,
266
267 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub binary_sha256: Option<String>,
274
275 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub description_hash: Option<String>,
282
283 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub publisher: Option<McpPublisherInfo>,
288
289 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
294
295 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub timeout_secs: Option<u32>,
300
301 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub network: Option<McpServerNetwork>,
307
308 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub url: Option<String>,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub auth: Option<McpAuth>,
317
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
321 pub requires_programs: Vec<ProgramDep>,
322}
323
324#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
326#[serde(rename_all = "snake_case", tag = "kind")]
327pub enum McpAuth {
328 Bearer { token: crate::secret::SecretRef },
330 Oauth(OauthAuth),
332}
333
334#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
336pub struct OauthAuth {
337 pub token_endpoint: String,
339 pub client_id: String,
341 pub access_token: crate::secret::SecretRef,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub refresh_token: Option<crate::secret::SecretRef>,
346 #[serde(default)]
348 pub expires_at: u64,
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
353#[serde(rename_all = "snake_case")]
354pub enum McpNetMode {
355 #[default]
357 Inherit,
358 Restricted,
360 BroadAudited,
366 Off,
368}
369
370pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
379
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
382pub struct McpServerNetwork {
383 #[serde(default)]
384 pub mode: McpNetMode,
385 #[serde(default)]
386 pub allow_hosts: Vec<String>,
387 #[serde(default)]
390 pub deny_hosts: Vec<String>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub authorization: Option<EgressAuthorization>,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
405pub struct AddonRef {
406 pub id: String,
408 pub source: String,
410 #[serde(default)]
411 pub enabled: bool,
412 #[serde(default, skip_serializing_if = "Vec::is_empty")]
413 pub skills: Vec<String>,
414 #[serde(default, skip_serializing_if = "Vec::is_empty")]
415 pub mcp: Vec<String>,
416 #[serde(default, skip_serializing_if = "Vec::is_empty")]
417 pub commands: Vec<String>,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub content_hash: Option<String>,
422 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub fetch_ref: Option<String>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub fetch_plugin: Option<String>,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
441pub struct McpPublisherInfo {
442 pub name: String,
445
446 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub homepage: Option<String>,
451
452 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub registry_id: Option<String>,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
459pub struct TransportConfig {
460 pub stdio: bool,
461 pub socket: SocketTransportConfig,
462 #[serde(default)]
463 pub tcp: TcpTransportConfig,
464 #[serde(default)]
468 pub webhook: WebhookTransportConfig,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
472pub struct TcpTransportConfig {
473 #[serde(default)]
474 pub enabled: bool,
475 #[serde(default)]
476 pub bind: String,
477 #[serde(default)]
478 pub noise: NoiseConfig,
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
495pub struct WebhookTransportConfig {
496 #[serde(default)]
497 pub enabled: bool,
498 #[serde(default = "default_webhook_bind")]
499 pub bind: String,
500 #[serde(default = "default_webhook_port")]
501 pub port: u16,
502 #[serde(default)]
506 pub hmac_secret_ref: String,
507}
508
509fn default_webhook_bind() -> String {
510 "127.0.0.1".to_string()
511}
512
513fn default_webhook_port() -> u16 {
514 6789
515}
516
517impl Default for WebhookTransportConfig {
518 fn default() -> Self {
519 Self {
520 enabled: false,
521 bind: default_webhook_bind(),
522 port: default_webhook_port(),
523 hmac_secret_ref: String::new(),
524 }
525 }
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
529pub struct NoiseConfig {
530 pub pattern: String,
531}
532
533impl Default for NoiseConfig {
534 fn default() -> Self {
535 Self {
536 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
537 }
538 }
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
542pub struct SocketTransportConfig {
543 pub enabled: bool,
544 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub auth: Option<AuthConfig>,
547}
548
549#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
550pub struct AuthConfig {
551 pub scheme: String,
552 pub token_file: String,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
556pub struct CommunicationConfig {
557 #[serde(default = "default_accepts_all")]
558 pub accepts_from: Vec<String>,
559 #[serde(default)]
560 pub sends_to: Vec<String>,
561}
562fn default_accepts_all() -> Vec<String> {
563 vec!["*".to_string()]
564}
565
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
567pub struct Entitlements {
568 pub network: NetworkEntitlement,
569 pub filesystem: FilesystemEntitlement,
570 pub processes: ProcessesEntitlement,
571 #[serde(default)]
572 pub syscalls: SyscallsEntitlement,
573 #[serde(default)]
574 pub limits: LimitsEntitlement,
575 #[serde(default)]
578 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
579 #[serde(default, skip_serializing_if = "Vec::is_empty")]
581 pub tools: Vec<ToolRule>,
582 #[serde(default = "default_true")]
587 pub fail_closed_on_sandbox_error: bool,
588}
589
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
591pub struct NetworkEntitlement {
592 pub inbound: InboundNetwork,
593 pub outbound: OutboundNetwork,
594}
595
596#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
597pub struct InboundNetwork {
598 #[serde(default)]
599 pub ports: Vec<u16>,
600}
601
602#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
603pub struct OutboundNetwork {
604 pub mode: NetworkOutboundMode,
605 #[serde(default)]
606 pub allow_hosts: Vec<String>,
607 #[serde(default = "default_protocols")]
608 pub protocols: Vec<String>,
609 #[serde(default)]
610 pub resolve_dns: ResolveDnsConfig,
611}
612fn default_protocols() -> Vec<String> {
613 vec!["tcp".to_string()]
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
620pub struct EgressAuthorization {
621 pub authorized_by: String,
622 pub authorized_at_ms: u64,
623}
624
625#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
626#[serde(rename_all = "lowercase")]
627pub enum NetworkOutboundMode {
628 Unrestricted,
629 Restricted,
630 ProxyOnly,
634 Off,
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
638pub struct ResolveDnsConfig {
639 #[serde(default = "default_dns_mode")]
640 pub mode: String,
641 #[serde(default)]
642 pub servers: Vec<String>,
643}
644impl Default for ResolveDnsConfig {
645 fn default() -> Self {
646 Self {
647 mode: default_dns_mode(),
648 servers: vec![],
649 }
650 }
651}
652fn default_dns_mode() -> String {
653 "system".to_string()
654}
655
656#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
657pub struct FilesystemEntitlement {
658 #[serde(default)]
659 pub read: Vec<String>,
660 #[serde(default)]
661 pub write: Vec<String>,
662 #[serde(default)]
663 pub deny: Vec<String>,
664}
665
666#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
667pub struct ProcessesEntitlement {
668 pub spawn: SpawnEntitlement,
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
672pub struct SpawnEntitlement {
673 pub mode: SpawnMode,
674 #[serde(default)]
675 pub allowed: Vec<String>,
676}
677
678#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
679#[serde(rename_all = "lowercase")]
680pub enum SpawnMode {
681 Allowlist,
682 Any,
683 None,
684 Strict,
690}
691
692#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
693pub struct SyscallsEntitlement {
694 #[serde(default = "default_syscalls_mode")]
695 pub mode: String,
696 #[serde(default)]
697 pub extra_deny: Vec<String>,
698}
699fn default_syscalls_mode() -> String {
700 "default".to_string()
701}
702
703#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
704pub struct LimitsEntitlement {
705 #[serde(default)]
706 pub cpu_seconds: Option<u64>,
707 #[serde(default = "default_memory_mb")]
708 pub memory_mb: u64,
709 #[serde(default = "default_fds")]
710 pub file_descriptors: u32,
711 #[serde(default = "default_procs")]
712 pub processes: u32,
713}
714fn default_memory_mb() -> u64 {
715 512
716}
717fn default_fds() -> u32 {
718 1024
719}
720fn default_procs() -> u32 {
721 32
722}
723
724#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
725#[serde(rename_all = "lowercase")]
726pub enum ToolPolicy {
727 Allow,
728 #[default]
729 Ask,
730 Deny,
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
734pub struct ToolRule {
735 pub pattern: String,
736 pub policy: ToolPolicy,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub risk: Option<crate::hitl::RiskTier>,
741}
742
743pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
747 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
748}
749
750pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
755 for rule in rules {
756 if rule.pattern == tool_name {
757 return Some(rule.policy);
758 }
759 }
760 let mut best: Option<(&ToolRule, usize)> = None;
761 for rule in rules {
762 if let Some(prefix) = rule.pattern.strip_suffix('*')
763 && tool_name.starts_with(prefix)
764 {
765 let len = prefix.len();
766 if best.is_none_or(|(_, best_len)| len > best_len) {
767 best = Some((rule, len));
768 }
769 }
770 }
771 best.map(|(rule, _)| rule.policy)
772}
773
774#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
775pub struct NotificationsConfig {
776 #[serde(default)]
777 pub on_task_complete: Vec<NotificationTarget>,
778 #[serde(default)]
779 pub on_error: Vec<NotificationTarget>,
780 #[serde(default)]
781 pub on_shutdown: Vec<NotificationTarget>,
782}
783
784#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
785#[serde(tag = "target", rename_all = "lowercase")]
786pub enum NotificationTarget {
787 Agent {
788 name: String,
789 },
790 Commander,
791 Email {
792 address: String,
793 #[serde(default)]
794 smtp_config_file: Option<String>,
795 },
796 Slack {
797 #[serde(default)]
798 channel: Option<String>,
799 #[serde(default)]
800 webhook_url_env: Option<String>,
801 },
802 Webpush {
803 url: String,
804 },
805 Webhook {
806 url: String,
807 #[serde(default = "default_post")]
808 method: String,
809 #[serde(default)]
810 auth: Option<String>,
811 },
812}
813fn default_post() -> String {
814 "POST".to_string()
815}
816
817#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
818pub struct RetryConfig {
819 pub llm: RetryPolicy,
820 pub tool: RetryPolicy,
821}
822
823#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
824pub struct RetryPolicy {
825 pub max_retries: u32,
826 pub backoff: BackoffStrategy,
827 pub initial_delay_ms: u64,
828 #[serde(default)]
829 pub max_delay_ms: Option<u64>,
830 #[serde(default)]
831 pub retry_on: Vec<String>,
832}
833
834#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
835#[serde(rename_all = "lowercase")]
836pub enum BackoffStrategy {
837 Linear,
838 Exponential,
839 Fixed,
840}
841
842#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
843pub struct LifecycleConfig {
844 pub restart: RestartPolicy,
845 #[serde(default = "default_max_restarts")]
846 pub max_restarts: u32,
847 #[serde(default = "default_window")]
848 pub restart_window_secs: u64,
849 #[serde(default = "default_stop_timeout")]
850 pub stop_timeout_secs: u64,
851 #[serde(default = "default_mcp_required")]
852 pub mcp_required: bool,
853 #[serde(default)]
854 pub execution: ExecutionMode,
855 #[serde(default)]
856 pub schedule: Vec<ScheduleEntry>,
857 #[serde(default)]
858 pub idle_triggers: Vec<IdleTrigger>,
859}
860fn default_max_restarts() -> u32 {
861 3
862}
863fn default_window() -> u64 {
864 600
865}
866fn default_stop_timeout() -> u64 {
867 15
868}
869fn default_mcp_required() -> bool {
870 true
871}
872
873#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
874#[serde(rename_all = "snake_case")]
875pub enum RestartPolicy {
876 Never,
877 OnFailure,
878 Always,
879}
880
881#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
882#[serde(rename_all = "snake_case")]
883pub enum ExecutionMode {
884 #[default]
885 Daemon,
886 OnDemand,
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
890pub struct ScheduleEntry {
891 pub cron: String,
892 pub message: String,
893 #[serde(default, skip_serializing_if = "Option::is_none")]
894 pub sends_to: Option<String>,
895}
896
897#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
898pub struct IdleTrigger {
899 pub after_secs: u64,
901 pub message: String,
903 #[serde(default, skip_serializing_if = "Option::is_none")]
905 pub sends_to: Option<String>,
906 #[serde(default = "default_idle_cooldown")]
909 pub cooldown_secs: u64,
910 #[serde(default = "default_true")]
913 pub respect_quiet_hours: bool,
914}
915
916fn default_idle_cooldown() -> u64 {
917 600
918}
919pub fn name_enabled(denylist: &[String], name: &str) -> bool {
921 !denylist.iter().any(|n| n == name)
922}
923
924pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
927 if enabled {
928 list.retain(|n| n != name);
929 } else if !list.iter().any(|n| n == name) {
930 list.push(name.to_string());
931 }
932}
933
934fn default_true() -> bool {
935 true
936}
937
938#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
939pub struct FileTransferConfig {
940 #[serde(default = "default_accept_max")]
941 pub accept_incoming_file_max_bytes: u64,
942 #[serde(default = "default_accept_total")]
943 pub accept_incoming_total_per_hour: u64,
944 #[serde(default = "default_approval_threshold")]
945 pub require_approval_above_bytes: u64,
946 #[serde(default = "default_reject_paths")]
947 pub reject_paths: Vec<String>,
948 #[serde(default = "default_allowed_mime")]
949 pub allowed_mime_types: Vec<String>,
950}
951
952impl Default for FileTransferConfig {
953 fn default() -> Self {
954 Self {
955 accept_incoming_file_max_bytes: default_accept_max(),
956 accept_incoming_total_per_hour: default_accept_total(),
957 require_approval_above_bytes: default_approval_threshold(),
958 reject_paths: default_reject_paths(),
959 allowed_mime_types: default_allowed_mime(),
960 }
961 }
962}
963
964fn default_accept_max() -> u64 {
965 10_485_760
966}
967fn default_accept_total() -> u64 {
968 104_857_600
969}
970fn default_approval_threshold() -> u64 {
971 10_485_760
972}
973fn default_reject_paths() -> Vec<String> {
974 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
975}
976fn default_allowed_mime() -> Vec<String> {
977 vec!["*".into()]
978}
979
980#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
981#[serde(rename_all = "snake_case")]
982pub enum DeploymentType {
983 #[default]
984 Laptop,
985 Vm,
986 Docker,
987 K8s,
988 Lambda,
989}
990
991#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
992pub struct DeploymentConfig {
993 #[serde(rename = "type", default)]
994 pub deployment_type: DeploymentType,
995 #[serde(default, skip_serializing_if = "Option::is_none")]
996 pub region: Option<String>,
997 #[serde(default = "default_env")]
998 pub environment: Option<String>,
999}
1000
1001impl Default for DeploymentConfig {
1002 fn default() -> Self {
1003 Self {
1004 deployment_type: DeploymentType::default(),
1005 region: None,
1006 environment: default_env(),
1007 }
1008 }
1009}
1010
1011fn default_env() -> Option<String> {
1012 Some("dev".into())
1013}
1014
1015#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1016pub struct LockFile {
1017 pub schema: u32,
1018 pub uuid: String,
1019 pub name: String,
1020 pub pid: u32,
1021 pub ppid: u32,
1022 pub started_at: String,
1023 pub binary_version: String,
1024 pub transports: LockTransports,
1025 pub card_digest: String,
1026 pub capabilities: Vec<String>,
1027 #[serde(default)]
1030 pub build_sha: String,
1031 #[serde(default)]
1034 pub proto_version: u32,
1035}
1036
1037#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1038pub struct LockTransports {
1039 pub stdio: bool,
1040 #[serde(default)]
1041 pub unix_socket: Option<String>,
1042 #[serde(default)]
1043 pub tcp: Option<String>,
1044 #[serde(default)]
1049 pub webhook: Option<String>,
1050}
1051
1052#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1059#[serde(rename_all = "snake_case")]
1060pub enum VoiceId {
1061 #[default]
1063 AfHeart,
1064 AfBella,
1065 AfNicole,
1066 AmAdam,
1067 AmMichael,
1068}
1069
1070impl VoiceId {
1071 pub fn style_index(&self) -> usize {
1073 match self {
1074 VoiceId::AfHeart => 0,
1075 VoiceId::AfBella => 1,
1076 VoiceId::AfNicole => 2,
1077 VoiceId::AmAdam => 3,
1078 VoiceId::AmMichael => 4,
1079 }
1080 }
1081
1082 pub fn as_str(&self) -> &'static str {
1084 match self {
1085 VoiceId::AfHeart => "af_heart",
1086 VoiceId::AfBella => "af_bella",
1087 VoiceId::AfNicole => "af_nicole",
1088 VoiceId::AmAdam => "am_adam",
1089 VoiceId::AmMichael => "am_michael",
1090 }
1091 }
1092}
1093
1094impl std::str::FromStr for VoiceId {
1095 type Err = anyhow::Error;
1096
1097 fn from_str(s: &str) -> anyhow::Result<Self> {
1098 match s {
1099 "af_heart" => Ok(VoiceId::AfHeart),
1100 "af_bella" => Ok(VoiceId::AfBella),
1101 "af_nicole" => Ok(VoiceId::AfNicole),
1102 "am_adam" => Ok(VoiceId::AmAdam),
1103 "am_michael" => Ok(VoiceId::AmMichael),
1104 other => anyhow::bail!(
1105 "unknown voice ID '{other}' \
1106 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1107 ),
1108 }
1109 }
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1115pub struct VoiceConfig {
1116 #[serde(default)]
1118 pub enabled: bool,
1119 #[serde(default)]
1121 pub voice_id: VoiceId,
1122 #[serde(default, skip_serializing_if = "Option::is_none")]
1125 pub input_device: Option<String>,
1126}
1127
1128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1133pub struct HitlConfig {
1134 #[serde(default = "default_hitl_timeout_secs")]
1135 pub timeout_secs: u32,
1136 #[serde(default)]
1140 pub max_iterations: Option<u32>,
1141 #[serde(default)]
1146 pub max_tokens: Option<u64>,
1147}
1148
1149fn default_hitl_timeout_secs() -> u32 {
1150 300
1151}
1152
1153impl Default for HitlConfig {
1154 fn default() -> Self {
1155 Self {
1156 timeout_secs: default_hitl_timeout_secs(),
1157 max_iterations: None,
1158 max_tokens: None,
1159 }
1160 }
1161}
1162
1163#[cfg(test)]
1164mod hitl_tests {
1165 use super::*;
1166
1167 #[test]
1168 fn hitl_config_default_max_iterations_is_none() {
1169 let cfg = HitlConfig::default();
1170 assert!(cfg.max_iterations.is_none());
1171 }
1172
1173 #[test]
1174 fn hitl_config_max_iterations_explicit() {
1175 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1176 assert_eq!(cfg.max_iterations, Some(5));
1177 }
1178
1179 #[test]
1180 fn hitl_config_default_max_tokens_is_none() {
1181 let cfg = HitlConfig::default();
1182 assert!(cfg.max_tokens.is_none());
1183 }
1184
1185 #[test]
1186 fn hitl_config_max_tokens_explicit() {
1187 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1188 assert_eq!(cfg.max_tokens, Some(250_000));
1189 }
1190}
1191
1192#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1198pub struct CompanionConfig {
1199 #[serde(default)]
1200 pub enabled: bool,
1201 #[serde(default = "default_locale")]
1202 pub locale: String,
1203 #[serde(default)]
1204 pub relationship: Relationship,
1205 #[serde(default)]
1206 pub voice_overrides: VoiceOverrides,
1207 #[serde(default)]
1208 pub onboarding: OnboardingState,
1209 #[serde(default)]
1210 pub rhythm: RhythmConfig,
1211 #[serde(default)]
1212 pub proactive: ProactiveConfig,
1213}
1214
1215pub fn default_locale() -> String {
1218 std::env::var("LANG")
1219 .ok()
1220 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
1221 .unwrap_or_else(|| "en-US".into())
1222}
1223
1224#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1225pub struct VoiceOverrides {
1226 #[serde(default, skip_serializing_if = "Option::is_none")]
1227 pub name_for_user: Option<String>,
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1229 pub formality: Option<Formality>,
1230 #[serde(default, skip_serializing_if = "Option::is_none")]
1231 pub extra_instructions: Option<String>,
1232}
1233
1234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1235pub struct FirstMemory {
1236 pub text: String,
1237 pub established_at: chrono::DateTime<chrono::Utc>,
1238}
1239
1240#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1241pub struct OnboardingState {
1242 #[serde(default, skip_serializing_if = "Option::is_none")]
1243 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1244 #[serde(default)]
1245 pub version: u32,
1246 #[serde(default, skip_serializing_if = "Option::is_none")]
1247 pub agent_display_name: Option<String>,
1248 #[serde(default, skip_serializing_if = "Option::is_none")]
1249 pub first_memory: Option<FirstMemory>,
1250}
1251
1252#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1255pub struct RhythmConfig {
1256 #[serde(default)]
1257 pub enabled: bool,
1258}
1259
1260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1261pub struct ProactiveConfig {
1262 #[serde(default)]
1263 pub enabled: bool,
1264 #[serde(default, skip_serializing_if = "Option::is_none")]
1266 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1267 #[serde(default, skip_serializing_if = "Option::is_none")]
1268 pub quiet_hours: Option<QuietHours>,
1269 #[serde(default, skip_serializing_if = "Option::is_none")]
1270 pub active_hours: Option<ActiveHours>,
1271 #[serde(default = "default_daily_cap")]
1272 pub daily_cap: u8,
1273 #[serde(default = "default_channels")]
1274 pub channels: Vec<String>,
1275 #[serde(default, skip_serializing_if = "Option::is_none")]
1276 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1277}
1278
1279impl Default for ProactiveConfig {
1280 fn default() -> Self {
1281 Self {
1282 enabled: false,
1283 learning_until: None,
1284 quiet_hours: None,
1285 active_hours: None,
1286 daily_cap: default_daily_cap(),
1287 channels: default_channels(),
1288 paused_until: None,
1289 }
1290 }
1291}
1292
1293fn default_daily_cap() -> u8 {
1294 3
1295}
1296fn default_channels() -> Vec<String> {
1297 vec!["stdout".into()]
1298}
1299
1300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1301pub struct QuietHours {
1302 pub start: String,
1303 pub end: String,
1304}
1305
1306#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1307pub struct ActiveHours {
1308 pub start: String,
1309 pub end: String,
1310}
1311
1312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1317pub struct AgentAppearance {
1318 #[serde(default = "default_style_preset")]
1320 pub style_preset: String,
1321 #[serde(default)]
1322 pub behavior_preset: BehaviorPreset,
1323 #[serde(default, skip_serializing_if = "Option::is_none")]
1325 pub source_image_path: Option<std::path::PathBuf>,
1326 #[serde(default = "default_expressions_dir")]
1328 pub expressions_dir: std::path::PathBuf,
1329 #[serde(default, skip_serializing_if = "Option::is_none")]
1330 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1331 #[serde(default)]
1332 pub render_status: RenderStatus,
1333}
1334
1335fn default_style_preset() -> String {
1336 "default-blob".into()
1337}
1338
1339fn default_expressions_dir() -> std::path::PathBuf {
1340 std::path::PathBuf::from("expressions")
1341}
1342
1343impl Default for AgentAppearance {
1344 fn default() -> Self {
1345 Self {
1346 style_preset: default_style_preset(),
1347 behavior_preset: BehaviorPreset::Normal,
1348 source_image_path: None,
1349 expressions_dir: default_expressions_dir(),
1350 last_rendered_at: None,
1351 render_status: RenderStatus::Pending,
1352 }
1353 }
1354}
1355
1356#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1357#[serde(rename_all = "snake_case")]
1358pub enum BehaviorPreset {
1359 Quiet,
1360 #[default]
1361 Normal,
1362 Lively,
1363}
1364
1365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1366#[serde(tag = "status", rename_all = "snake_case")]
1367pub enum RenderStatus {
1368 #[default]
1369 Pending,
1370 Rendering {
1371 done: u8,
1372 total: u8,
1373 },
1374 Ready,
1375 Failed {
1376 reason: String,
1377 },
1378}
1379
1380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1386#[serde(rename_all = "kebab-case")]
1387pub enum SnapshotPolicy {
1388 #[default]
1389 PullOnStart,
1390 PullPeriodic,
1391 Manual,
1392}
1393
1394#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1396pub struct PatternFilter {
1397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1398 pub applies_in: Vec<String>,
1399 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1400 pub tier: Vec<String>,
1401 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1402 pub maturity: Vec<String>,
1403 #[serde(default)]
1404 pub importance_min: f64,
1405 #[serde(default = "default_max_snapshot_count")]
1406 pub max_count: usize,
1407 #[serde(default)]
1408 pub snapshot_policy: SnapshotPolicy,
1409}
1410
1411fn default_max_snapshot_count() -> usize {
1412 200
1413}
1414
1415impl Default for PatternFilter {
1416 fn default() -> Self {
1417 Self {
1418 applies_in: vec![],
1419 tier: vec![],
1420 maturity: vec![],
1421 importance_min: 0.0,
1422 max_count: 200,
1423 snapshot_policy: SnapshotPolicy::default(),
1424 }
1425 }
1426}
1427
1428#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1430pub struct SnapshotRef {
1431 pub knowledge_commit: String,
1432 pub taken_at: String,
1433 pub filter: PatternFilter,
1434}
1435
1436#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1438pub struct FederationConfig {
1439 #[serde(default)]
1440 pub filter: PatternFilter,
1441 #[serde(default, skip_serializing_if = "Option::is_none")]
1442 pub snapshot_ref: Option<SnapshotRef>,
1443 #[serde(default)]
1444 pub evidence_flush_interval_minutes: u32,
1445}
1446
1447impl AgentProfile {
1448 #[doc(hidden)]
1454 pub fn default_for_tests() -> Self {
1455 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1456 .expect("minimal profile fixture")
1457 }
1458
1459 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1467 let path = mur_home.join("agents").join(name).join("profile.yaml");
1468 let yaml = std::fs::read_to_string(&path)
1469 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1470 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1471 }
1472
1473 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1475 self.addons.iter().find(|g| {
1476 g.skills.iter().any(|n| n == name)
1477 || g.mcp.iter().any(|n| n == name)
1478 || g.commands.iter().any(|n| n == name)
1479 })
1480 }
1481
1482 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1485 name_enabled(&self.disabled_skills, skill_name)
1486 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1487 }
1488
1489 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1491 name_enabled(&self.disabled_mcp, server_id)
1492 && self.group_of(server_id).is_none_or(|g| g.enabled)
1493 }
1494
1495 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1497 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1498 }
1499
1500 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1502 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1503 }
1504
1505 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1508 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1509 Some(g) => {
1510 g.enabled = enabled;
1511 true
1512 }
1513 None => false,
1514 }
1515 }
1516
1517 pub fn disable_all_addons(&mut self) {
1523 for g in &mut self.addons {
1524 g.enabled = false;
1525 }
1526 }
1527
1528 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1530 self.mcp_servers
1531 .iter()
1532 .filter(|m| self.mcp_enabled(&m.name))
1533 .cloned()
1534 .collect()
1535 }
1536}
1537
1538#[cfg(test)]
1539mod tests {
1540 use super::*;
1541
1542 #[test]
1543 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1544 let net = McpServerNetwork {
1545 mode: McpNetMode::BroadAudited,
1546 allow_hosts: vec![],
1547 deny_hosts: vec!["evil.example".into()],
1548 authorization: Some(EgressAuthorization {
1549 authorized_by: "david".into(),
1550 authorized_at_ms: 1_750_000_000_000,
1551 }),
1552 };
1553 let y = serde_yaml::to_string(&net).unwrap();
1554 assert!(y.contains("broad_audited"));
1555 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1556 assert_eq!(back, net);
1557 let legacy: McpServerNetwork =
1559 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1560 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1561 assert!(legacy.authorization.is_none());
1562 }
1563
1564 #[test]
1565 fn mcp_entry_network_is_optional_and_round_trips() {
1566 let bare = "name: x\ncommand: npx\n";
1568 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1569 assert!(e.network.is_none());
1570
1571 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1573 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1574 let net = e2.network.expect("network present");
1575 assert_eq!(net.mode, McpNetMode::Restricted);
1576 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1577
1578 let out = serde_yaml_ng::to_string(&e).unwrap();
1580 assert!(!out.contains("network"));
1581 }
1582
1583 #[test]
1584 fn profile_round_trip_yaml() {
1585 let yaml = r#"
1586schema: 1
1587id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1588name: agent_a
1589display_name: "Price Hunter"
1590version: "0.1.0"
1591persona:
1592 category: research
1593 description: "Finds prices"
1594 traits: { tone: concise, risk: cautious, verbosity: low }
1595sys_prompt_file: "sys_prompt.md"
1596model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1597mcp_servers: []
1598skills: []
1599transport:
1600 stdio: true
1601 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1602communication: { accepts_from: ["*"], sends_to: [] }
1603capabilities: ["a2a.message.send", "a2a.tasks"]
1604entitlements:
1605 network:
1606 inbound: { ports: [] }
1607 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1608 filesystem: { read: [], write: [], deny: [] }
1609 processes: { spawn: { mode: allowlist, allowed: [] } }
1610 syscalls: { mode: default }
1611 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1612notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1613retry:
1614 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1615 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1616lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1617created_at: "2026-04-22T10:00:00+08:00"
1618updated_at: "2026-04-22T10:00:00+08:00"
1619"#;
1620 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1621 assert_eq!(profile.name, "agent_a");
1622 assert_eq!(profile.persona.category, PersonaCategory::Research);
1623 assert_eq!(
1624 profile.entitlements.network.outbound.mode,
1625 NetworkOutboundMode::Restricted
1626 );
1627 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1628 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1629 assert_eq!(profile.id, round_tripped.id);
1630 }
1631
1632 #[test]
1633 fn requires_capabilities_defaults_empty_and_round_trips() {
1634 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1635 let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
1636 assert!(p.requires_capabilities.is_empty());
1637 let with = format!("{base}\nrequires_capabilities:\n - media\n");
1638 let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
1639 assert_eq!(p2.requires_capabilities, vec!["media"]);
1640 }
1641}
1642
1643#[cfg(test)]
1644mod model_ref_tests {
1645 use super::*;
1646
1647 #[test]
1648 fn legacy_profile_without_model_ref_still_parses() {
1649 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1650 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1651 assert!(
1652 p.model_ref.is_none(),
1653 "legacy profile must not have model_ref"
1654 );
1655 }
1656
1657 #[test]
1658 fn round_trip_with_model_ref_preserves_field() {
1659 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1660 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1661 p.model_ref = Some("anthropic_opus_4_7".into());
1662 let s = serde_yaml_ng::to_string(&p).unwrap();
1663 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1664 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1665 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1666 }
1667
1668 #[test]
1669 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
1670 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1672 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1673 assert!(
1674 p.fallback_chain.is_empty(),
1675 "legacy profile must have empty fallback_chain"
1676 );
1677 assert!(
1678 p.routing.is_none(),
1679 "legacy profile must have no routing override"
1680 );
1681
1682 let mut p = p.clone();
1684 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
1685 p.routing = Some(crate::config::RoutingConfig {
1686 enabled: true,
1687 ..Default::default()
1688 });
1689 let s = serde_yaml_ng::to_string(&p).unwrap();
1690 assert!(
1691 s.contains("fallback_chain:"),
1692 "yaml must contain fallback_chain"
1693 );
1694 assert!(s.contains("routing:"), "yaml must contain routing");
1695 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1696 assert_eq!(
1697 p2.fallback_chain,
1698 vec!["claude_opus", "claude_sonnet"],
1699 "fallback_chain must round-trip"
1700 );
1701 assert!(
1702 p2.routing.as_ref().unwrap().enabled,
1703 "routing.enabled must round-trip"
1704 );
1705 }
1706}
1707
1708#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1716#[serde(rename_all = "snake_case")]
1717pub enum ProactiveTier {
1718 Off,
1719 WarmOnly,
1720 WarmAndBehavior,
1721 All,
1722}
1723
1724impl ProactiveTier {
1725 pub fn from_config(c: &CompanionConfig) -> Self {
1726 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1727 (false, _, _) => Self::Off,
1728 (true, false, false) => Self::WarmOnly,
1729 (true, true, false) => Self::WarmAndBehavior,
1730 (true, _, true) => Self::All,
1731 }
1732 }
1733
1734 pub fn apply(&self, c: &mut CompanionConfig) {
1735 match self {
1736 Self::Off => {
1737 c.enabled = false;
1738 c.rhythm.enabled = false;
1739 c.proactive.enabled = false;
1740 }
1741 Self::WarmOnly => {
1742 c.enabled = true;
1743 c.rhythm.enabled = false;
1744 c.proactive.enabled = false;
1745 }
1746 Self::WarmAndBehavior => {
1747 c.enabled = true;
1748 c.rhythm.enabled = true;
1749 c.proactive.enabled = false;
1750 }
1751 Self::All => {
1752 c.enabled = true;
1753 c.rhythm.enabled = true;
1754 c.proactive.enabled = true;
1755 }
1756 }
1757 }
1758}
1759
1760#[cfg(test)]
1761mod mcp_pin_tests {
1762 use super::*;
1763
1764 #[test]
1768 fn pre_m9_entry_roundtrips_without_pin_fields() {
1769 let yaml = r#"
1770name: weather
1771command: /opt/mcp/weather
1772args: ["--port", "0"]
1773"#;
1774 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1775 assert_eq!(entry.name, "weather");
1776 assert_eq!(entry.binary_sha256, None);
1777 assert_eq!(entry.description_hash, None);
1778 assert_eq!(entry.publisher, None);
1779 assert_eq!(entry.installed_at, None);
1780
1781 let out = serde_yaml_ng::to_string(&entry).unwrap();
1784 assert!(!out.contains("binary_sha256"), "got {out}");
1785 assert!(!out.contains("description_hash"), "got {out}");
1786 assert!(!out.contains("publisher"), "got {out}");
1787 assert!(!out.contains("installed_at"), "got {out}");
1788 }
1789
1790 #[test]
1792 fn full_m9_entry_roundtrips_all_fields() {
1793 let yaml = r#"
1794name: weather
1795command: /opt/mcp/weather
1796args: []
1797binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1798description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1799publisher:
1800 name: "@anthropic-mcp/weather"
1801 homepage: "https://github.com/anthropic-mcp/weather"
1802 registry_id: "@anthropic-mcp/weather@1.2.3"
1803installed_at: "2026-05-06T08:00:00Z"
1804"#;
1805 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1806 assert!(
1807 entry
1808 .binary_sha256
1809 .as_deref()
1810 .unwrap()
1811 .starts_with("3f4abca8")
1812 );
1813 assert!(
1814 entry
1815 .description_hash
1816 .as_deref()
1817 .unwrap()
1818 .starts_with("9a01b2c3")
1819 );
1820 let pub_info = entry.publisher.clone().unwrap();
1821 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1822 assert_eq!(
1823 pub_info.homepage.as_deref(),
1824 Some("https://github.com/anthropic-mcp/weather"),
1825 );
1826 assert_eq!(
1827 pub_info.registry_id.as_deref(),
1828 Some("@anthropic-mcp/weather@1.2.3"),
1829 );
1830 let installed = entry.installed_at.unwrap();
1831 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1832 }
1833
1834 #[test]
1838 fn partial_pin_only_binary_sha_roundtrips() {
1839 let yaml = r#"
1840name: weather
1841command: /opt/mcp/weather
1842args: []
1843binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1844"#;
1845 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1846 assert_eq!(
1847 entry.binary_sha256.as_deref(),
1848 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1849 );
1850 assert_eq!(entry.description_hash, None);
1851 assert_eq!(entry.publisher, None);
1852 }
1853
1854 #[test]
1857 fn publisher_minimal_just_name() {
1858 let yaml = r#"
1859name: weather
1860command: /opt/mcp/weather
1861args: []
1862publisher:
1863 name: "alice"
1864"#;
1865 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1866 let p = entry.publisher.as_ref().unwrap();
1867 assert_eq!(p.name, "alice");
1868 assert_eq!(p.homepage, None);
1869 assert_eq!(p.registry_id, None);
1870
1871 let out = serde_yaml_ng::to_string(&entry).unwrap();
1873 assert!(!out.contains("homepage:"), "got {out}");
1874 assert!(!out.contains("registry_id:"), "got {out}");
1875 }
1876}
1877
1878#[cfg(test)]
1879mod voice_tests {
1880 use super::*;
1881 use std::str::FromStr;
1882
1883 #[test]
1884 fn voice_config_round_trips() {
1885 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1887 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1888
1889 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1890 assert!(profile.voice.enabled);
1891 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1892
1893 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1895 assert!(!legacy.voice.enabled);
1896 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1897 }
1898
1899 #[test]
1900 fn voice_id_from_str_roundtrips() {
1901 let cases = [
1902 ("af_heart", VoiceId::AfHeart),
1903 ("af_bella", VoiceId::AfBella),
1904 ("af_nicole", VoiceId::AfNicole),
1905 ("am_adam", VoiceId::AmAdam),
1906 ("am_michael", VoiceId::AmMichael),
1907 ];
1908 for (s, expected) in cases {
1909 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1910 assert_eq!(expected.as_str(), s);
1911 }
1912 }
1913
1914 #[test]
1915 fn voice_id_from_str_rejects_unknown() {
1916 assert!(VoiceId::from_str("bogus").is_err());
1917 }
1918}
1919
1920#[cfg(test)]
1921mod idle_trigger_tests {
1922 use super::*;
1923
1924 #[test]
1925 fn idle_trigger_yaml_round_trip() {
1926 let yaml = r#"
1927restart: on_failure
1928idle_triggers:
1929 - after_secs: 3600
1930 message: "still there?"
1931 sends_to: other_agent
1932 cooldown_secs: 1800
1933 respect_quiet_hours: true
1934"#;
1935 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1936 assert_eq!(cfg.idle_triggers.len(), 1);
1937 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1938 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1939 assert_eq!(
1940 cfg.idle_triggers[0].sends_to.as_deref(),
1941 Some("other_agent")
1942 );
1943 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1944 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1945 }
1946
1947 #[test]
1948 fn idle_trigger_defaults_when_omitted() {
1949 let yaml = "restart: on_failure\n";
1950 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1951 assert!(cfg.idle_triggers.is_empty());
1952 }
1953}
1954
1955#[cfg(test)]
1956mod appearance_tests {
1957 use super::*;
1958
1959 #[test]
1960 fn appearance_default_style_preset_is_default_blob() {
1961 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1962 }
1963
1964 #[test]
1965 fn appearance_default_behavior_is_normal() {
1966 assert_eq!(
1967 AgentAppearance::default().behavior_preset,
1968 BehaviorPreset::Normal
1969 );
1970 }
1971
1972 #[test]
1973 fn appearance_default_render_status_is_pending() {
1974 assert_eq!(
1975 AgentAppearance::default().render_status,
1976 RenderStatus::Pending
1977 );
1978 }
1979
1980 #[test]
1981 fn render_status_serde_round_trip() {
1982 let cases = [
1983 RenderStatus::Pending,
1984 RenderStatus::Rendering { done: 3, total: 12 },
1985 RenderStatus::Ready,
1986 RenderStatus::Failed {
1987 reason: "out of quota".into(),
1988 },
1989 ];
1990 for status in cases {
1991 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1992 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1993 assert_eq!(status, back);
1994 }
1995 }
1996
1997 #[test]
1998 fn agent_profile_with_appearance_round_trips() {
1999 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2000 let yaml = format!(
2001 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
2002 );
2003 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2004 assert_eq!(profile.appearance.style_preset, "chiikawa");
2005 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2006
2007 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2008 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2009 assert_eq!(profile.appearance, back.appearance);
2010 }
2011
2012 #[test]
2013 fn legacy_profile_without_appearance_uses_default() {
2014 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2015 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2016 assert_eq!(profile.appearance.style_preset, "default-blob");
2017 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2018 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2019 }
2020
2021 #[test]
2022 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2023 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2024 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2025 assert!(p.file_actions.is_empty());
2026 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2027 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2028 }
2029}
2030
2031#[cfg(test)]
2032mod federation_tests {
2033 use super::*;
2034
2035 #[test]
2036 fn test_pattern_filter_default() {
2037 let f = PatternFilter::default();
2038 assert_eq!(f.max_count, 200);
2039 assert_eq!(f.importance_min, 0.0);
2040 assert!(f.tier.is_empty());
2041 }
2042
2043 #[test]
2044 fn test_federation_config_roundtrip() {
2045 let cfg = FederationConfig {
2046 filter: PatternFilter {
2047 tier: vec!["core".into()],
2048 max_count: 50,
2049 ..Default::default()
2050 },
2051 snapshot_ref: Some(SnapshotRef {
2052 knowledge_commit: "abc123def456".into(),
2053 taken_at: "2026-05-19T00:00:00Z".into(),
2054 filter: PatternFilter::default(),
2055 }),
2056 evidence_flush_interval_minutes: 15,
2057 };
2058 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2059 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2060 assert_eq!(cfg, back);
2061 }
2062
2063 #[test]
2064 fn test_agent_profile_federation_defaults() {
2065 let cfg = FederationConfig::default();
2069 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2070 assert!(cfg.snapshot_ref.is_none());
2071 }
2072}
2073
2074#[cfg(test)]
2075mod skill_card_tests {
2076 use super::*;
2077
2078 #[test]
2079 fn installed_skills_default_to_empty_when_absent() {
2080 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2081 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2082 assert!(p.installed_skills.is_empty());
2083 }
2084
2085 #[test]
2086 fn installed_skills_roundtrip_preserves_entries() {
2087 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2088 let yaml = format!(
2089 "{base}installed_skills:\n - name: s1\n version: 1.0.0\n publisher: human:d\n description: desc\n category: workflow\n tags: [web]\n triggers:\n - type: command\n pattern: /find\n abstract: does things\n transfer_chain:\n - agent://alice\n"
2090 );
2091 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2092 assert_eq!(p.installed_skills.len(), 1);
2093 assert_eq!(p.installed_skills[0].name, "s1");
2094 assert_eq!(p.installed_skills[0].abstract_text, "does things");
2095 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2096
2097 let out = serde_yaml_ng::to_string(&p).unwrap();
2098 assert!(out.contains("abstract: does things"));
2099 assert!(out.contains("pattern: /find"));
2100
2101 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2102 assert_eq!(p.installed_skills, back.installed_skills);
2103 }
2104
2105 #[test]
2106 fn installed_skills_minimal_entry_serializes_compactly() {
2107 let entry = SkillCardEntry {
2109 name: "minimal".into(),
2110 ..Default::default()
2111 };
2112 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2113 assert!(yaml.contains("name: minimal"));
2114 assert!(
2115 !yaml.contains("version:"),
2116 "empty version must be skipped: {yaml}"
2117 );
2118 assert!(
2119 !yaml.contains("publisher:"),
2120 "empty publisher must be skipped: {yaml}"
2121 );
2122 assert!(
2123 !yaml.contains("abstract:"),
2124 "empty abstract must be skipped: {yaml}"
2125 );
2126 }
2127}
2128
2129#[cfg(test)]
2130mod tool_policy_tests {
2131 use super::*;
2132
2133 fn rules() -> Vec<ToolRule> {
2134 vec![
2135 ToolRule {
2136 pattern: "mcp__github__merge_pr".into(),
2137 policy: ToolPolicy::Ask,
2138 risk: None,
2139 },
2140 ToolRule {
2141 pattern: "mcp__github__*".into(),
2142 policy: ToolPolicy::Allow,
2143 risk: None,
2144 },
2145 ToolRule {
2146 pattern: "mcp__*".into(),
2147 policy: ToolPolicy::Deny,
2148 risk: None,
2149 },
2150 ToolRule {
2151 pattern: "bash".into(),
2152 policy: ToolPolicy::Allow,
2153 risk: None,
2154 },
2155 ]
2156 }
2157
2158 #[test]
2159 fn exact_beats_glob() {
2160 assert_eq!(
2161 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2162 ToolPolicy::Ask
2163 );
2164 }
2165
2166 #[test]
2167 fn longer_glob_wins() {
2168 assert_eq!(
2169 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2170 ToolPolicy::Allow
2171 );
2172 }
2173
2174 #[test]
2175 fn shorter_glob_fallback() {
2176 assert_eq!(
2177 resolve_tool_policy(&rules(), "mcp__slack__send"),
2178 ToolPolicy::Deny
2179 );
2180 }
2181
2182 #[test]
2183 fn exact_bash() {
2184 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2185 }
2186
2187 #[test]
2188 fn unknown_tool_defaults_ask() {
2189 assert_eq!(
2190 resolve_tool_policy(&rules(), "unknown_tool"),
2191 ToolPolicy::Ask
2192 );
2193 }
2194
2195 #[test]
2196 fn empty_rules_defaults_ask() {
2197 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2198 }
2199
2200 fn minimal_entitlements_yaml() -> &'static str {
2201 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2202 }
2203
2204 #[test]
2205 fn entitlements_tools_defaults_empty() {
2206 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2207 assert!(e.tools.is_empty());
2208 }
2209
2210 #[test]
2211 fn entitlements_tools_roundtrip() {
2212 let base = minimal_entitlements_yaml();
2213 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2214 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2215 assert_eq!(e.tools.len(), 1);
2216 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2217 let y = serde_yaml_ng::to_string(&e).unwrap();
2218 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2219 assert_eq!(back.tools.len(), 1);
2220 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2221 }
2222 #[test]
2223 fn denylist_membership_and_mutation() {
2224 let mut list: Vec<String> = vec![];
2225 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2226
2227 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2229 assert_eq!(list, ["a"]);
2230
2231 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2233
2234 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2236 assert!(list.is_empty());
2237
2238 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2240 }
2241
2242 #[test]
2243 fn addon_group_rule_truth_table() {
2244 let mut p = AgentProfile::default_for_tests();
2245 p.addons.push(AddonRef {
2246 id: "grp".into(),
2247 source: "claude-local:grp@1.0.0".into(),
2248 enabled: false,
2249 skills: vec!["g_skill".into()],
2250 mcp: vec!["g_mcp".into()],
2251 commands: vec!["g_cmd".into()],
2252 content_hash: None,
2253 fetch_ref: None,
2254 fetch_plugin: None,
2255 });
2256
2257 assert!(p.skill_enabled("standalone"));
2259 assert!(p.mcp_enabled("standalone_mcp"));
2260
2261 assert!(!p.skill_enabled("g_skill"));
2263 assert!(!p.mcp_enabled("g_mcp"));
2264
2265 assert!(p.set_addon_enabled("grp", true));
2267 assert!(p.skill_enabled("g_skill"));
2268 assert!(p.mcp_enabled("g_mcp"));
2269
2270 p.set_skill_enabled("g_skill", false);
2272 assert!(!p.skill_enabled("g_skill"));
2273
2274 assert!(!p.set_addon_enabled("nope", true));
2276
2277 p.disable_all_addons();
2279 assert!(p.addons.iter().all(|g| !g.enabled));
2280 assert!(!p.skill_enabled("g_skill"));
2281 assert!(!p.skill_enabled("g_cmd"));
2282 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2288 assert!(!p.skill_enabled("g_skill")); assert!(p.skill_enabled("g_cmd")); assert!(p.mcp_enabled("g_mcp")); p.set_skill_enabled("g_skill", true);
2294 assert!(p.skill_enabled("g_skill"));
2295 }
2296
2297 #[test]
2298 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2299 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2301 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2302 assert_eq!(r.content_hash, None);
2303 assert_eq!(r.fetch_ref, None);
2304
2305 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2307 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2308 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2309 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2310 let back = serde_yaml_ng::to_string(&r2).unwrap();
2311 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2312 assert_eq!(r2, r3);
2313 }
2314}
2315
2316#[cfg(test)]
2317mod lockfile_compat_tests {
2318 use super::*;
2319
2320 #[test]
2321 fn lockfile_new_fields_default_for_old_locks() {
2322 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2325 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2326 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2327 let lock: LockFile = serde_json::from_str(old).unwrap();
2328 assert_eq!(lock.build_sha, "");
2329 assert_eq!(lock.proto_version, 0);
2330 }
2331}
2332
2333#[cfg(test)]
2334mod remote_mcp_tests {
2335 use super::*;
2336
2337 #[test]
2338 fn mcp_entry_roundtrips_remote_bearer() {
2339 let e = McpServerEntry {
2340 name: "gh".into(),
2341 command: String::new(),
2342 url: Some("https://api.example.com/mcp".into()),
2343 auth: Some(McpAuth::Bearer {
2344 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2345 }),
2346 ..Default::default()
2347 };
2348 let y = serde_yaml_ng::to_string(&e).unwrap();
2349 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2350 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2351 assert!(matches!(
2352 back.auth,
2353 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2354 ));
2355 let legacy: McpServerEntry =
2357 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2358 assert!(legacy.url.is_none());
2359 assert!(legacy.auth.is_none());
2360 }
2361}
2362
2363#[cfg(test)]
2364mod requires_programs_tests {
2365 #[test]
2366 fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2367 let with = r#"
2368name: research-gateway
2369command: mur-research-gateway
2370requires_programs:
2371 - name: lightpanda
2372 detect: { file: "~/.mur/aura/lightpanda" }
2373 reason: "render tier"
2374 registry: lightpanda
2375"#;
2376 let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2377 assert_eq!(e.requires_programs.len(), 1);
2378 assert_eq!(e.requires_programs[0].name, "lightpanda");
2379
2380 let without = "name: x\ncommand: y\n";
2382 let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2383 assert!(e2.requires_programs.is_empty());
2384 }
2385}