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