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 for rule in rules {
728 if rule.pattern == tool_name {
729 return rule.policy;
730 }
731 }
732 let mut best: Option<(&ToolRule, usize)> = None;
733 for rule in rules {
734 if let Some(prefix) = rule.pattern.strip_suffix('*')
735 && tool_name.starts_with(prefix)
736 {
737 let len = prefix.len();
738 if best.is_none_or(|(_, best_len)| len > best_len) {
739 best = Some((rule, len));
740 }
741 }
742 }
743 if let Some((rule, _)) = best {
744 return rule.policy;
745 }
746 ToolPolicy::default()
747}
748
749#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
750pub struct NotificationsConfig {
751 #[serde(default)]
752 pub on_task_complete: Vec<NotificationTarget>,
753 #[serde(default)]
754 pub on_error: Vec<NotificationTarget>,
755 #[serde(default)]
756 pub on_shutdown: Vec<NotificationTarget>,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
760#[serde(tag = "target", rename_all = "lowercase")]
761pub enum NotificationTarget {
762 Agent {
763 name: String,
764 },
765 Commander,
766 Email {
767 address: String,
768 #[serde(default)]
769 smtp_config_file: Option<String>,
770 },
771 Slack {
772 #[serde(default)]
773 channel: Option<String>,
774 #[serde(default)]
775 webhook_url_env: Option<String>,
776 },
777 Webpush {
778 url: String,
779 },
780 Webhook {
781 url: String,
782 #[serde(default = "default_post")]
783 method: String,
784 #[serde(default)]
785 auth: Option<String>,
786 },
787}
788fn default_post() -> String {
789 "POST".to_string()
790}
791
792#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
793pub struct RetryConfig {
794 pub llm: RetryPolicy,
795 pub tool: RetryPolicy,
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
799pub struct RetryPolicy {
800 pub max_retries: u32,
801 pub backoff: BackoffStrategy,
802 pub initial_delay_ms: u64,
803 #[serde(default)]
804 pub max_delay_ms: Option<u64>,
805 #[serde(default)]
806 pub retry_on: Vec<String>,
807}
808
809#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
810#[serde(rename_all = "lowercase")]
811pub enum BackoffStrategy {
812 Linear,
813 Exponential,
814 Fixed,
815}
816
817#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
818pub struct LifecycleConfig {
819 pub restart: RestartPolicy,
820 #[serde(default = "default_max_restarts")]
821 pub max_restarts: u32,
822 #[serde(default = "default_window")]
823 pub restart_window_secs: u64,
824 #[serde(default = "default_stop_timeout")]
825 pub stop_timeout_secs: u64,
826 #[serde(default = "default_mcp_required")]
827 pub mcp_required: bool,
828 #[serde(default)]
829 pub execution: ExecutionMode,
830 #[serde(default)]
831 pub schedule: Vec<ScheduleEntry>,
832 #[serde(default)]
833 pub idle_triggers: Vec<IdleTrigger>,
834}
835fn default_max_restarts() -> u32 {
836 3
837}
838fn default_window() -> u64 {
839 600
840}
841fn default_stop_timeout() -> u64 {
842 15
843}
844fn default_mcp_required() -> bool {
845 true
846}
847
848#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
849#[serde(rename_all = "snake_case")]
850pub enum RestartPolicy {
851 Never,
852 OnFailure,
853 Always,
854}
855
856#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
857#[serde(rename_all = "snake_case")]
858pub enum ExecutionMode {
859 #[default]
860 Daemon,
861 OnDemand,
862}
863
864#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
865pub struct ScheduleEntry {
866 pub cron: String,
867 pub message: String,
868 #[serde(default, skip_serializing_if = "Option::is_none")]
869 pub sends_to: Option<String>,
870}
871
872#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
873pub struct IdleTrigger {
874 pub after_secs: u64,
876 pub message: String,
878 #[serde(default, skip_serializing_if = "Option::is_none")]
880 pub sends_to: Option<String>,
881 #[serde(default = "default_idle_cooldown")]
884 pub cooldown_secs: u64,
885 #[serde(default = "default_true")]
888 pub respect_quiet_hours: bool,
889}
890
891fn default_idle_cooldown() -> u64 {
892 600
893}
894pub fn name_enabled(denylist: &[String], name: &str) -> bool {
896 !denylist.iter().any(|n| n == name)
897}
898
899pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
902 if enabled {
903 list.retain(|n| n != name);
904 } else if !list.iter().any(|n| n == name) {
905 list.push(name.to_string());
906 }
907}
908
909fn default_true() -> bool {
910 true
911}
912
913#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
914pub struct FileTransferConfig {
915 #[serde(default = "default_accept_max")]
916 pub accept_incoming_file_max_bytes: u64,
917 #[serde(default = "default_accept_total")]
918 pub accept_incoming_total_per_hour: u64,
919 #[serde(default = "default_approval_threshold")]
920 pub require_approval_above_bytes: u64,
921 #[serde(default = "default_reject_paths")]
922 pub reject_paths: Vec<String>,
923 #[serde(default = "default_allowed_mime")]
924 pub allowed_mime_types: Vec<String>,
925}
926
927impl Default for FileTransferConfig {
928 fn default() -> Self {
929 Self {
930 accept_incoming_file_max_bytes: default_accept_max(),
931 accept_incoming_total_per_hour: default_accept_total(),
932 require_approval_above_bytes: default_approval_threshold(),
933 reject_paths: default_reject_paths(),
934 allowed_mime_types: default_allowed_mime(),
935 }
936 }
937}
938
939fn default_accept_max() -> u64 {
940 10_485_760
941}
942fn default_accept_total() -> u64 {
943 104_857_600
944}
945fn default_approval_threshold() -> u64 {
946 10_485_760
947}
948fn default_reject_paths() -> Vec<String> {
949 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
950}
951fn default_allowed_mime() -> Vec<String> {
952 vec!["*".into()]
953}
954
955#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
956#[serde(rename_all = "snake_case")]
957pub enum DeploymentType {
958 #[default]
959 Laptop,
960 Vm,
961 Docker,
962 K8s,
963 Lambda,
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
967pub struct DeploymentConfig {
968 #[serde(rename = "type", default)]
969 pub deployment_type: DeploymentType,
970 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub region: Option<String>,
972 #[serde(default = "default_env")]
973 pub environment: Option<String>,
974}
975
976impl Default for DeploymentConfig {
977 fn default() -> Self {
978 Self {
979 deployment_type: DeploymentType::default(),
980 region: None,
981 environment: default_env(),
982 }
983 }
984}
985
986fn default_env() -> Option<String> {
987 Some("dev".into())
988}
989
990#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
991pub struct LockFile {
992 pub schema: u32,
993 pub uuid: String,
994 pub name: String,
995 pub pid: u32,
996 pub ppid: u32,
997 pub started_at: String,
998 pub binary_version: String,
999 pub transports: LockTransports,
1000 pub card_digest: String,
1001 pub capabilities: Vec<String>,
1002 #[serde(default)]
1005 pub build_sha: String,
1006 #[serde(default)]
1009 pub proto_version: u32,
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1013pub struct LockTransports {
1014 pub stdio: bool,
1015 #[serde(default)]
1016 pub unix_socket: Option<String>,
1017 #[serde(default)]
1018 pub tcp: Option<String>,
1019 #[serde(default)]
1024 pub webhook: Option<String>,
1025}
1026
1027#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1034#[serde(rename_all = "snake_case")]
1035pub enum VoiceId {
1036 #[default]
1038 AfHeart,
1039 AfBella,
1040 AfNicole,
1041 AmAdam,
1042 AmMichael,
1043}
1044
1045impl VoiceId {
1046 pub fn style_index(&self) -> usize {
1048 match self {
1049 VoiceId::AfHeart => 0,
1050 VoiceId::AfBella => 1,
1051 VoiceId::AfNicole => 2,
1052 VoiceId::AmAdam => 3,
1053 VoiceId::AmMichael => 4,
1054 }
1055 }
1056
1057 pub fn as_str(&self) -> &'static str {
1059 match self {
1060 VoiceId::AfHeart => "af_heart",
1061 VoiceId::AfBella => "af_bella",
1062 VoiceId::AfNicole => "af_nicole",
1063 VoiceId::AmAdam => "am_adam",
1064 VoiceId::AmMichael => "am_michael",
1065 }
1066 }
1067}
1068
1069impl std::str::FromStr for VoiceId {
1070 type Err = anyhow::Error;
1071
1072 fn from_str(s: &str) -> anyhow::Result<Self> {
1073 match s {
1074 "af_heart" => Ok(VoiceId::AfHeart),
1075 "af_bella" => Ok(VoiceId::AfBella),
1076 "af_nicole" => Ok(VoiceId::AfNicole),
1077 "am_adam" => Ok(VoiceId::AmAdam),
1078 "am_michael" => Ok(VoiceId::AmMichael),
1079 other => anyhow::bail!(
1080 "unknown voice ID '{other}' \
1081 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1082 ),
1083 }
1084 }
1085}
1086
1087#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1090pub struct VoiceConfig {
1091 #[serde(default)]
1093 pub enabled: bool,
1094 #[serde(default)]
1096 pub voice_id: VoiceId,
1097 #[serde(default, skip_serializing_if = "Option::is_none")]
1100 pub input_device: Option<String>,
1101}
1102
1103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1108pub struct HitlConfig {
1109 #[serde(default = "default_hitl_timeout_secs")]
1110 pub timeout_secs: u32,
1111 #[serde(default)]
1115 pub max_iterations: Option<u32>,
1116 #[serde(default)]
1121 pub max_tokens: Option<u64>,
1122}
1123
1124fn default_hitl_timeout_secs() -> u32 {
1125 300
1126}
1127
1128impl Default for HitlConfig {
1129 fn default() -> Self {
1130 Self {
1131 timeout_secs: default_hitl_timeout_secs(),
1132 max_iterations: None,
1133 max_tokens: None,
1134 }
1135 }
1136}
1137
1138#[cfg(test)]
1139mod hitl_tests {
1140 use super::*;
1141
1142 #[test]
1143 fn hitl_config_default_max_iterations_is_none() {
1144 let cfg = HitlConfig::default();
1145 assert!(cfg.max_iterations.is_none());
1146 }
1147
1148 #[test]
1149 fn hitl_config_max_iterations_explicit() {
1150 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1151 assert_eq!(cfg.max_iterations, Some(5));
1152 }
1153
1154 #[test]
1155 fn hitl_config_default_max_tokens_is_none() {
1156 let cfg = HitlConfig::default();
1157 assert!(cfg.max_tokens.is_none());
1158 }
1159
1160 #[test]
1161 fn hitl_config_max_tokens_explicit() {
1162 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1163 assert_eq!(cfg.max_tokens, Some(250_000));
1164 }
1165}
1166
1167#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1173pub struct CompanionConfig {
1174 #[serde(default)]
1175 pub enabled: bool,
1176 #[serde(default = "default_locale")]
1177 pub locale: String,
1178 #[serde(default)]
1179 pub relationship: Relationship,
1180 #[serde(default)]
1181 pub voice_overrides: VoiceOverrides,
1182 #[serde(default)]
1183 pub onboarding: OnboardingState,
1184 #[serde(default)]
1185 pub rhythm: RhythmConfig,
1186 #[serde(default)]
1187 pub proactive: ProactiveConfig,
1188}
1189
1190pub fn default_locale() -> String {
1193 std::env::var("LANG")
1194 .ok()
1195 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
1196 .unwrap_or_else(|| "en-US".into())
1197}
1198
1199#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1200pub struct VoiceOverrides {
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 pub name_for_user: Option<String>,
1203 #[serde(default, skip_serializing_if = "Option::is_none")]
1204 pub formality: Option<Formality>,
1205 #[serde(default, skip_serializing_if = "Option::is_none")]
1206 pub extra_instructions: Option<String>,
1207}
1208
1209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1210pub struct FirstMemory {
1211 pub text: String,
1212 pub established_at: chrono::DateTime<chrono::Utc>,
1213}
1214
1215#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1216pub struct OnboardingState {
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1218 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1219 #[serde(default)]
1220 pub version: u32,
1221 #[serde(default, skip_serializing_if = "Option::is_none")]
1222 pub agent_display_name: Option<String>,
1223 #[serde(default, skip_serializing_if = "Option::is_none")]
1224 pub first_memory: Option<FirstMemory>,
1225}
1226
1227#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1230pub struct RhythmConfig {
1231 #[serde(default)]
1232 pub enabled: bool,
1233}
1234
1235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1236pub struct ProactiveConfig {
1237 #[serde(default)]
1238 pub enabled: bool,
1239 #[serde(default, skip_serializing_if = "Option::is_none")]
1241 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1242 #[serde(default, skip_serializing_if = "Option::is_none")]
1243 pub quiet_hours: Option<QuietHours>,
1244 #[serde(default, skip_serializing_if = "Option::is_none")]
1245 pub active_hours: Option<ActiveHours>,
1246 #[serde(default = "default_daily_cap")]
1247 pub daily_cap: u8,
1248 #[serde(default = "default_channels")]
1249 pub channels: Vec<String>,
1250 #[serde(default, skip_serializing_if = "Option::is_none")]
1251 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1252}
1253
1254impl Default for ProactiveConfig {
1255 fn default() -> Self {
1256 Self {
1257 enabled: false,
1258 learning_until: None,
1259 quiet_hours: None,
1260 active_hours: None,
1261 daily_cap: default_daily_cap(),
1262 channels: default_channels(),
1263 paused_until: None,
1264 }
1265 }
1266}
1267
1268fn default_daily_cap() -> u8 {
1269 3
1270}
1271fn default_channels() -> Vec<String> {
1272 vec!["stdout".into()]
1273}
1274
1275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1276pub struct QuietHours {
1277 pub start: String,
1278 pub end: String,
1279}
1280
1281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1282pub struct ActiveHours {
1283 pub start: String,
1284 pub end: String,
1285}
1286
1287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1292pub struct AgentAppearance {
1293 #[serde(default = "default_style_preset")]
1295 pub style_preset: String,
1296 #[serde(default)]
1297 pub behavior_preset: BehaviorPreset,
1298 #[serde(default, skip_serializing_if = "Option::is_none")]
1300 pub source_image_path: Option<std::path::PathBuf>,
1301 #[serde(default = "default_expressions_dir")]
1303 pub expressions_dir: std::path::PathBuf,
1304 #[serde(default, skip_serializing_if = "Option::is_none")]
1305 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1306 #[serde(default)]
1307 pub render_status: RenderStatus,
1308}
1309
1310fn default_style_preset() -> String {
1311 "default-blob".into()
1312}
1313
1314fn default_expressions_dir() -> std::path::PathBuf {
1315 std::path::PathBuf::from("expressions")
1316}
1317
1318impl Default for AgentAppearance {
1319 fn default() -> Self {
1320 Self {
1321 style_preset: default_style_preset(),
1322 behavior_preset: BehaviorPreset::Normal,
1323 source_image_path: None,
1324 expressions_dir: default_expressions_dir(),
1325 last_rendered_at: None,
1326 render_status: RenderStatus::Pending,
1327 }
1328 }
1329}
1330
1331#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1332#[serde(rename_all = "snake_case")]
1333pub enum BehaviorPreset {
1334 Quiet,
1335 #[default]
1336 Normal,
1337 Lively,
1338}
1339
1340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1341#[serde(tag = "status", rename_all = "snake_case")]
1342pub enum RenderStatus {
1343 #[default]
1344 Pending,
1345 Rendering {
1346 done: u8,
1347 total: u8,
1348 },
1349 Ready,
1350 Failed {
1351 reason: String,
1352 },
1353}
1354
1355#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1361#[serde(rename_all = "kebab-case")]
1362pub enum SnapshotPolicy {
1363 #[default]
1364 PullOnStart,
1365 PullPeriodic,
1366 Manual,
1367}
1368
1369#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1371pub struct PatternFilter {
1372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1373 pub applies_in: Vec<String>,
1374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1375 pub tier: Vec<String>,
1376 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1377 pub maturity: Vec<String>,
1378 #[serde(default)]
1379 pub importance_min: f64,
1380 #[serde(default = "default_max_snapshot_count")]
1381 pub max_count: usize,
1382 #[serde(default)]
1383 pub snapshot_policy: SnapshotPolicy,
1384}
1385
1386fn default_max_snapshot_count() -> usize {
1387 200
1388}
1389
1390impl Default for PatternFilter {
1391 fn default() -> Self {
1392 Self {
1393 applies_in: vec![],
1394 tier: vec![],
1395 maturity: vec![],
1396 importance_min: 0.0,
1397 max_count: 200,
1398 snapshot_policy: SnapshotPolicy::default(),
1399 }
1400 }
1401}
1402
1403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1405pub struct SnapshotRef {
1406 pub knowledge_commit: String,
1407 pub taken_at: String,
1408 pub filter: PatternFilter,
1409}
1410
1411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1413pub struct FederationConfig {
1414 #[serde(default)]
1415 pub filter: PatternFilter,
1416 #[serde(default, skip_serializing_if = "Option::is_none")]
1417 pub snapshot_ref: Option<SnapshotRef>,
1418 #[serde(default)]
1419 pub evidence_flush_interval_minutes: u32,
1420}
1421
1422impl AgentProfile {
1423 #[doc(hidden)]
1429 pub fn default_for_tests() -> Self {
1430 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1431 .expect("minimal profile fixture")
1432 }
1433
1434 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1442 let path = mur_home.join("agents").join(name).join("profile.yaml");
1443 let yaml = std::fs::read_to_string(&path)
1444 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1445 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1446 }
1447
1448 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1450 self.addons.iter().find(|g| {
1451 g.skills.iter().any(|n| n == name)
1452 || g.mcp.iter().any(|n| n == name)
1453 || g.commands.iter().any(|n| n == name)
1454 })
1455 }
1456
1457 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1460 name_enabled(&self.disabled_skills, skill_name)
1461 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1462 }
1463
1464 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1466 name_enabled(&self.disabled_mcp, server_id)
1467 && self.group_of(server_id).is_none_or(|g| g.enabled)
1468 }
1469
1470 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1472 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1473 }
1474
1475 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1477 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1478 }
1479
1480 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1483 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1484 Some(g) => {
1485 g.enabled = enabled;
1486 true
1487 }
1488 None => false,
1489 }
1490 }
1491
1492 pub fn disable_all_addons(&mut self) {
1498 for g in &mut self.addons {
1499 g.enabled = false;
1500 }
1501 }
1502
1503 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1505 self.mcp_servers
1506 .iter()
1507 .filter(|m| self.mcp_enabled(&m.name))
1508 .cloned()
1509 .collect()
1510 }
1511}
1512
1513#[cfg(test)]
1514mod tests {
1515 use super::*;
1516
1517 #[test]
1518 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1519 let net = McpServerNetwork {
1520 mode: McpNetMode::BroadAudited,
1521 allow_hosts: vec![],
1522 deny_hosts: vec!["evil.example".into()],
1523 authorization: Some(EgressAuthorization {
1524 authorized_by: "david".into(),
1525 authorized_at_ms: 1_750_000_000_000,
1526 }),
1527 };
1528 let y = serde_yaml::to_string(&net).unwrap();
1529 assert!(y.contains("broad_audited"));
1530 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1531 assert_eq!(back, net);
1532 let legacy: McpServerNetwork =
1534 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1535 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1536 assert!(legacy.authorization.is_none());
1537 }
1538
1539 #[test]
1540 fn mcp_entry_network_is_optional_and_round_trips() {
1541 let bare = "name: x\ncommand: npx\n";
1543 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1544 assert!(e.network.is_none());
1545
1546 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1548 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1549 let net = e2.network.expect("network present");
1550 assert_eq!(net.mode, McpNetMode::Restricted);
1551 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1552
1553 let out = serde_yaml_ng::to_string(&e).unwrap();
1555 assert!(!out.contains("network"));
1556 }
1557
1558 #[test]
1559 fn profile_round_trip_yaml() {
1560 let yaml = r#"
1561schema: 1
1562id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1563name: agent_a
1564display_name: "Price Hunter"
1565version: "0.1.0"
1566persona:
1567 category: research
1568 description: "Finds prices"
1569 traits: { tone: concise, risk: cautious, verbosity: low }
1570sys_prompt_file: "sys_prompt.md"
1571model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1572mcp_servers: []
1573skills: []
1574transport:
1575 stdio: true
1576 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1577communication: { accepts_from: ["*"], sends_to: [] }
1578capabilities: ["a2a.message.send", "a2a.tasks"]
1579entitlements:
1580 network:
1581 inbound: { ports: [] }
1582 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1583 filesystem: { read: [], write: [], deny: [] }
1584 processes: { spawn: { mode: allowlist, allowed: [] } }
1585 syscalls: { mode: default }
1586 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1587notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1588retry:
1589 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1590 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1591lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1592created_at: "2026-04-22T10:00:00+08:00"
1593updated_at: "2026-04-22T10:00:00+08:00"
1594"#;
1595 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1596 assert_eq!(profile.name, "agent_a");
1597 assert_eq!(profile.persona.category, PersonaCategory::Research);
1598 assert_eq!(
1599 profile.entitlements.network.outbound.mode,
1600 NetworkOutboundMode::Restricted
1601 );
1602 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1603 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1604 assert_eq!(profile.id, round_tripped.id);
1605 }
1606}
1607
1608#[cfg(test)]
1609mod model_ref_tests {
1610 use super::*;
1611
1612 #[test]
1613 fn legacy_profile_without_model_ref_still_parses() {
1614 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1615 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1616 assert!(
1617 p.model_ref.is_none(),
1618 "legacy profile must not have model_ref"
1619 );
1620 }
1621
1622 #[test]
1623 fn round_trip_with_model_ref_preserves_field() {
1624 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1625 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1626 p.model_ref = Some("anthropic_opus_4_7".into());
1627 let s = serde_yaml_ng::to_string(&p).unwrap();
1628 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1629 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1630 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1631 }
1632
1633 #[test]
1634 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
1635 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1637 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1638 assert!(
1639 p.fallback_chain.is_empty(),
1640 "legacy profile must have empty fallback_chain"
1641 );
1642 assert!(
1643 p.routing.is_none(),
1644 "legacy profile must have no routing override"
1645 );
1646
1647 let mut p = p.clone();
1649 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
1650 p.routing = Some(crate::config::RoutingConfig {
1651 enabled: true,
1652 ..Default::default()
1653 });
1654 let s = serde_yaml_ng::to_string(&p).unwrap();
1655 assert!(
1656 s.contains("fallback_chain:"),
1657 "yaml must contain fallback_chain"
1658 );
1659 assert!(s.contains("routing:"), "yaml must contain routing");
1660 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1661 assert_eq!(
1662 p2.fallback_chain,
1663 vec!["claude_opus", "claude_sonnet"],
1664 "fallback_chain must round-trip"
1665 );
1666 assert!(
1667 p2.routing.as_ref().unwrap().enabled,
1668 "routing.enabled must round-trip"
1669 );
1670 }
1671}
1672
1673#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1681#[serde(rename_all = "snake_case")]
1682pub enum ProactiveTier {
1683 Off,
1684 WarmOnly,
1685 WarmAndBehavior,
1686 All,
1687}
1688
1689impl ProactiveTier {
1690 pub fn from_config(c: &CompanionConfig) -> Self {
1691 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1692 (false, _, _) => Self::Off,
1693 (true, false, false) => Self::WarmOnly,
1694 (true, true, false) => Self::WarmAndBehavior,
1695 (true, _, true) => Self::All,
1696 }
1697 }
1698
1699 pub fn apply(&self, c: &mut CompanionConfig) {
1700 match self {
1701 Self::Off => {
1702 c.enabled = false;
1703 c.rhythm.enabled = false;
1704 c.proactive.enabled = false;
1705 }
1706 Self::WarmOnly => {
1707 c.enabled = true;
1708 c.rhythm.enabled = false;
1709 c.proactive.enabled = false;
1710 }
1711 Self::WarmAndBehavior => {
1712 c.enabled = true;
1713 c.rhythm.enabled = true;
1714 c.proactive.enabled = false;
1715 }
1716 Self::All => {
1717 c.enabled = true;
1718 c.rhythm.enabled = true;
1719 c.proactive.enabled = true;
1720 }
1721 }
1722 }
1723}
1724
1725#[cfg(test)]
1726mod mcp_pin_tests {
1727 use super::*;
1728
1729 #[test]
1733 fn pre_m9_entry_roundtrips_without_pin_fields() {
1734 let yaml = r#"
1735name: weather
1736command: /opt/mcp/weather
1737args: ["--port", "0"]
1738"#;
1739 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1740 assert_eq!(entry.name, "weather");
1741 assert_eq!(entry.binary_sha256, None);
1742 assert_eq!(entry.description_hash, None);
1743 assert_eq!(entry.publisher, None);
1744 assert_eq!(entry.installed_at, None);
1745
1746 let out = serde_yaml_ng::to_string(&entry).unwrap();
1749 assert!(!out.contains("binary_sha256"), "got {out}");
1750 assert!(!out.contains("description_hash"), "got {out}");
1751 assert!(!out.contains("publisher"), "got {out}");
1752 assert!(!out.contains("installed_at"), "got {out}");
1753 }
1754
1755 #[test]
1757 fn full_m9_entry_roundtrips_all_fields() {
1758 let yaml = r#"
1759name: weather
1760command: /opt/mcp/weather
1761args: []
1762binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1763description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1764publisher:
1765 name: "@anthropic-mcp/weather"
1766 homepage: "https://github.com/anthropic-mcp/weather"
1767 registry_id: "@anthropic-mcp/weather@1.2.3"
1768installed_at: "2026-05-06T08:00:00Z"
1769"#;
1770 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1771 assert!(
1772 entry
1773 .binary_sha256
1774 .as_deref()
1775 .unwrap()
1776 .starts_with("3f4abca8")
1777 );
1778 assert!(
1779 entry
1780 .description_hash
1781 .as_deref()
1782 .unwrap()
1783 .starts_with("9a01b2c3")
1784 );
1785 let pub_info = entry.publisher.clone().unwrap();
1786 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1787 assert_eq!(
1788 pub_info.homepage.as_deref(),
1789 Some("https://github.com/anthropic-mcp/weather"),
1790 );
1791 assert_eq!(
1792 pub_info.registry_id.as_deref(),
1793 Some("@anthropic-mcp/weather@1.2.3"),
1794 );
1795 let installed = entry.installed_at.unwrap();
1796 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1797 }
1798
1799 #[test]
1803 fn partial_pin_only_binary_sha_roundtrips() {
1804 let yaml = r#"
1805name: weather
1806command: /opt/mcp/weather
1807args: []
1808binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1809"#;
1810 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1811 assert_eq!(
1812 entry.binary_sha256.as_deref(),
1813 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1814 );
1815 assert_eq!(entry.description_hash, None);
1816 assert_eq!(entry.publisher, None);
1817 }
1818
1819 #[test]
1822 fn publisher_minimal_just_name() {
1823 let yaml = r#"
1824name: weather
1825command: /opt/mcp/weather
1826args: []
1827publisher:
1828 name: "alice"
1829"#;
1830 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1831 let p = entry.publisher.as_ref().unwrap();
1832 assert_eq!(p.name, "alice");
1833 assert_eq!(p.homepage, None);
1834 assert_eq!(p.registry_id, None);
1835
1836 let out = serde_yaml_ng::to_string(&entry).unwrap();
1838 assert!(!out.contains("homepage:"), "got {out}");
1839 assert!(!out.contains("registry_id:"), "got {out}");
1840 }
1841}
1842
1843#[cfg(test)]
1844mod voice_tests {
1845 use super::*;
1846 use std::str::FromStr;
1847
1848 #[test]
1849 fn voice_config_round_trips() {
1850 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1852 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1853
1854 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1855 assert!(profile.voice.enabled);
1856 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1857
1858 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1860 assert!(!legacy.voice.enabled);
1861 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1862 }
1863
1864 #[test]
1865 fn voice_id_from_str_roundtrips() {
1866 let cases = [
1867 ("af_heart", VoiceId::AfHeart),
1868 ("af_bella", VoiceId::AfBella),
1869 ("af_nicole", VoiceId::AfNicole),
1870 ("am_adam", VoiceId::AmAdam),
1871 ("am_michael", VoiceId::AmMichael),
1872 ];
1873 for (s, expected) in cases {
1874 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1875 assert_eq!(expected.as_str(), s);
1876 }
1877 }
1878
1879 #[test]
1880 fn voice_id_from_str_rejects_unknown() {
1881 assert!(VoiceId::from_str("bogus").is_err());
1882 }
1883}
1884
1885#[cfg(test)]
1886mod idle_trigger_tests {
1887 use super::*;
1888
1889 #[test]
1890 fn idle_trigger_yaml_round_trip() {
1891 let yaml = r#"
1892restart: on_failure
1893idle_triggers:
1894 - after_secs: 3600
1895 message: "still there?"
1896 sends_to: other_agent
1897 cooldown_secs: 1800
1898 respect_quiet_hours: true
1899"#;
1900 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1901 assert_eq!(cfg.idle_triggers.len(), 1);
1902 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1903 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1904 assert_eq!(
1905 cfg.idle_triggers[0].sends_to.as_deref(),
1906 Some("other_agent")
1907 );
1908 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1909 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1910 }
1911
1912 #[test]
1913 fn idle_trigger_defaults_when_omitted() {
1914 let yaml = "restart: on_failure\n";
1915 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1916 assert!(cfg.idle_triggers.is_empty());
1917 }
1918}
1919
1920#[cfg(test)]
1921mod appearance_tests {
1922 use super::*;
1923
1924 #[test]
1925 fn appearance_default_style_preset_is_default_blob() {
1926 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1927 }
1928
1929 #[test]
1930 fn appearance_default_behavior_is_normal() {
1931 assert_eq!(
1932 AgentAppearance::default().behavior_preset,
1933 BehaviorPreset::Normal
1934 );
1935 }
1936
1937 #[test]
1938 fn appearance_default_render_status_is_pending() {
1939 assert_eq!(
1940 AgentAppearance::default().render_status,
1941 RenderStatus::Pending
1942 );
1943 }
1944
1945 #[test]
1946 fn render_status_serde_round_trip() {
1947 let cases = [
1948 RenderStatus::Pending,
1949 RenderStatus::Rendering { done: 3, total: 12 },
1950 RenderStatus::Ready,
1951 RenderStatus::Failed {
1952 reason: "out of quota".into(),
1953 },
1954 ];
1955 for status in cases {
1956 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1957 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1958 assert_eq!(status, back);
1959 }
1960 }
1961
1962 #[test]
1963 fn agent_profile_with_appearance_round_trips() {
1964 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1965 let yaml = format!(
1966 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
1967 );
1968 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1969 assert_eq!(profile.appearance.style_preset, "chiikawa");
1970 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1971
1972 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1973 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1974 assert_eq!(profile.appearance, back.appearance);
1975 }
1976
1977 #[test]
1978 fn legacy_profile_without_appearance_uses_default() {
1979 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1980 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1981 assert_eq!(profile.appearance.style_preset, "default-blob");
1982 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1983 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1984 }
1985
1986 #[test]
1987 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1988 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1989 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1990 assert!(p.file_actions.is_empty());
1991 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1992 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1993 }
1994}
1995
1996#[cfg(test)]
1997mod federation_tests {
1998 use super::*;
1999
2000 #[test]
2001 fn test_pattern_filter_default() {
2002 let f = PatternFilter::default();
2003 assert_eq!(f.max_count, 200);
2004 assert_eq!(f.importance_min, 0.0);
2005 assert!(f.tier.is_empty());
2006 }
2007
2008 #[test]
2009 fn test_federation_config_roundtrip() {
2010 let cfg = FederationConfig {
2011 filter: PatternFilter {
2012 tier: vec!["core".into()],
2013 max_count: 50,
2014 ..Default::default()
2015 },
2016 snapshot_ref: Some(SnapshotRef {
2017 knowledge_commit: "abc123def456".into(),
2018 taken_at: "2026-05-19T00:00:00Z".into(),
2019 filter: PatternFilter::default(),
2020 }),
2021 evidence_flush_interval_minutes: 15,
2022 };
2023 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2024 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2025 assert_eq!(cfg, back);
2026 }
2027
2028 #[test]
2029 fn test_agent_profile_federation_defaults() {
2030 let cfg = FederationConfig::default();
2034 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2035 assert!(cfg.snapshot_ref.is_none());
2036 }
2037}
2038
2039#[cfg(test)]
2040mod skill_card_tests {
2041 use super::*;
2042
2043 #[test]
2044 fn installed_skills_default_to_empty_when_absent() {
2045 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2046 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2047 assert!(p.installed_skills.is_empty());
2048 }
2049
2050 #[test]
2051 fn installed_skills_roundtrip_preserves_entries() {
2052 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2053 let yaml = format!(
2054 "{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"
2055 );
2056 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2057 assert_eq!(p.installed_skills.len(), 1);
2058 assert_eq!(p.installed_skills[0].name, "s1");
2059 assert_eq!(p.installed_skills[0].abstract_text, "does things");
2060 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2061
2062 let out = serde_yaml_ng::to_string(&p).unwrap();
2063 assert!(out.contains("abstract: does things"));
2064 assert!(out.contains("pattern: /find"));
2065
2066 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2067 assert_eq!(p.installed_skills, back.installed_skills);
2068 }
2069
2070 #[test]
2071 fn installed_skills_minimal_entry_serializes_compactly() {
2072 let entry = SkillCardEntry {
2074 name: "minimal".into(),
2075 ..Default::default()
2076 };
2077 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2078 assert!(yaml.contains("name: minimal"));
2079 assert!(
2080 !yaml.contains("version:"),
2081 "empty version must be skipped: {yaml}"
2082 );
2083 assert!(
2084 !yaml.contains("publisher:"),
2085 "empty publisher must be skipped: {yaml}"
2086 );
2087 assert!(
2088 !yaml.contains("abstract:"),
2089 "empty abstract must be skipped: {yaml}"
2090 );
2091 }
2092}
2093
2094#[cfg(test)]
2095mod tool_policy_tests {
2096 use super::*;
2097
2098 fn rules() -> Vec<ToolRule> {
2099 vec![
2100 ToolRule {
2101 pattern: "mcp__github__merge_pr".into(),
2102 policy: ToolPolicy::Ask,
2103 risk: None,
2104 },
2105 ToolRule {
2106 pattern: "mcp__github__*".into(),
2107 policy: ToolPolicy::Allow,
2108 risk: None,
2109 },
2110 ToolRule {
2111 pattern: "mcp__*".into(),
2112 policy: ToolPolicy::Deny,
2113 risk: None,
2114 },
2115 ToolRule {
2116 pattern: "bash".into(),
2117 policy: ToolPolicy::Allow,
2118 risk: None,
2119 },
2120 ]
2121 }
2122
2123 #[test]
2124 fn exact_beats_glob() {
2125 assert_eq!(
2126 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2127 ToolPolicy::Ask
2128 );
2129 }
2130
2131 #[test]
2132 fn longer_glob_wins() {
2133 assert_eq!(
2134 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2135 ToolPolicy::Allow
2136 );
2137 }
2138
2139 #[test]
2140 fn shorter_glob_fallback() {
2141 assert_eq!(
2142 resolve_tool_policy(&rules(), "mcp__slack__send"),
2143 ToolPolicy::Deny
2144 );
2145 }
2146
2147 #[test]
2148 fn exact_bash() {
2149 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2150 }
2151
2152 #[test]
2153 fn unknown_tool_defaults_ask() {
2154 assert_eq!(
2155 resolve_tool_policy(&rules(), "unknown_tool"),
2156 ToolPolicy::Ask
2157 );
2158 }
2159
2160 #[test]
2161 fn empty_rules_defaults_ask() {
2162 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2163 }
2164
2165 fn minimal_entitlements_yaml() -> &'static str {
2166 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2167 }
2168
2169 #[test]
2170 fn entitlements_tools_defaults_empty() {
2171 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2172 assert!(e.tools.is_empty());
2173 }
2174
2175 #[test]
2176 fn entitlements_tools_roundtrip() {
2177 let base = minimal_entitlements_yaml();
2178 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2179 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2180 assert_eq!(e.tools.len(), 1);
2181 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2182 let y = serde_yaml_ng::to_string(&e).unwrap();
2183 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2184 assert_eq!(back.tools.len(), 1);
2185 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2186 }
2187 #[test]
2188 fn denylist_membership_and_mutation() {
2189 let mut list: Vec<String> = vec![];
2190 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2191
2192 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2194 assert_eq!(list, ["a"]);
2195
2196 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2198
2199 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2201 assert!(list.is_empty());
2202
2203 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2205 }
2206
2207 #[test]
2208 fn addon_group_rule_truth_table() {
2209 let mut p = AgentProfile::default_for_tests();
2210 p.addons.push(AddonRef {
2211 id: "grp".into(),
2212 source: "claude-local:grp@1.0.0".into(),
2213 enabled: false,
2214 skills: vec!["g_skill".into()],
2215 mcp: vec!["g_mcp".into()],
2216 commands: vec!["g_cmd".into()],
2217 });
2218
2219 assert!(p.skill_enabled("standalone"));
2221 assert!(p.mcp_enabled("standalone_mcp"));
2222
2223 assert!(!p.skill_enabled("g_skill"));
2225 assert!(!p.mcp_enabled("g_mcp"));
2226
2227 assert!(p.set_addon_enabled("grp", true));
2229 assert!(p.skill_enabled("g_skill"));
2230 assert!(p.mcp_enabled("g_mcp"));
2231
2232 p.set_skill_enabled("g_skill", false);
2234 assert!(!p.skill_enabled("g_skill"));
2235
2236 assert!(!p.set_addon_enabled("nope", true));
2238
2239 p.disable_all_addons();
2241 assert!(p.addons.iter().all(|g| !g.enabled));
2242 assert!(!p.skill_enabled("g_skill"));
2243 assert!(!p.skill_enabled("g_cmd"));
2244 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2250 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);
2256 assert!(p.skill_enabled("g_skill"));
2257 }
2258}
2259
2260#[cfg(test)]
2261mod lockfile_compat_tests {
2262 use super::*;
2263
2264 #[test]
2265 fn lockfile_new_fields_default_for_old_locks() {
2266 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2269 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2270 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2271 let lock: LockFile = serde_json::from_str(old).unwrap();
2272 assert_eq!(lock.build_sha, "");
2273 assert_eq!(lock.proto_version, 0);
2274 }
2275}
2276
2277#[cfg(test)]
2278mod remote_mcp_tests {
2279 use super::*;
2280
2281 #[test]
2282 fn mcp_entry_roundtrips_remote_bearer() {
2283 let e = McpServerEntry {
2284 name: "gh".into(),
2285 command: String::new(),
2286 url: Some("https://api.example.com/mcp".into()),
2287 auth: Some(McpAuth::Bearer {
2288 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2289 }),
2290 ..Default::default()
2291 };
2292 let y = serde_yaml_ng::to_string(&e).unwrap();
2293 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2294 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2295 assert!(matches!(
2296 back.auth,
2297 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2298 ));
2299 let legacy: McpServerEntry =
2301 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2302 assert!(legacy.url.is_none());
2303 assert!(legacy.auth.is_none());
2304 }
2305}
2306
2307#[cfg(test)]
2308mod requires_programs_tests {
2309 #[test]
2310 fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2311 let with = r#"
2312name: research-gateway
2313command: mur-research-gateway
2314requires_programs:
2315 - name: lightpanda
2316 detect: { file: "~/.mur/aura/lightpanda" }
2317 reason: "render tier"
2318 registry: lightpanda
2319"#;
2320 let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2321 assert_eq!(e.requires_programs.len(), 1);
2322 assert_eq!(e.requires_programs[0].name, "lightpanda");
2323
2324 let without = "name: x\ncommand: y\n";
2326 let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2327 assert!(e2.requires_programs.is_empty());
2328 }
2329}