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