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