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
160fn default_algorithm() -> String {
161 "ed25519".into()
162}
163
164pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
166
167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
168pub struct IdentityConfig {
169 #[serde(default)]
172 pub pubkey: String,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub owner: Option<String>,
176
177 #[serde(default = "default_algorithm")]
180 pub algorithm: String,
181 #[serde(default)]
183 pub key_version: u32,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub created_at_key: Option<String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub previous_pubkey: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub previous_key_version: Option<u32>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub grace_expires_at: Option<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub rotated_at: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub emergency_rekey_at: Option<String>,
203}
204
205impl Default for IdentityConfig {
206 fn default() -> Self {
207 Self {
208 pubkey: String::new(),
209 owner: None,
210 algorithm: default_algorithm(),
211 key_version: 0,
212 created_at_key: None,
213 previous_pubkey: None,
214 previous_key_version: None,
215 grace_expires_at: None,
216 rotated_at: None,
217 emergency_rekey_at: None,
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
223pub struct Persona {
224 pub category: PersonaCategory,
225 pub description: String,
226 pub traits: PersonaTraits,
227}
228
229#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
230#[serde(rename_all = "lowercase")]
231pub enum PersonaCategory {
232 Research,
233 Automation,
234 Monitor,
235 Notify,
236 Commerce,
237 Custom,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
241pub struct PersonaTraits {
242 pub tone: String,
243 pub risk: String,
244 pub verbosity: String,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248pub struct ModelConfig {
249 pub provider: String,
250 pub name: String,
251 #[serde(default)]
252 pub params: BTreeMap<String, serde_yaml_ng::Value>,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
256pub struct McpServerEntry {
257 pub name: String,
258 pub command: String,
259 #[serde(default)]
260 pub args: Vec<String>,
261
262 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub binary_sha256: Option<String>,
269
270 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub description_hash: Option<String>,
277
278 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub publisher: Option<McpPublisherInfo>,
283
284 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
289
290 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub timeout_secs: Option<u32>,
295
296 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub network: Option<McpServerNetwork>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub url: Option<String>,
307
308 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub auth: Option<McpAuth>,
312
313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
316 pub requires_programs: Vec<ProgramDep>,
317}
318
319#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
321#[serde(rename_all = "snake_case", tag = "kind")]
322pub enum McpAuth {
323 Bearer { token: crate::secret::SecretRef },
325 Oauth(OauthAuth),
327}
328
329#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
331pub struct OauthAuth {
332 pub token_endpoint: String,
334 pub client_id: String,
336 pub access_token: crate::secret::SecretRef,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub refresh_token: Option<crate::secret::SecretRef>,
341 #[serde(default)]
343 pub expires_at: u64,
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
348#[serde(rename_all = "snake_case")]
349pub enum McpNetMode {
350 #[default]
352 Inherit,
353 Restricted,
355 BroadAudited,
361 Off,
363}
364
365pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
377pub struct McpServerNetwork {
378 #[serde(default)]
379 pub mode: McpNetMode,
380 #[serde(default)]
381 pub allow_hosts: Vec<String>,
382 #[serde(default)]
385 pub deny_hosts: Vec<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub authorization: Option<EgressAuthorization>,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
400pub struct AddonRef {
401 pub id: String,
403 pub source: String,
405 #[serde(default)]
406 pub enabled: bool,
407 #[serde(default, skip_serializing_if = "Vec::is_empty")]
408 pub skills: Vec<String>,
409 #[serde(default, skip_serializing_if = "Vec::is_empty")]
410 pub mcp: Vec<String>,
411 #[serde(default, skip_serializing_if = "Vec::is_empty")]
412 pub commands: Vec<String>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
421pub struct McpPublisherInfo {
422 pub name: String,
425
426 #[serde(default, skip_serializing_if = "Option::is_none")]
430 pub homepage: Option<String>,
431
432 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub registry_id: Option<String>,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
439pub struct TransportConfig {
440 pub stdio: bool,
441 pub socket: SocketTransportConfig,
442 #[serde(default)]
443 pub tcp: TcpTransportConfig,
444 #[serde(default)]
448 pub webhook: WebhookTransportConfig,
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
452pub struct TcpTransportConfig {
453 #[serde(default)]
454 pub enabled: bool,
455 #[serde(default)]
456 pub bind: String,
457 #[serde(default)]
458 pub noise: NoiseConfig,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
475pub struct WebhookTransportConfig {
476 #[serde(default)]
477 pub enabled: bool,
478 #[serde(default = "default_webhook_bind")]
479 pub bind: String,
480 #[serde(default = "default_webhook_port")]
481 pub port: u16,
482 #[serde(default)]
486 pub hmac_secret_ref: String,
487}
488
489fn default_webhook_bind() -> String {
490 "127.0.0.1".to_string()
491}
492
493fn default_webhook_port() -> u16 {
494 6789
495}
496
497impl Default for WebhookTransportConfig {
498 fn default() -> Self {
499 Self {
500 enabled: false,
501 bind: default_webhook_bind(),
502 port: default_webhook_port(),
503 hmac_secret_ref: String::new(),
504 }
505 }
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
509pub struct NoiseConfig {
510 pub pattern: String,
511}
512
513impl Default for NoiseConfig {
514 fn default() -> Self {
515 Self {
516 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
517 }
518 }
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
522pub struct SocketTransportConfig {
523 pub enabled: bool,
524 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub auth: Option<AuthConfig>,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
530pub struct AuthConfig {
531 pub scheme: String,
532 pub token_file: String,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
536pub struct CommunicationConfig {
537 #[serde(default = "default_accepts_all")]
538 pub accepts_from: Vec<String>,
539 #[serde(default)]
540 pub sends_to: Vec<String>,
541}
542fn default_accepts_all() -> Vec<String> {
543 vec!["*".to_string()]
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
547pub struct Entitlements {
548 pub network: NetworkEntitlement,
549 pub filesystem: FilesystemEntitlement,
550 pub processes: ProcessesEntitlement,
551 #[serde(default)]
552 pub syscalls: SyscallsEntitlement,
553 #[serde(default)]
554 pub limits: LimitsEntitlement,
555 #[serde(default)]
558 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
559 #[serde(default, skip_serializing_if = "Vec::is_empty")]
561 pub tools: Vec<ToolRule>,
562 #[serde(default = "default_true")]
567 pub fail_closed_on_sandbox_error: bool,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
571pub struct NetworkEntitlement {
572 pub inbound: InboundNetwork,
573 pub outbound: OutboundNetwork,
574}
575
576#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
577pub struct InboundNetwork {
578 #[serde(default)]
579 pub ports: Vec<u16>,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
583pub struct OutboundNetwork {
584 pub mode: NetworkOutboundMode,
585 #[serde(default)]
586 pub allow_hosts: Vec<String>,
587 #[serde(default = "default_protocols")]
588 pub protocols: Vec<String>,
589 #[serde(default)]
590 pub resolve_dns: ResolveDnsConfig,
591}
592fn default_protocols() -> Vec<String> {
593 vec!["tcp".to_string()]
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
600pub struct EgressAuthorization {
601 pub authorized_by: String,
602 pub authorized_at_ms: u64,
603}
604
605#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
606#[serde(rename_all = "lowercase")]
607pub enum NetworkOutboundMode {
608 Unrestricted,
609 Restricted,
610 ProxyOnly,
614 Off,
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
618pub struct ResolveDnsConfig {
619 #[serde(default = "default_dns_mode")]
620 pub mode: String,
621 #[serde(default)]
622 pub servers: Vec<String>,
623}
624impl Default for ResolveDnsConfig {
625 fn default() -> Self {
626 Self {
627 mode: default_dns_mode(),
628 servers: vec![],
629 }
630 }
631}
632fn default_dns_mode() -> String {
633 "system".to_string()
634}
635
636#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
637pub struct FilesystemEntitlement {
638 #[serde(default)]
639 pub read: Vec<String>,
640 #[serde(default)]
641 pub write: Vec<String>,
642 #[serde(default)]
643 pub deny: Vec<String>,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
647pub struct ProcessesEntitlement {
648 pub spawn: SpawnEntitlement,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
652pub struct SpawnEntitlement {
653 pub mode: SpawnMode,
654 #[serde(default)]
655 pub allowed: Vec<String>,
656}
657
658#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
659#[serde(rename_all = "lowercase")]
660pub enum SpawnMode {
661 Allowlist,
662 Any,
663 None,
664 Strict,
670}
671
672#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
673pub struct SyscallsEntitlement {
674 #[serde(default = "default_syscalls_mode")]
675 pub mode: String,
676 #[serde(default)]
677 pub extra_deny: Vec<String>,
678}
679fn default_syscalls_mode() -> String {
680 "default".to_string()
681}
682
683#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
684pub struct LimitsEntitlement {
685 #[serde(default)]
686 pub cpu_seconds: Option<u64>,
687 #[serde(default = "default_memory_mb")]
688 pub memory_mb: u64,
689 #[serde(default = "default_fds")]
690 pub file_descriptors: u32,
691 #[serde(default = "default_procs")]
692 pub processes: u32,
693}
694fn default_memory_mb() -> u64 {
695 512
696}
697fn default_fds() -> u32 {
698 1024
699}
700fn default_procs() -> u32 {
701 32
702}
703
704#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
705#[serde(rename_all = "lowercase")]
706pub enum ToolPolicy {
707 Allow,
708 #[default]
709 Ask,
710 Deny,
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
714pub struct ToolRule {
715 pub pattern: String,
716 pub policy: ToolPolicy,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub risk: Option<crate::hitl::RiskTier>,
721}
722
723pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
727 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
728}
729
730pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
735 for rule in rules {
736 if rule.pattern == tool_name {
737 return Some(rule.policy);
738 }
739 }
740 let mut best: Option<(&ToolRule, usize)> = None;
741 for rule in rules {
742 if let Some(prefix) = rule.pattern.strip_suffix('*')
743 && tool_name.starts_with(prefix)
744 {
745 let len = prefix.len();
746 if best.is_none_or(|(_, best_len)| len > best_len) {
747 best = Some((rule, len));
748 }
749 }
750 }
751 best.map(|(rule, _)| rule.policy)
752}
753
754#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
755pub struct NotificationsConfig {
756 #[serde(default)]
757 pub on_task_complete: Vec<NotificationTarget>,
758 #[serde(default)]
759 pub on_error: Vec<NotificationTarget>,
760 #[serde(default)]
761 pub on_shutdown: Vec<NotificationTarget>,
762}
763
764#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
765#[serde(tag = "target", rename_all = "lowercase")]
766pub enum NotificationTarget {
767 Agent {
768 name: String,
769 },
770 Commander,
771 Email {
772 address: String,
773 #[serde(default)]
774 smtp_config_file: Option<String>,
775 },
776 Slack {
777 #[serde(default)]
778 channel: Option<String>,
779 #[serde(default)]
780 webhook_url_env: Option<String>,
781 },
782 Webpush {
783 url: String,
784 },
785 Webhook {
786 url: String,
787 #[serde(default = "default_post")]
788 method: String,
789 #[serde(default)]
790 auth: Option<String>,
791 },
792}
793fn default_post() -> String {
794 "POST".to_string()
795}
796
797#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
798pub struct RetryConfig {
799 pub llm: RetryPolicy,
800 pub tool: RetryPolicy,
801}
802
803#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
804pub struct RetryPolicy {
805 pub max_retries: u32,
806 pub backoff: BackoffStrategy,
807 pub initial_delay_ms: u64,
808 #[serde(default)]
809 pub max_delay_ms: Option<u64>,
810 #[serde(default)]
811 pub retry_on: Vec<String>,
812}
813
814#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
815#[serde(rename_all = "lowercase")]
816pub enum BackoffStrategy {
817 Linear,
818 Exponential,
819 Fixed,
820}
821
822#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
823pub struct LifecycleConfig {
824 pub restart: RestartPolicy,
825 #[serde(default = "default_max_restarts")]
826 pub max_restarts: u32,
827 #[serde(default = "default_window")]
828 pub restart_window_secs: u64,
829 #[serde(default = "default_stop_timeout")]
830 pub stop_timeout_secs: u64,
831 #[serde(default = "default_mcp_required")]
832 pub mcp_required: bool,
833 #[serde(default)]
834 pub execution: ExecutionMode,
835 #[serde(default)]
836 pub schedule: Vec<ScheduleEntry>,
837 #[serde(default)]
838 pub idle_triggers: Vec<IdleTrigger>,
839}
840fn default_max_restarts() -> u32 {
841 3
842}
843fn default_window() -> u64 {
844 600
845}
846fn default_stop_timeout() -> u64 {
847 15
848}
849fn default_mcp_required() -> bool {
850 true
851}
852
853#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
854#[serde(rename_all = "snake_case")]
855pub enum RestartPolicy {
856 Never,
857 OnFailure,
858 Always,
859}
860
861#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
862#[serde(rename_all = "snake_case")]
863pub enum ExecutionMode {
864 #[default]
865 Daemon,
866 OnDemand,
867}
868
869#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
870pub struct ScheduleEntry {
871 pub cron: String,
872 pub message: String,
873 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub sends_to: Option<String>,
875}
876
877#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
878pub struct IdleTrigger {
879 pub after_secs: u64,
881 pub message: String,
883 #[serde(default, skip_serializing_if = "Option::is_none")]
885 pub sends_to: Option<String>,
886 #[serde(default = "default_idle_cooldown")]
889 pub cooldown_secs: u64,
890 #[serde(default = "default_true")]
893 pub respect_quiet_hours: bool,
894}
895
896fn default_idle_cooldown() -> u64 {
897 600
898}
899pub fn name_enabled(denylist: &[String], name: &str) -> bool {
901 !denylist.iter().any(|n| n == name)
902}
903
904pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
907 if enabled {
908 list.retain(|n| n != name);
909 } else if !list.iter().any(|n| n == name) {
910 list.push(name.to_string());
911 }
912}
913
914fn default_true() -> bool {
915 true
916}
917
918#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
919pub struct FileTransferConfig {
920 #[serde(default = "default_accept_max")]
921 pub accept_incoming_file_max_bytes: u64,
922 #[serde(default = "default_accept_total")]
923 pub accept_incoming_total_per_hour: u64,
924 #[serde(default = "default_approval_threshold")]
925 pub require_approval_above_bytes: u64,
926 #[serde(default = "default_reject_paths")]
927 pub reject_paths: Vec<String>,
928 #[serde(default = "default_allowed_mime")]
929 pub allowed_mime_types: Vec<String>,
930}
931
932impl Default for FileTransferConfig {
933 fn default() -> Self {
934 Self {
935 accept_incoming_file_max_bytes: default_accept_max(),
936 accept_incoming_total_per_hour: default_accept_total(),
937 require_approval_above_bytes: default_approval_threshold(),
938 reject_paths: default_reject_paths(),
939 allowed_mime_types: default_allowed_mime(),
940 }
941 }
942}
943
944fn default_accept_max() -> u64 {
945 10_485_760
946}
947fn default_accept_total() -> u64 {
948 104_857_600
949}
950fn default_approval_threshold() -> u64 {
951 10_485_760
952}
953fn default_reject_paths() -> Vec<String> {
954 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
955}
956fn default_allowed_mime() -> Vec<String> {
957 vec!["*".into()]
958}
959
960#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
961#[serde(rename_all = "snake_case")]
962pub enum DeploymentType {
963 #[default]
964 Laptop,
965 Vm,
966 Docker,
967 K8s,
968 Lambda,
969}
970
971#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
972pub struct DeploymentConfig {
973 #[serde(rename = "type", default)]
974 pub deployment_type: DeploymentType,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub region: Option<String>,
977 #[serde(default = "default_env")]
978 pub environment: Option<String>,
979}
980
981impl Default for DeploymentConfig {
982 fn default() -> Self {
983 Self {
984 deployment_type: DeploymentType::default(),
985 region: None,
986 environment: default_env(),
987 }
988 }
989}
990
991fn default_env() -> Option<String> {
992 Some("dev".into())
993}
994
995#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
996pub struct LockFile {
997 pub schema: u32,
998 pub uuid: String,
999 pub name: String,
1000 pub pid: u32,
1001 pub ppid: u32,
1002 pub started_at: String,
1003 pub binary_version: String,
1004 pub transports: LockTransports,
1005 pub card_digest: String,
1006 pub capabilities: Vec<String>,
1007 #[serde(default)]
1010 pub build_sha: String,
1011 #[serde(default)]
1014 pub proto_version: u32,
1015}
1016
1017#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1018pub struct LockTransports {
1019 pub stdio: bool,
1020 #[serde(default)]
1021 pub unix_socket: Option<String>,
1022 #[serde(default)]
1023 pub tcp: Option<String>,
1024 #[serde(default)]
1029 pub webhook: Option<String>,
1030}
1031
1032#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1039#[serde(rename_all = "snake_case")]
1040pub enum VoiceId {
1041 #[default]
1043 AfHeart,
1044 AfBella,
1045 AfNicole,
1046 AmAdam,
1047 AmMichael,
1048}
1049
1050impl VoiceId {
1051 pub fn style_index(&self) -> usize {
1053 match self {
1054 VoiceId::AfHeart => 0,
1055 VoiceId::AfBella => 1,
1056 VoiceId::AfNicole => 2,
1057 VoiceId::AmAdam => 3,
1058 VoiceId::AmMichael => 4,
1059 }
1060 }
1061
1062 pub fn as_str(&self) -> &'static str {
1064 match self {
1065 VoiceId::AfHeart => "af_heart",
1066 VoiceId::AfBella => "af_bella",
1067 VoiceId::AfNicole => "af_nicole",
1068 VoiceId::AmAdam => "am_adam",
1069 VoiceId::AmMichael => "am_michael",
1070 }
1071 }
1072}
1073
1074impl std::str::FromStr for VoiceId {
1075 type Err = anyhow::Error;
1076
1077 fn from_str(s: &str) -> anyhow::Result<Self> {
1078 match s {
1079 "af_heart" => Ok(VoiceId::AfHeart),
1080 "af_bella" => Ok(VoiceId::AfBella),
1081 "af_nicole" => Ok(VoiceId::AfNicole),
1082 "am_adam" => Ok(VoiceId::AmAdam),
1083 "am_michael" => Ok(VoiceId::AmMichael),
1084 other => anyhow::bail!(
1085 "unknown voice ID '{other}' \
1086 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1087 ),
1088 }
1089 }
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1095pub struct VoiceConfig {
1096 #[serde(default)]
1098 pub enabled: bool,
1099 #[serde(default)]
1101 pub voice_id: VoiceId,
1102 #[serde(default, skip_serializing_if = "Option::is_none")]
1105 pub input_device: Option<String>,
1106}
1107
1108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1113pub struct HitlConfig {
1114 #[serde(default = "default_hitl_timeout_secs")]
1115 pub timeout_secs: u32,
1116 #[serde(default)]
1120 pub max_iterations: Option<u32>,
1121 #[serde(default)]
1126 pub max_tokens: Option<u64>,
1127}
1128
1129fn default_hitl_timeout_secs() -> u32 {
1130 300
1131}
1132
1133impl Default for HitlConfig {
1134 fn default() -> Self {
1135 Self {
1136 timeout_secs: default_hitl_timeout_secs(),
1137 max_iterations: None,
1138 max_tokens: None,
1139 }
1140 }
1141}
1142
1143#[cfg(test)]
1144mod hitl_tests {
1145 use super::*;
1146
1147 #[test]
1148 fn hitl_config_default_max_iterations_is_none() {
1149 let cfg = HitlConfig::default();
1150 assert!(cfg.max_iterations.is_none());
1151 }
1152
1153 #[test]
1154 fn hitl_config_max_iterations_explicit() {
1155 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1156 assert_eq!(cfg.max_iterations, Some(5));
1157 }
1158
1159 #[test]
1160 fn hitl_config_default_max_tokens_is_none() {
1161 let cfg = HitlConfig::default();
1162 assert!(cfg.max_tokens.is_none());
1163 }
1164
1165 #[test]
1166 fn hitl_config_max_tokens_explicit() {
1167 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1168 assert_eq!(cfg.max_tokens, Some(250_000));
1169 }
1170}
1171
1172#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1178pub struct CompanionConfig {
1179 #[serde(default)]
1180 pub enabled: bool,
1181 #[serde(default = "default_locale")]
1182 pub locale: String,
1183 #[serde(default)]
1184 pub relationship: Relationship,
1185 #[serde(default)]
1186 pub voice_overrides: VoiceOverrides,
1187 #[serde(default)]
1188 pub onboarding: OnboardingState,
1189 #[serde(default)]
1190 pub rhythm: RhythmConfig,
1191 #[serde(default)]
1192 pub proactive: ProactiveConfig,
1193}
1194
1195pub fn default_locale() -> String {
1198 std::env::var("LANG")
1199 .ok()
1200 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
1201 .unwrap_or_else(|| "en-US".into())
1202}
1203
1204#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1205pub struct VoiceOverrides {
1206 #[serde(default, skip_serializing_if = "Option::is_none")]
1207 pub name_for_user: Option<String>,
1208 #[serde(default, skip_serializing_if = "Option::is_none")]
1209 pub formality: Option<Formality>,
1210 #[serde(default, skip_serializing_if = "Option::is_none")]
1211 pub extra_instructions: Option<String>,
1212}
1213
1214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1215pub struct FirstMemory {
1216 pub text: String,
1217 pub established_at: chrono::DateTime<chrono::Utc>,
1218}
1219
1220#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1221pub struct OnboardingState {
1222 #[serde(default, skip_serializing_if = "Option::is_none")]
1223 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1224 #[serde(default)]
1225 pub version: u32,
1226 #[serde(default, skip_serializing_if = "Option::is_none")]
1227 pub agent_display_name: Option<String>,
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1229 pub first_memory: Option<FirstMemory>,
1230}
1231
1232#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1235pub struct RhythmConfig {
1236 #[serde(default)]
1237 pub enabled: bool,
1238}
1239
1240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1241pub struct ProactiveConfig {
1242 #[serde(default)]
1243 pub enabled: bool,
1244 #[serde(default, skip_serializing_if = "Option::is_none")]
1246 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1247 #[serde(default, skip_serializing_if = "Option::is_none")]
1248 pub quiet_hours: Option<QuietHours>,
1249 #[serde(default, skip_serializing_if = "Option::is_none")]
1250 pub active_hours: Option<ActiveHours>,
1251 #[serde(default = "default_daily_cap")]
1252 pub daily_cap: u8,
1253 #[serde(default = "default_channels")]
1254 pub channels: Vec<String>,
1255 #[serde(default, skip_serializing_if = "Option::is_none")]
1256 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1257}
1258
1259impl Default for ProactiveConfig {
1260 fn default() -> Self {
1261 Self {
1262 enabled: false,
1263 learning_until: None,
1264 quiet_hours: None,
1265 active_hours: None,
1266 daily_cap: default_daily_cap(),
1267 channels: default_channels(),
1268 paused_until: None,
1269 }
1270 }
1271}
1272
1273fn default_daily_cap() -> u8 {
1274 3
1275}
1276fn default_channels() -> Vec<String> {
1277 vec!["stdout".into()]
1278}
1279
1280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1281pub struct QuietHours {
1282 pub start: String,
1283 pub end: String,
1284}
1285
1286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1287pub struct ActiveHours {
1288 pub start: String,
1289 pub end: String,
1290}
1291
1292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1297pub struct AgentAppearance {
1298 #[serde(default = "default_style_preset")]
1300 pub style_preset: String,
1301 #[serde(default)]
1302 pub behavior_preset: BehaviorPreset,
1303 #[serde(default, skip_serializing_if = "Option::is_none")]
1305 pub source_image_path: Option<std::path::PathBuf>,
1306 #[serde(default = "default_expressions_dir")]
1308 pub expressions_dir: std::path::PathBuf,
1309 #[serde(default, skip_serializing_if = "Option::is_none")]
1310 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1311 #[serde(default)]
1312 pub render_status: RenderStatus,
1313}
1314
1315fn default_style_preset() -> String {
1316 "default-blob".into()
1317}
1318
1319fn default_expressions_dir() -> std::path::PathBuf {
1320 std::path::PathBuf::from("expressions")
1321}
1322
1323impl Default for AgentAppearance {
1324 fn default() -> Self {
1325 Self {
1326 style_preset: default_style_preset(),
1327 behavior_preset: BehaviorPreset::Normal,
1328 source_image_path: None,
1329 expressions_dir: default_expressions_dir(),
1330 last_rendered_at: None,
1331 render_status: RenderStatus::Pending,
1332 }
1333 }
1334}
1335
1336#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1337#[serde(rename_all = "snake_case")]
1338pub enum BehaviorPreset {
1339 Quiet,
1340 #[default]
1341 Normal,
1342 Lively,
1343}
1344
1345#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1346#[serde(tag = "status", rename_all = "snake_case")]
1347pub enum RenderStatus {
1348 #[default]
1349 Pending,
1350 Rendering {
1351 done: u8,
1352 total: u8,
1353 },
1354 Ready,
1355 Failed {
1356 reason: String,
1357 },
1358}
1359
1360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1366#[serde(rename_all = "kebab-case")]
1367pub enum SnapshotPolicy {
1368 #[default]
1369 PullOnStart,
1370 PullPeriodic,
1371 Manual,
1372}
1373
1374#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1376pub struct PatternFilter {
1377 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1378 pub applies_in: Vec<String>,
1379 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1380 pub tier: Vec<String>,
1381 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1382 pub maturity: Vec<String>,
1383 #[serde(default)]
1384 pub importance_min: f64,
1385 #[serde(default = "default_max_snapshot_count")]
1386 pub max_count: usize,
1387 #[serde(default)]
1388 pub snapshot_policy: SnapshotPolicy,
1389}
1390
1391fn default_max_snapshot_count() -> usize {
1392 200
1393}
1394
1395impl Default for PatternFilter {
1396 fn default() -> Self {
1397 Self {
1398 applies_in: vec![],
1399 tier: vec![],
1400 maturity: vec![],
1401 importance_min: 0.0,
1402 max_count: 200,
1403 snapshot_policy: SnapshotPolicy::default(),
1404 }
1405 }
1406}
1407
1408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1410pub struct SnapshotRef {
1411 pub knowledge_commit: String,
1412 pub taken_at: String,
1413 pub filter: PatternFilter,
1414}
1415
1416#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1418pub struct FederationConfig {
1419 #[serde(default)]
1420 pub filter: PatternFilter,
1421 #[serde(default, skip_serializing_if = "Option::is_none")]
1422 pub snapshot_ref: Option<SnapshotRef>,
1423 #[serde(default)]
1424 pub evidence_flush_interval_minutes: u32,
1425}
1426
1427impl AgentProfile {
1428 #[doc(hidden)]
1434 pub fn default_for_tests() -> Self {
1435 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1436 .expect("minimal profile fixture")
1437 }
1438
1439 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1447 let path = mur_home.join("agents").join(name).join("profile.yaml");
1448 let yaml = std::fs::read_to_string(&path)
1449 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1450 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1451 }
1452
1453 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1455 self.addons.iter().find(|g| {
1456 g.skills.iter().any(|n| n == name)
1457 || g.mcp.iter().any(|n| n == name)
1458 || g.commands.iter().any(|n| n == name)
1459 })
1460 }
1461
1462 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1465 name_enabled(&self.disabled_skills, skill_name)
1466 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1467 }
1468
1469 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1471 name_enabled(&self.disabled_mcp, server_id)
1472 && self.group_of(server_id).is_none_or(|g| g.enabled)
1473 }
1474
1475 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1477 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1478 }
1479
1480 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1482 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1483 }
1484
1485 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1488 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1489 Some(g) => {
1490 g.enabled = enabled;
1491 true
1492 }
1493 None => false,
1494 }
1495 }
1496
1497 pub fn disable_all_addons(&mut self) {
1503 for g in &mut self.addons {
1504 g.enabled = false;
1505 }
1506 }
1507
1508 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1510 self.mcp_servers
1511 .iter()
1512 .filter(|m| self.mcp_enabled(&m.name))
1513 .cloned()
1514 .collect()
1515 }
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520 use super::*;
1521
1522 #[test]
1523 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1524 let net = McpServerNetwork {
1525 mode: McpNetMode::BroadAudited,
1526 allow_hosts: vec![],
1527 deny_hosts: vec!["evil.example".into()],
1528 authorization: Some(EgressAuthorization {
1529 authorized_by: "david".into(),
1530 authorized_at_ms: 1_750_000_000_000,
1531 }),
1532 };
1533 let y = serde_yaml::to_string(&net).unwrap();
1534 assert!(y.contains("broad_audited"));
1535 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1536 assert_eq!(back, net);
1537 let legacy: McpServerNetwork =
1539 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1540 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1541 assert!(legacy.authorization.is_none());
1542 }
1543
1544 #[test]
1545 fn mcp_entry_network_is_optional_and_round_trips() {
1546 let bare = "name: x\ncommand: npx\n";
1548 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1549 assert!(e.network.is_none());
1550
1551 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1553 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1554 let net = e2.network.expect("network present");
1555 assert_eq!(net.mode, McpNetMode::Restricted);
1556 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1557
1558 let out = serde_yaml_ng::to_string(&e).unwrap();
1560 assert!(!out.contains("network"));
1561 }
1562
1563 #[test]
1564 fn profile_round_trip_yaml() {
1565 let yaml = r#"
1566schema: 1
1567id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1568name: agent_a
1569display_name: "Price Hunter"
1570version: "0.1.0"
1571persona:
1572 category: research
1573 description: "Finds prices"
1574 traits: { tone: concise, risk: cautious, verbosity: low }
1575sys_prompt_file: "sys_prompt.md"
1576model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1577mcp_servers: []
1578skills: []
1579transport:
1580 stdio: true
1581 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1582communication: { accepts_from: ["*"], sends_to: [] }
1583capabilities: ["a2a.message.send", "a2a.tasks"]
1584entitlements:
1585 network:
1586 inbound: { ports: [] }
1587 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1588 filesystem: { read: [], write: [], deny: [] }
1589 processes: { spawn: { mode: allowlist, allowed: [] } }
1590 syscalls: { mode: default }
1591 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1592notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1593retry:
1594 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1595 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1596lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1597created_at: "2026-04-22T10:00:00+08:00"
1598updated_at: "2026-04-22T10:00:00+08:00"
1599"#;
1600 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1601 assert_eq!(profile.name, "agent_a");
1602 assert_eq!(profile.persona.category, PersonaCategory::Research);
1603 assert_eq!(
1604 profile.entitlements.network.outbound.mode,
1605 NetworkOutboundMode::Restricted
1606 );
1607 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1608 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1609 assert_eq!(profile.id, round_tripped.id);
1610 }
1611}
1612
1613#[cfg(test)]
1614mod model_ref_tests {
1615 use super::*;
1616
1617 #[test]
1618 fn legacy_profile_without_model_ref_still_parses() {
1619 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1620 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1621 assert!(
1622 p.model_ref.is_none(),
1623 "legacy profile must not have model_ref"
1624 );
1625 }
1626
1627 #[test]
1628 fn round_trip_with_model_ref_preserves_field() {
1629 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1630 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1631 p.model_ref = Some("anthropic_opus_4_7".into());
1632 let s = serde_yaml_ng::to_string(&p).unwrap();
1633 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1634 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1635 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1636 }
1637
1638 #[test]
1639 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
1640 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1642 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1643 assert!(
1644 p.fallback_chain.is_empty(),
1645 "legacy profile must have empty fallback_chain"
1646 );
1647 assert!(
1648 p.routing.is_none(),
1649 "legacy profile must have no routing override"
1650 );
1651
1652 let mut p = p.clone();
1654 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
1655 p.routing = Some(crate::config::RoutingConfig {
1656 enabled: true,
1657 ..Default::default()
1658 });
1659 let s = serde_yaml_ng::to_string(&p).unwrap();
1660 assert!(
1661 s.contains("fallback_chain:"),
1662 "yaml must contain fallback_chain"
1663 );
1664 assert!(s.contains("routing:"), "yaml must contain routing");
1665 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1666 assert_eq!(
1667 p2.fallback_chain,
1668 vec!["claude_opus", "claude_sonnet"],
1669 "fallback_chain must round-trip"
1670 );
1671 assert!(
1672 p2.routing.as_ref().unwrap().enabled,
1673 "routing.enabled must round-trip"
1674 );
1675 }
1676}
1677
1678#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1686#[serde(rename_all = "snake_case")]
1687pub enum ProactiveTier {
1688 Off,
1689 WarmOnly,
1690 WarmAndBehavior,
1691 All,
1692}
1693
1694impl ProactiveTier {
1695 pub fn from_config(c: &CompanionConfig) -> Self {
1696 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1697 (false, _, _) => Self::Off,
1698 (true, false, false) => Self::WarmOnly,
1699 (true, true, false) => Self::WarmAndBehavior,
1700 (true, _, true) => Self::All,
1701 }
1702 }
1703
1704 pub fn apply(&self, c: &mut CompanionConfig) {
1705 match self {
1706 Self::Off => {
1707 c.enabled = false;
1708 c.rhythm.enabled = false;
1709 c.proactive.enabled = false;
1710 }
1711 Self::WarmOnly => {
1712 c.enabled = true;
1713 c.rhythm.enabled = false;
1714 c.proactive.enabled = false;
1715 }
1716 Self::WarmAndBehavior => {
1717 c.enabled = true;
1718 c.rhythm.enabled = true;
1719 c.proactive.enabled = false;
1720 }
1721 Self::All => {
1722 c.enabled = true;
1723 c.rhythm.enabled = true;
1724 c.proactive.enabled = true;
1725 }
1726 }
1727 }
1728}
1729
1730#[cfg(test)]
1731mod mcp_pin_tests {
1732 use super::*;
1733
1734 #[test]
1738 fn pre_m9_entry_roundtrips_without_pin_fields() {
1739 let yaml = r#"
1740name: weather
1741command: /opt/mcp/weather
1742args: ["--port", "0"]
1743"#;
1744 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1745 assert_eq!(entry.name, "weather");
1746 assert_eq!(entry.binary_sha256, None);
1747 assert_eq!(entry.description_hash, None);
1748 assert_eq!(entry.publisher, None);
1749 assert_eq!(entry.installed_at, None);
1750
1751 let out = serde_yaml_ng::to_string(&entry).unwrap();
1754 assert!(!out.contains("binary_sha256"), "got {out}");
1755 assert!(!out.contains("description_hash"), "got {out}");
1756 assert!(!out.contains("publisher"), "got {out}");
1757 assert!(!out.contains("installed_at"), "got {out}");
1758 }
1759
1760 #[test]
1762 fn full_m9_entry_roundtrips_all_fields() {
1763 let yaml = r#"
1764name: weather
1765command: /opt/mcp/weather
1766args: []
1767binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1768description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1769publisher:
1770 name: "@anthropic-mcp/weather"
1771 homepage: "https://github.com/anthropic-mcp/weather"
1772 registry_id: "@anthropic-mcp/weather@1.2.3"
1773installed_at: "2026-05-06T08:00:00Z"
1774"#;
1775 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1776 assert!(
1777 entry
1778 .binary_sha256
1779 .as_deref()
1780 .unwrap()
1781 .starts_with("3f4abca8")
1782 );
1783 assert!(
1784 entry
1785 .description_hash
1786 .as_deref()
1787 .unwrap()
1788 .starts_with("9a01b2c3")
1789 );
1790 let pub_info = entry.publisher.clone().unwrap();
1791 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1792 assert_eq!(
1793 pub_info.homepage.as_deref(),
1794 Some("https://github.com/anthropic-mcp/weather"),
1795 );
1796 assert_eq!(
1797 pub_info.registry_id.as_deref(),
1798 Some("@anthropic-mcp/weather@1.2.3"),
1799 );
1800 let installed = entry.installed_at.unwrap();
1801 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1802 }
1803
1804 #[test]
1808 fn partial_pin_only_binary_sha_roundtrips() {
1809 let yaml = r#"
1810name: weather
1811command: /opt/mcp/weather
1812args: []
1813binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1814"#;
1815 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1816 assert_eq!(
1817 entry.binary_sha256.as_deref(),
1818 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1819 );
1820 assert_eq!(entry.description_hash, None);
1821 assert_eq!(entry.publisher, None);
1822 }
1823
1824 #[test]
1827 fn publisher_minimal_just_name() {
1828 let yaml = r#"
1829name: weather
1830command: /opt/mcp/weather
1831args: []
1832publisher:
1833 name: "alice"
1834"#;
1835 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1836 let p = entry.publisher.as_ref().unwrap();
1837 assert_eq!(p.name, "alice");
1838 assert_eq!(p.homepage, None);
1839 assert_eq!(p.registry_id, None);
1840
1841 let out = serde_yaml_ng::to_string(&entry).unwrap();
1843 assert!(!out.contains("homepage:"), "got {out}");
1844 assert!(!out.contains("registry_id:"), "got {out}");
1845 }
1846}
1847
1848#[cfg(test)]
1849mod voice_tests {
1850 use super::*;
1851 use std::str::FromStr;
1852
1853 #[test]
1854 fn voice_config_round_trips() {
1855 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1857 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1858
1859 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1860 assert!(profile.voice.enabled);
1861 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1862
1863 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1865 assert!(!legacy.voice.enabled);
1866 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1867 }
1868
1869 #[test]
1870 fn voice_id_from_str_roundtrips() {
1871 let cases = [
1872 ("af_heart", VoiceId::AfHeart),
1873 ("af_bella", VoiceId::AfBella),
1874 ("af_nicole", VoiceId::AfNicole),
1875 ("am_adam", VoiceId::AmAdam),
1876 ("am_michael", VoiceId::AmMichael),
1877 ];
1878 for (s, expected) in cases {
1879 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1880 assert_eq!(expected.as_str(), s);
1881 }
1882 }
1883
1884 #[test]
1885 fn voice_id_from_str_rejects_unknown() {
1886 assert!(VoiceId::from_str("bogus").is_err());
1887 }
1888}
1889
1890#[cfg(test)]
1891mod idle_trigger_tests {
1892 use super::*;
1893
1894 #[test]
1895 fn idle_trigger_yaml_round_trip() {
1896 let yaml = r#"
1897restart: on_failure
1898idle_triggers:
1899 - after_secs: 3600
1900 message: "still there?"
1901 sends_to: other_agent
1902 cooldown_secs: 1800
1903 respect_quiet_hours: true
1904"#;
1905 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1906 assert_eq!(cfg.idle_triggers.len(), 1);
1907 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1908 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1909 assert_eq!(
1910 cfg.idle_triggers[0].sends_to.as_deref(),
1911 Some("other_agent")
1912 );
1913 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1914 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1915 }
1916
1917 #[test]
1918 fn idle_trigger_defaults_when_omitted() {
1919 let yaml = "restart: on_failure\n";
1920 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1921 assert!(cfg.idle_triggers.is_empty());
1922 }
1923}
1924
1925#[cfg(test)]
1926mod appearance_tests {
1927 use super::*;
1928
1929 #[test]
1930 fn appearance_default_style_preset_is_default_blob() {
1931 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1932 }
1933
1934 #[test]
1935 fn appearance_default_behavior_is_normal() {
1936 assert_eq!(
1937 AgentAppearance::default().behavior_preset,
1938 BehaviorPreset::Normal
1939 );
1940 }
1941
1942 #[test]
1943 fn appearance_default_render_status_is_pending() {
1944 assert_eq!(
1945 AgentAppearance::default().render_status,
1946 RenderStatus::Pending
1947 );
1948 }
1949
1950 #[test]
1951 fn render_status_serde_round_trip() {
1952 let cases = [
1953 RenderStatus::Pending,
1954 RenderStatus::Rendering { done: 3, total: 12 },
1955 RenderStatus::Ready,
1956 RenderStatus::Failed {
1957 reason: "out of quota".into(),
1958 },
1959 ];
1960 for status in cases {
1961 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1962 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1963 assert_eq!(status, back);
1964 }
1965 }
1966
1967 #[test]
1968 fn agent_profile_with_appearance_round_trips() {
1969 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1970 let yaml = format!(
1971 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
1972 );
1973 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1974 assert_eq!(profile.appearance.style_preset, "chiikawa");
1975 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1976
1977 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1978 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1979 assert_eq!(profile.appearance, back.appearance);
1980 }
1981
1982 #[test]
1983 fn legacy_profile_without_appearance_uses_default() {
1984 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1985 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1986 assert_eq!(profile.appearance.style_preset, "default-blob");
1987 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1988 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1989 }
1990
1991 #[test]
1992 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1993 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1994 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1995 assert!(p.file_actions.is_empty());
1996 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1997 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1998 }
1999}
2000
2001#[cfg(test)]
2002mod federation_tests {
2003 use super::*;
2004
2005 #[test]
2006 fn test_pattern_filter_default() {
2007 let f = PatternFilter::default();
2008 assert_eq!(f.max_count, 200);
2009 assert_eq!(f.importance_min, 0.0);
2010 assert!(f.tier.is_empty());
2011 }
2012
2013 #[test]
2014 fn test_federation_config_roundtrip() {
2015 let cfg = FederationConfig {
2016 filter: PatternFilter {
2017 tier: vec!["core".into()],
2018 max_count: 50,
2019 ..Default::default()
2020 },
2021 snapshot_ref: Some(SnapshotRef {
2022 knowledge_commit: "abc123def456".into(),
2023 taken_at: "2026-05-19T00:00:00Z".into(),
2024 filter: PatternFilter::default(),
2025 }),
2026 evidence_flush_interval_minutes: 15,
2027 };
2028 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2029 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2030 assert_eq!(cfg, back);
2031 }
2032
2033 #[test]
2034 fn test_agent_profile_federation_defaults() {
2035 let cfg = FederationConfig::default();
2039 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2040 assert!(cfg.snapshot_ref.is_none());
2041 }
2042}
2043
2044#[cfg(test)]
2045mod skill_card_tests {
2046 use super::*;
2047
2048 #[test]
2049 fn installed_skills_default_to_empty_when_absent() {
2050 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2051 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2052 assert!(p.installed_skills.is_empty());
2053 }
2054
2055 #[test]
2056 fn installed_skills_roundtrip_preserves_entries() {
2057 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2058 let yaml = format!(
2059 "{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"
2060 );
2061 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2062 assert_eq!(p.installed_skills.len(), 1);
2063 assert_eq!(p.installed_skills[0].name, "s1");
2064 assert_eq!(p.installed_skills[0].abstract_text, "does things");
2065 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2066
2067 let out = serde_yaml_ng::to_string(&p).unwrap();
2068 assert!(out.contains("abstract: does things"));
2069 assert!(out.contains("pattern: /find"));
2070
2071 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2072 assert_eq!(p.installed_skills, back.installed_skills);
2073 }
2074
2075 #[test]
2076 fn installed_skills_minimal_entry_serializes_compactly() {
2077 let entry = SkillCardEntry {
2079 name: "minimal".into(),
2080 ..Default::default()
2081 };
2082 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2083 assert!(yaml.contains("name: minimal"));
2084 assert!(
2085 !yaml.contains("version:"),
2086 "empty version must be skipped: {yaml}"
2087 );
2088 assert!(
2089 !yaml.contains("publisher:"),
2090 "empty publisher must be skipped: {yaml}"
2091 );
2092 assert!(
2093 !yaml.contains("abstract:"),
2094 "empty abstract must be skipped: {yaml}"
2095 );
2096 }
2097}
2098
2099#[cfg(test)]
2100mod tool_policy_tests {
2101 use super::*;
2102
2103 fn rules() -> Vec<ToolRule> {
2104 vec![
2105 ToolRule {
2106 pattern: "mcp__github__merge_pr".into(),
2107 policy: ToolPolicy::Ask,
2108 risk: None,
2109 },
2110 ToolRule {
2111 pattern: "mcp__github__*".into(),
2112 policy: ToolPolicy::Allow,
2113 risk: None,
2114 },
2115 ToolRule {
2116 pattern: "mcp__*".into(),
2117 policy: ToolPolicy::Deny,
2118 risk: None,
2119 },
2120 ToolRule {
2121 pattern: "bash".into(),
2122 policy: ToolPolicy::Allow,
2123 risk: None,
2124 },
2125 ]
2126 }
2127
2128 #[test]
2129 fn exact_beats_glob() {
2130 assert_eq!(
2131 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2132 ToolPolicy::Ask
2133 );
2134 }
2135
2136 #[test]
2137 fn longer_glob_wins() {
2138 assert_eq!(
2139 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2140 ToolPolicy::Allow
2141 );
2142 }
2143
2144 #[test]
2145 fn shorter_glob_fallback() {
2146 assert_eq!(
2147 resolve_tool_policy(&rules(), "mcp__slack__send"),
2148 ToolPolicy::Deny
2149 );
2150 }
2151
2152 #[test]
2153 fn exact_bash() {
2154 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2155 }
2156
2157 #[test]
2158 fn unknown_tool_defaults_ask() {
2159 assert_eq!(
2160 resolve_tool_policy(&rules(), "unknown_tool"),
2161 ToolPolicy::Ask
2162 );
2163 }
2164
2165 #[test]
2166 fn empty_rules_defaults_ask() {
2167 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2168 }
2169
2170 fn minimal_entitlements_yaml() -> &'static str {
2171 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2172 }
2173
2174 #[test]
2175 fn entitlements_tools_defaults_empty() {
2176 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2177 assert!(e.tools.is_empty());
2178 }
2179
2180 #[test]
2181 fn entitlements_tools_roundtrip() {
2182 let base = minimal_entitlements_yaml();
2183 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2184 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2185 assert_eq!(e.tools.len(), 1);
2186 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2187 let y = serde_yaml_ng::to_string(&e).unwrap();
2188 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2189 assert_eq!(back.tools.len(), 1);
2190 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2191 }
2192 #[test]
2193 fn denylist_membership_and_mutation() {
2194 let mut list: Vec<String> = vec![];
2195 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2196
2197 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2199 assert_eq!(list, ["a"]);
2200
2201 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2203
2204 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2206 assert!(list.is_empty());
2207
2208 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2210 }
2211
2212 #[test]
2213 fn addon_group_rule_truth_table() {
2214 let mut p = AgentProfile::default_for_tests();
2215 p.addons.push(AddonRef {
2216 id: "grp".into(),
2217 source: "claude-local:grp@1.0.0".into(),
2218 enabled: false,
2219 skills: vec!["g_skill".into()],
2220 mcp: vec!["g_mcp".into()],
2221 commands: vec!["g_cmd".into()],
2222 });
2223
2224 assert!(p.skill_enabled("standalone"));
2226 assert!(p.mcp_enabled("standalone_mcp"));
2227
2228 assert!(!p.skill_enabled("g_skill"));
2230 assert!(!p.mcp_enabled("g_mcp"));
2231
2232 assert!(p.set_addon_enabled("grp", true));
2234 assert!(p.skill_enabled("g_skill"));
2235 assert!(p.mcp_enabled("g_mcp"));
2236
2237 p.set_skill_enabled("g_skill", false);
2239 assert!(!p.skill_enabled("g_skill"));
2240
2241 assert!(!p.set_addon_enabled("nope", true));
2243
2244 p.disable_all_addons();
2246 assert!(p.addons.iter().all(|g| !g.enabled));
2247 assert!(!p.skill_enabled("g_skill"));
2248 assert!(!p.skill_enabled("g_cmd"));
2249 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2255 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);
2261 assert!(p.skill_enabled("g_skill"));
2262 }
2263}
2264
2265#[cfg(test)]
2266mod lockfile_compat_tests {
2267 use super::*;
2268
2269 #[test]
2270 fn lockfile_new_fields_default_for_old_locks() {
2271 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2274 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2275 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2276 let lock: LockFile = serde_json::from_str(old).unwrap();
2277 assert_eq!(lock.build_sha, "");
2278 assert_eq!(lock.proto_version, 0);
2279 }
2280}
2281
2282#[cfg(test)]
2283mod remote_mcp_tests {
2284 use super::*;
2285
2286 #[test]
2287 fn mcp_entry_roundtrips_remote_bearer() {
2288 let e = McpServerEntry {
2289 name: "gh".into(),
2290 command: String::new(),
2291 url: Some("https://api.example.com/mcp".into()),
2292 auth: Some(McpAuth::Bearer {
2293 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2294 }),
2295 ..Default::default()
2296 };
2297 let y = serde_yaml_ng::to_string(&e).unwrap();
2298 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2299 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2300 assert!(matches!(
2301 back.auth,
2302 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2303 ));
2304 let legacy: McpServerEntry =
2306 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2307 assert!(legacy.url.is_none());
2308 assert!(legacy.auth.is_none());
2309 }
2310}
2311
2312#[cfg(test)]
2313mod requires_programs_tests {
2314 #[test]
2315 fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2316 let with = r#"
2317name: research-gateway
2318command: mur-research-gateway
2319requires_programs:
2320 - name: lightpanda
2321 detect: { file: "~/.mur/aura/lightpanda" }
2322 reason: "render tier"
2323 registry: lightpanda
2324"#;
2325 let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2326 assert_eq!(e.requires_programs.len(), 1);
2327 assert_eq!(e.requires_programs[0].name, "lightpanda");
2328
2329 let without = "name: x\ncommand: y\n";
2331 let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2332 assert!(e2.requires_programs.is_empty());
2333 }
2334}