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