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
374#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
376#[serde(rename_all = "snake_case", tag = "kind")]
377pub enum McpAuth {
378 Bearer { token: crate::secret::SecretRef },
380 Oauth(OauthAuth),
382}
383
384#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
386pub struct OauthAuth {
387 pub token_endpoint: String,
389 pub client_id: String,
391 pub access_token: crate::secret::SecretRef,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub refresh_token: Option<crate::secret::SecretRef>,
396 #[serde(default)]
398 pub expires_at: u64,
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
403#[serde(rename_all = "snake_case")]
404pub enum McpNetMode {
405 #[default]
407 Inherit,
408 Restricted,
410 BroadAudited,
416 Off,
418}
419
420pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
429
430#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
432pub struct McpServerNetwork {
433 #[serde(default)]
434 pub mode: McpNetMode,
435 #[serde(default)]
436 pub allow_hosts: Vec<String>,
437 #[serde(default)]
440 pub deny_hosts: Vec<String>,
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub authorization: Option<EgressAuthorization>,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
455pub struct AddonRef {
456 pub id: String,
458 pub source: String,
460 #[serde(default)]
461 pub enabled: bool,
462 #[serde(default, skip_serializing_if = "Vec::is_empty")]
463 pub skills: Vec<String>,
464 #[serde(default, skip_serializing_if = "Vec::is_empty")]
465 pub mcp: Vec<String>,
466 #[serde(default, skip_serializing_if = "Vec::is_empty")]
467 pub commands: Vec<String>,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub content_hash: Option<String>,
472 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub fetch_ref: Option<String>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub fetch_plugin: Option<String>,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
491pub struct McpPublisherInfo {
492 pub name: String,
495
496 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub homepage: Option<String>,
501
502 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub registry_id: Option<String>,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
509pub struct TransportConfig {
510 pub stdio: bool,
511 pub socket: SocketTransportConfig,
512 #[serde(default)]
513 pub tcp: TcpTransportConfig,
514 #[serde(default)]
518 pub webhook: WebhookTransportConfig,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
522pub struct TcpTransportConfig {
523 #[serde(default)]
524 pub enabled: bool,
525 #[serde(default)]
526 pub bind: String,
527 #[serde(default)]
528 pub noise: NoiseConfig,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
545pub struct WebhookTransportConfig {
546 #[serde(default)]
547 pub enabled: bool,
548 #[serde(default = "default_webhook_bind")]
549 pub bind: String,
550 #[serde(default = "default_webhook_port")]
551 pub port: u16,
552 #[serde(default)]
556 pub hmac_secret_ref: String,
557}
558
559fn default_webhook_bind() -> String {
560 "127.0.0.1".to_string()
561}
562
563fn default_webhook_port() -> u16 {
564 6789
565}
566
567impl Default for WebhookTransportConfig {
568 fn default() -> Self {
569 Self {
570 enabled: false,
571 bind: default_webhook_bind(),
572 port: default_webhook_port(),
573 hmac_secret_ref: String::new(),
574 }
575 }
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
579pub struct NoiseConfig {
580 pub pattern: String,
581}
582
583impl Default for NoiseConfig {
584 fn default() -> Self {
585 Self {
586 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
587 }
588 }
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
592pub struct SocketTransportConfig {
593 pub enabled: bool,
594 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub auth: Option<AuthConfig>,
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
600pub struct AuthConfig {
601 pub scheme: String,
602 pub token_file: String,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
606pub struct CommunicationConfig {
607 #[serde(default = "default_accepts_all")]
608 pub accepts_from: Vec<String>,
609 #[serde(default)]
610 pub sends_to: Vec<String>,
611}
612fn default_accepts_all() -> Vec<String> {
613 vec!["*".to_string()]
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
617pub struct Entitlements {
618 pub network: NetworkEntitlement,
619 pub filesystem: FilesystemEntitlement,
620 pub processes: ProcessesEntitlement,
621 #[serde(default)]
622 pub syscalls: SyscallsEntitlement,
623 #[serde(default)]
624 pub limits: LimitsEntitlement,
625 #[serde(default)]
628 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
629 #[serde(default, skip_serializing_if = "Vec::is_empty")]
631 pub tools: Vec<ToolRule>,
632 #[serde(default = "default_true")]
637 pub fail_closed_on_sandbox_error: bool,
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
641pub struct NetworkEntitlement {
642 pub inbound: InboundNetwork,
643 pub outbound: OutboundNetwork,
644}
645
646#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
647pub struct InboundNetwork {
648 #[serde(default)]
649 pub ports: Vec<u16>,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
653pub struct OutboundNetwork {
654 pub mode: NetworkOutboundMode,
655 #[serde(default)]
656 pub allow_hosts: Vec<String>,
657 #[serde(default = "default_protocols")]
658 pub protocols: Vec<String>,
659 #[serde(default)]
660 pub resolve_dns: ResolveDnsConfig,
661}
662fn default_protocols() -> Vec<String> {
663 vec!["tcp".to_string()]
664}
665
666#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
670pub struct EgressAuthorization {
671 pub authorized_by: String,
672 pub authorized_at_ms: u64,
673}
674
675#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
676#[serde(rename_all = "lowercase")]
677pub enum NetworkOutboundMode {
678 Unrestricted,
679 Restricted,
680 ProxyOnly,
684 Off,
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
688pub struct ResolveDnsConfig {
689 #[serde(default = "default_dns_mode")]
690 pub mode: String,
691 #[serde(default)]
692 pub servers: Vec<String>,
693}
694impl Default for ResolveDnsConfig {
695 fn default() -> Self {
696 Self {
697 mode: default_dns_mode(),
698 servers: vec![],
699 }
700 }
701}
702fn default_dns_mode() -> String {
703 "system".to_string()
704}
705
706#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
707pub struct FilesystemEntitlement {
708 #[serde(default)]
709 pub read: Vec<String>,
710 #[serde(default)]
711 pub write: Vec<String>,
712 #[serde(default)]
713 pub deny: Vec<String>,
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
717pub struct ProcessesEntitlement {
718 pub spawn: SpawnEntitlement,
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
722pub struct SpawnEntitlement {
723 pub mode: SpawnMode,
724 #[serde(default)]
725 pub allowed: Vec<String>,
726}
727
728#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
729#[serde(rename_all = "lowercase")]
730pub enum SpawnMode {
731 Allowlist,
732 Any,
733 None,
734 Strict,
740}
741
742#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
743pub struct SyscallsEntitlement {
744 #[serde(default = "default_syscalls_mode")]
745 pub mode: String,
746 #[serde(default)]
747 pub extra_deny: Vec<String>,
748}
749fn default_syscalls_mode() -> String {
750 "default".to_string()
751}
752
753#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
754pub struct LimitsEntitlement {
755 #[serde(default)]
756 pub cpu_seconds: Option<u64>,
757 #[serde(default = "default_memory_mb")]
758 pub memory_mb: u64,
759 #[serde(default = "default_fds")]
760 pub file_descriptors: u32,
761 #[serde(default = "default_procs")]
762 pub processes: u32,
763}
764fn default_memory_mb() -> u64 {
765 512
766}
767fn default_fds() -> u32 {
768 1024
769}
770fn default_procs() -> u32 {
771 32
772}
773
774#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
775#[serde(rename_all = "lowercase")]
776pub enum ToolPolicy {
777 Allow,
778 #[default]
779 Ask,
780 Deny,
781}
782
783#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
784pub struct ToolRule {
785 pub pattern: String,
786 pub policy: ToolPolicy,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub risk: Option<crate::hitl::RiskTier>,
791}
792
793pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
797 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
798}
799
800pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
805 for rule in rules {
806 if rule.pattern == tool_name {
807 return Some(rule.policy);
808 }
809 }
810 let mut best: Option<(&ToolRule, usize)> = None;
811 for rule in rules {
812 if let Some(prefix) = rule.pattern.strip_suffix('*')
813 && tool_name.starts_with(prefix)
814 {
815 let len = prefix.len();
816 if best.is_none_or(|(_, best_len)| len > best_len) {
817 best = Some((rule, len));
818 }
819 }
820 }
821 best.map(|(rule, _)| rule.policy)
822}
823
824#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
825pub struct NotificationsConfig {
826 #[serde(default)]
827 pub on_task_complete: Vec<NotificationTarget>,
828 #[serde(default)]
829 pub on_error: Vec<NotificationTarget>,
830 #[serde(default)]
831 pub on_shutdown: Vec<NotificationTarget>,
832}
833
834#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
835#[serde(tag = "target", rename_all = "lowercase")]
836pub enum NotificationTarget {
837 Agent {
838 name: String,
839 },
840 Commander,
841 Email {
842 address: String,
843 #[serde(default)]
844 smtp_config_file: Option<String>,
845 },
846 Slack {
847 #[serde(default)]
848 channel: Option<String>,
849 #[serde(default)]
850 webhook_url_env: Option<String>,
851 },
852 Webpush {
853 url: String,
854 },
855 Webhook {
856 url: String,
857 #[serde(default = "default_post")]
858 method: String,
859 #[serde(default)]
860 auth: Option<String>,
861 },
862}
863fn default_post() -> String {
864 "POST".to_string()
865}
866
867#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
868pub struct RetryConfig {
869 pub llm: RetryPolicy,
870 pub tool: RetryPolicy,
871}
872
873#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
874pub struct RetryPolicy {
875 pub max_retries: u32,
876 pub backoff: BackoffStrategy,
877 pub initial_delay_ms: u64,
878 #[serde(default)]
879 pub max_delay_ms: Option<u64>,
880 #[serde(default)]
881 pub retry_on: Vec<String>,
882}
883
884#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
885#[serde(rename_all = "lowercase")]
886pub enum BackoffStrategy {
887 Linear,
888 Exponential,
889 Fixed,
890}
891
892#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
893pub struct LifecycleConfig {
894 pub restart: RestartPolicy,
895 #[serde(default = "default_max_restarts")]
896 pub max_restarts: u32,
897 #[serde(default = "default_window")]
898 pub restart_window_secs: u64,
899 #[serde(default = "default_stop_timeout")]
900 pub stop_timeout_secs: u64,
901 #[serde(default = "default_mcp_required")]
902 pub mcp_required: bool,
903 #[serde(default)]
904 pub execution: ExecutionMode,
905 #[serde(default)]
906 pub schedule: Vec<ScheduleEntry>,
907 #[serde(default)]
908 pub idle_triggers: Vec<IdleTrigger>,
909}
910fn default_max_restarts() -> u32 {
911 3
912}
913fn default_window() -> u64 {
914 600
915}
916fn default_stop_timeout() -> u64 {
917 15
918}
919fn default_mcp_required() -> bool {
920 true
921}
922
923#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
924#[serde(rename_all = "snake_case")]
925pub enum RestartPolicy {
926 Never,
927 OnFailure,
928 Always,
929}
930
931#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
932#[serde(rename_all = "snake_case")]
933pub enum ExecutionMode {
934 #[default]
935 Daemon,
936 OnDemand,
937}
938
939#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
940pub struct ScheduleEntry {
941 pub cron: String,
942 pub message: String,
943 #[serde(default, skip_serializing_if = "Option::is_none")]
944 pub sends_to: Option<String>,
945}
946
947#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
948pub struct IdleTrigger {
949 pub after_secs: u64,
951 pub message: String,
953 #[serde(default, skip_serializing_if = "Option::is_none")]
955 pub sends_to: Option<String>,
956 #[serde(default = "default_idle_cooldown")]
959 pub cooldown_secs: u64,
960 #[serde(default = "default_true")]
963 pub respect_quiet_hours: bool,
964}
965
966fn default_idle_cooldown() -> u64 {
967 600
968}
969pub fn name_enabled(denylist: &[String], name: &str) -> bool {
971 !denylist.iter().any(|n| n == name)
972}
973
974pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
977 if enabled {
978 list.retain(|n| n != name);
979 } else if !list.iter().any(|n| n == name) {
980 list.push(name.to_string());
981 }
982}
983
984fn default_true() -> bool {
985 true
986}
987
988#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
989pub struct FileTransferConfig {
990 #[serde(default = "default_accept_max")]
991 pub accept_incoming_file_max_bytes: u64,
992 #[serde(default = "default_accept_total")]
993 pub accept_incoming_total_per_hour: u64,
994 #[serde(default = "default_approval_threshold")]
995 pub require_approval_above_bytes: u64,
996 #[serde(default = "default_reject_paths")]
997 pub reject_paths: Vec<String>,
998 #[serde(default = "default_allowed_mime")]
999 pub allowed_mime_types: Vec<String>,
1000}
1001
1002impl Default for FileTransferConfig {
1003 fn default() -> Self {
1004 Self {
1005 accept_incoming_file_max_bytes: default_accept_max(),
1006 accept_incoming_total_per_hour: default_accept_total(),
1007 require_approval_above_bytes: default_approval_threshold(),
1008 reject_paths: default_reject_paths(),
1009 allowed_mime_types: default_allowed_mime(),
1010 }
1011 }
1012}
1013
1014fn default_accept_max() -> u64 {
1015 10_485_760
1016}
1017fn default_accept_total() -> u64 {
1018 104_857_600
1019}
1020fn default_approval_threshold() -> u64 {
1021 10_485_760
1022}
1023fn default_reject_paths() -> Vec<String> {
1024 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
1025}
1026fn default_allowed_mime() -> Vec<String> {
1027 vec!["*".into()]
1028}
1029
1030#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1031#[serde(rename_all = "snake_case")]
1032pub enum DeploymentType {
1033 #[default]
1034 Laptop,
1035 Vm,
1036 Docker,
1037 K8s,
1038 Lambda,
1039}
1040
1041#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1042pub struct DeploymentConfig {
1043 #[serde(rename = "type", default)]
1044 pub deployment_type: DeploymentType,
1045 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub region: Option<String>,
1047 #[serde(default = "default_env")]
1048 pub environment: Option<String>,
1049}
1050
1051impl Default for DeploymentConfig {
1052 fn default() -> Self {
1053 Self {
1054 deployment_type: DeploymentType::default(),
1055 region: None,
1056 environment: default_env(),
1057 }
1058 }
1059}
1060
1061fn default_env() -> Option<String> {
1062 Some("dev".into())
1063}
1064
1065#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1066pub struct LockFile {
1067 pub schema: u32,
1068 pub uuid: String,
1069 pub name: String,
1070 pub pid: u32,
1071 pub ppid: u32,
1072 pub started_at: String,
1073 pub binary_version: String,
1074 pub transports: LockTransports,
1075 pub card_digest: String,
1076 pub capabilities: Vec<String>,
1077 #[serde(default)]
1080 pub build_sha: String,
1081 #[serde(default)]
1084 pub proto_version: u32,
1085}
1086
1087#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1088pub struct LockTransports {
1089 pub stdio: bool,
1090 #[serde(default)]
1091 pub unix_socket: Option<String>,
1092 #[serde(default)]
1093 pub tcp: Option<String>,
1094 #[serde(default)]
1099 pub webhook: Option<String>,
1100}
1101
1102#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1109#[serde(rename_all = "snake_case")]
1110pub enum VoiceId {
1111 #[default]
1113 AfHeart,
1114 AfBella,
1115 AfNicole,
1116 AmAdam,
1117 AmMichael,
1118}
1119
1120impl VoiceId {
1121 pub fn style_index(&self) -> usize {
1123 match self {
1124 VoiceId::AfHeart => 0,
1125 VoiceId::AfBella => 1,
1126 VoiceId::AfNicole => 2,
1127 VoiceId::AmAdam => 3,
1128 VoiceId::AmMichael => 4,
1129 }
1130 }
1131
1132 pub fn as_str(&self) -> &'static str {
1134 match self {
1135 VoiceId::AfHeart => "af_heart",
1136 VoiceId::AfBella => "af_bella",
1137 VoiceId::AfNicole => "af_nicole",
1138 VoiceId::AmAdam => "am_adam",
1139 VoiceId::AmMichael => "am_michael",
1140 }
1141 }
1142}
1143
1144impl std::str::FromStr for VoiceId {
1145 type Err = anyhow::Error;
1146
1147 fn from_str(s: &str) -> anyhow::Result<Self> {
1148 match s {
1149 "af_heart" => Ok(VoiceId::AfHeart),
1150 "af_bella" => Ok(VoiceId::AfBella),
1151 "af_nicole" => Ok(VoiceId::AfNicole),
1152 "am_adam" => Ok(VoiceId::AmAdam),
1153 "am_michael" => Ok(VoiceId::AmMichael),
1154 other => anyhow::bail!(
1155 "unknown voice ID '{other}' \
1156 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1157 ),
1158 }
1159 }
1160}
1161
1162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1165pub struct VoiceConfig {
1166 #[serde(default)]
1168 pub enabled: bool,
1169 #[serde(default)]
1171 pub voice_id: VoiceId,
1172 #[serde(default, skip_serializing_if = "Option::is_none")]
1175 pub input_device: Option<String>,
1176}
1177
1178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1183pub struct HitlConfig {
1184 #[serde(default = "default_hitl_timeout_secs")]
1185 pub timeout_secs: u32,
1186 #[serde(default)]
1190 pub max_iterations: Option<u32>,
1191 #[serde(default)]
1196 pub max_tokens: Option<u64>,
1197}
1198
1199fn default_hitl_timeout_secs() -> u32 {
1200 300
1201}
1202
1203impl Default for HitlConfig {
1204 fn default() -> Self {
1205 Self {
1206 timeout_secs: default_hitl_timeout_secs(),
1207 max_iterations: None,
1208 max_tokens: None,
1209 }
1210 }
1211}
1212
1213#[cfg(test)]
1214mod hitl_tests {
1215 use super::*;
1216
1217 #[test]
1218 fn hitl_config_default_max_iterations_is_none() {
1219 let cfg = HitlConfig::default();
1220 assert!(cfg.max_iterations.is_none());
1221 }
1222
1223 #[test]
1224 fn hitl_config_max_iterations_explicit() {
1225 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1226 assert_eq!(cfg.max_iterations, Some(5));
1227 }
1228
1229 #[test]
1230 fn hitl_config_default_max_tokens_is_none() {
1231 let cfg = HitlConfig::default();
1232 assert!(cfg.max_tokens.is_none());
1233 }
1234
1235 #[test]
1236 fn hitl_config_max_tokens_explicit() {
1237 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1238 assert_eq!(cfg.max_tokens, Some(250_000));
1239 }
1240}
1241
1242#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1248pub struct CompanionConfig {
1249 #[serde(default)]
1250 pub enabled: bool,
1251 #[serde(default = "default_locale")]
1252 pub locale: String,
1253 #[serde(default)]
1254 pub relationship: Relationship,
1255 #[serde(default)]
1256 pub voice_overrides: VoiceOverrides,
1257 #[serde(default)]
1258 pub onboarding: OnboardingState,
1259 #[serde(default)]
1260 pub rhythm: RhythmConfig,
1261 #[serde(default)]
1262 pub proactive: ProactiveConfig,
1263}
1264
1265pub fn default_locale() -> String {
1268 std::env::var("LANG")
1269 .ok()
1270 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
1271 .unwrap_or_else(|| "en-US".into())
1272}
1273
1274#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1275pub struct VoiceOverrides {
1276 #[serde(default, skip_serializing_if = "Option::is_none")]
1277 pub name_for_user: Option<String>,
1278 #[serde(default, skip_serializing_if = "Option::is_none")]
1279 pub formality: Option<Formality>,
1280 #[serde(default, skip_serializing_if = "Option::is_none")]
1281 pub extra_instructions: Option<String>,
1282}
1283
1284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1285pub struct FirstMemory {
1286 pub text: String,
1287 pub established_at: chrono::DateTime<chrono::Utc>,
1288}
1289
1290#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1291pub struct OnboardingState {
1292 #[serde(default, skip_serializing_if = "Option::is_none")]
1293 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1294 #[serde(default)]
1295 pub version: u32,
1296 #[serde(default, skip_serializing_if = "Option::is_none")]
1297 pub agent_display_name: Option<String>,
1298 #[serde(default, skip_serializing_if = "Option::is_none")]
1299 pub first_memory: Option<FirstMemory>,
1300}
1301
1302#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1305pub struct RhythmConfig {
1306 #[serde(default)]
1307 pub enabled: bool,
1308}
1309
1310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1311pub struct ProactiveConfig {
1312 #[serde(default)]
1313 pub enabled: bool,
1314 #[serde(default, skip_serializing_if = "Option::is_none")]
1316 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1318 pub quiet_hours: Option<QuietHours>,
1319 #[serde(default, skip_serializing_if = "Option::is_none")]
1320 pub active_hours: Option<ActiveHours>,
1321 #[serde(default = "default_daily_cap")]
1322 pub daily_cap: u8,
1323 #[serde(default = "default_channels")]
1324 pub channels: Vec<String>,
1325 #[serde(default, skip_serializing_if = "Option::is_none")]
1326 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1327}
1328
1329impl Default for ProactiveConfig {
1330 fn default() -> Self {
1331 Self {
1332 enabled: false,
1333 learning_until: None,
1334 quiet_hours: None,
1335 active_hours: None,
1336 daily_cap: default_daily_cap(),
1337 channels: default_channels(),
1338 paused_until: None,
1339 }
1340 }
1341}
1342
1343fn default_daily_cap() -> u8 {
1344 3
1345}
1346fn default_channels() -> Vec<String> {
1347 vec!["stdout".into()]
1348}
1349
1350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1351pub struct QuietHours {
1352 pub start: String,
1353 pub end: String,
1354}
1355
1356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1357pub struct ActiveHours {
1358 pub start: String,
1359 pub end: String,
1360}
1361
1362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1367pub struct AgentAppearance {
1368 #[serde(default = "default_style_preset")]
1370 pub style_preset: String,
1371 #[serde(default)]
1372 pub behavior_preset: BehaviorPreset,
1373 #[serde(default, skip_serializing_if = "Option::is_none")]
1375 pub source_image_path: Option<std::path::PathBuf>,
1376 #[serde(default = "default_expressions_dir")]
1378 pub expressions_dir: std::path::PathBuf,
1379 #[serde(default, skip_serializing_if = "Option::is_none")]
1380 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1381 #[serde(default)]
1382 pub render_status: RenderStatus,
1383}
1384
1385fn default_style_preset() -> String {
1386 "default-blob".into()
1387}
1388
1389fn default_expressions_dir() -> std::path::PathBuf {
1390 std::path::PathBuf::from("expressions")
1391}
1392
1393impl Default for AgentAppearance {
1394 fn default() -> Self {
1395 Self {
1396 style_preset: default_style_preset(),
1397 behavior_preset: BehaviorPreset::Normal,
1398 source_image_path: None,
1399 expressions_dir: default_expressions_dir(),
1400 last_rendered_at: None,
1401 render_status: RenderStatus::Pending,
1402 }
1403 }
1404}
1405
1406#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1407#[serde(rename_all = "snake_case")]
1408pub enum BehaviorPreset {
1409 Quiet,
1410 #[default]
1411 Normal,
1412 Lively,
1413}
1414
1415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1416#[serde(tag = "status", rename_all = "snake_case")]
1417pub enum RenderStatus {
1418 #[default]
1419 Pending,
1420 Rendering {
1421 done: u8,
1422 total: u8,
1423 },
1424 Ready,
1425 Failed {
1426 reason: String,
1427 },
1428}
1429
1430#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1436#[serde(rename_all = "kebab-case")]
1437pub enum SnapshotPolicy {
1438 #[default]
1439 PullOnStart,
1440 PullPeriodic,
1441 Manual,
1442}
1443
1444#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1446pub struct PatternFilter {
1447 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1448 pub applies_in: Vec<String>,
1449 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1450 pub tier: Vec<String>,
1451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1452 pub maturity: Vec<String>,
1453 #[serde(default)]
1454 pub importance_min: f64,
1455 #[serde(default = "default_max_snapshot_count")]
1456 pub max_count: usize,
1457 #[serde(default)]
1458 pub snapshot_policy: SnapshotPolicy,
1459}
1460
1461fn default_max_snapshot_count() -> usize {
1462 200
1463}
1464
1465impl Default for PatternFilter {
1466 fn default() -> Self {
1467 Self {
1468 applies_in: vec![],
1469 tier: vec![],
1470 maturity: vec![],
1471 importance_min: 0.0,
1472 max_count: 200,
1473 snapshot_policy: SnapshotPolicy::default(),
1474 }
1475 }
1476}
1477
1478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1480pub struct SnapshotRef {
1481 pub knowledge_commit: String,
1482 pub taken_at: String,
1483 pub filter: PatternFilter,
1484}
1485
1486#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1488pub struct FederationConfig {
1489 #[serde(default)]
1490 pub filter: PatternFilter,
1491 #[serde(default, skip_serializing_if = "Option::is_none")]
1492 pub snapshot_ref: Option<SnapshotRef>,
1493 #[serde(default)]
1494 pub evidence_flush_interval_minutes: u32,
1495}
1496
1497impl AgentProfile {
1498 #[doc(hidden)]
1504 pub fn default_for_tests() -> Self {
1505 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1506 .expect("minimal profile fixture")
1507 }
1508
1509 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1517 let path = mur_home.join("agents").join(name).join("profile.yaml");
1518 let yaml = std::fs::read_to_string(&path)
1519 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1520 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1521 }
1522
1523 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1525 self.addons.iter().find(|g| {
1526 g.skills.iter().any(|n| n == name)
1527 || g.mcp.iter().any(|n| n == name)
1528 || g.commands.iter().any(|n| n == name)
1529 })
1530 }
1531
1532 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1535 name_enabled(&self.disabled_skills, skill_name)
1536 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1537 }
1538
1539 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1541 name_enabled(&self.disabled_mcp, server_id)
1542 && self.group_of(server_id).is_none_or(|g| g.enabled)
1543 }
1544
1545 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1547 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1548 }
1549
1550 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1552 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1553 }
1554
1555 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1558 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1559 Some(g) => {
1560 g.enabled = enabled;
1561 true
1562 }
1563 None => false,
1564 }
1565 }
1566
1567 pub fn disable_all_addons(&mut self) {
1573 for g in &mut self.addons {
1574 g.enabled = false;
1575 }
1576 }
1577
1578 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1580 self.mcp_servers
1581 .iter()
1582 .filter(|m| self.mcp_enabled(&m.name))
1583 .cloned()
1584 .collect()
1585 }
1586}
1587
1588#[cfg(test)]
1589mod tests {
1590 use super::*;
1591
1592 #[test]
1593 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1594 let net = McpServerNetwork {
1595 mode: McpNetMode::BroadAudited,
1596 allow_hosts: vec![],
1597 deny_hosts: vec!["evil.example".into()],
1598 authorization: Some(EgressAuthorization {
1599 authorized_by: "david".into(),
1600 authorized_at_ms: 1_750_000_000_000,
1601 }),
1602 };
1603 let y = serde_yaml::to_string(&net).unwrap();
1604 assert!(y.contains("broad_audited"));
1605 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1606 assert_eq!(back, net);
1607 let legacy: McpServerNetwork =
1609 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1610 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1611 assert!(legacy.authorization.is_none());
1612 }
1613
1614 #[test]
1615 fn mcp_entry_network_is_optional_and_round_trips() {
1616 let bare = "name: x\ncommand: npx\n";
1618 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1619 assert!(e.network.is_none());
1620
1621 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1623 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1624 let net = e2.network.expect("network present");
1625 assert_eq!(net.mode, McpNetMode::Restricted);
1626 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1627
1628 let out = serde_yaml_ng::to_string(&e).unwrap();
1630 assert!(!out.contains("network"));
1631 }
1632
1633 #[test]
1634 fn profile_round_trip_yaml() {
1635 let yaml = r#"
1636schema: 1
1637id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1638name: agent_a
1639display_name: "Price Hunter"
1640version: "0.1.0"
1641persona:
1642 category: research
1643 description: "Finds prices"
1644 traits: { tone: concise, risk: cautious, verbosity: low }
1645sys_prompt_file: "sys_prompt.md"
1646model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1647mcp_servers: []
1648skills: []
1649transport:
1650 stdio: true
1651 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1652communication: { accepts_from: ["*"], sends_to: [] }
1653capabilities: ["a2a.message.send", "a2a.tasks"]
1654entitlements:
1655 network:
1656 inbound: { ports: [] }
1657 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1658 filesystem: { read: [], write: [], deny: [] }
1659 processes: { spawn: { mode: allowlist, allowed: [] } }
1660 syscalls: { mode: default }
1661 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1662notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1663retry:
1664 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1665 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1666lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1667created_at: "2026-04-22T10:00:00+08:00"
1668updated_at: "2026-04-22T10:00:00+08:00"
1669"#;
1670 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1671 assert_eq!(profile.name, "agent_a");
1672 assert_eq!(profile.persona.category, PersonaCategory::Research);
1673 assert_eq!(
1674 profile.entitlements.network.outbound.mode,
1675 NetworkOutboundMode::Restricted
1676 );
1677 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1678 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1679 assert_eq!(profile.id, round_tripped.id);
1680 }
1681
1682 #[test]
1683 fn requires_capabilities_defaults_empty_and_round_trips() {
1684 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1685 let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
1686 assert!(p.requires_capabilities.is_empty());
1687 let with = format!("{base}\nrequires_capabilities:\n - media\n");
1688 let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
1689 assert_eq!(p2.requires_capabilities, vec!["media"]);
1690 }
1691}
1692
1693#[cfg(test)]
1694mod model_ref_tests {
1695 use super::*;
1696
1697 #[test]
1698 fn legacy_profile_without_model_ref_still_parses() {
1699 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1700 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1701 assert!(
1702 p.model_ref.is_none(),
1703 "legacy profile must not have model_ref"
1704 );
1705 }
1706
1707 #[test]
1708 fn round_trip_with_model_ref_preserves_field() {
1709 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1710 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1711 p.model_ref = Some("anthropic_opus_4_7".into());
1712 let s = serde_yaml_ng::to_string(&p).unwrap();
1713 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1714 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1715 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1716 }
1717
1718 #[test]
1719 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
1720 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1722 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1723 assert!(
1724 p.fallback_chain.is_empty(),
1725 "legacy profile must have empty fallback_chain"
1726 );
1727 assert!(
1728 p.routing.is_none(),
1729 "legacy profile must have no routing override"
1730 );
1731
1732 let mut p = p.clone();
1734 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
1735 p.routing = Some(crate::config::RoutingConfig {
1736 enabled: true,
1737 ..Default::default()
1738 });
1739 let s = serde_yaml_ng::to_string(&p).unwrap();
1740 assert!(
1741 s.contains("fallback_chain:"),
1742 "yaml must contain fallback_chain"
1743 );
1744 assert!(s.contains("routing:"), "yaml must contain routing");
1745 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1746 assert_eq!(
1747 p2.fallback_chain,
1748 vec!["claude_opus", "claude_sonnet"],
1749 "fallback_chain must round-trip"
1750 );
1751 assert!(
1752 p2.routing.as_ref().unwrap().enabled,
1753 "routing.enabled must round-trip"
1754 );
1755 }
1756}
1757
1758#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1766#[serde(rename_all = "snake_case")]
1767pub enum ProactiveTier {
1768 Off,
1769 WarmOnly,
1770 WarmAndBehavior,
1771 All,
1772}
1773
1774impl ProactiveTier {
1775 pub fn from_config(c: &CompanionConfig) -> Self {
1776 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1777 (false, _, _) => Self::Off,
1778 (true, false, false) => Self::WarmOnly,
1779 (true, true, false) => Self::WarmAndBehavior,
1780 (true, _, true) => Self::All,
1781 }
1782 }
1783
1784 pub fn apply(&self, c: &mut CompanionConfig) {
1785 match self {
1786 Self::Off => {
1787 c.enabled = false;
1788 c.rhythm.enabled = false;
1789 c.proactive.enabled = false;
1790 }
1791 Self::WarmOnly => {
1792 c.enabled = true;
1793 c.rhythm.enabled = false;
1794 c.proactive.enabled = false;
1795 }
1796 Self::WarmAndBehavior => {
1797 c.enabled = true;
1798 c.rhythm.enabled = true;
1799 c.proactive.enabled = false;
1800 }
1801 Self::All => {
1802 c.enabled = true;
1803 c.rhythm.enabled = true;
1804 c.proactive.enabled = true;
1805 }
1806 }
1807 }
1808}
1809
1810#[cfg(test)]
1811mod mcp_pin_tests {
1812 use super::*;
1813
1814 #[test]
1818 fn pre_m9_entry_roundtrips_without_pin_fields() {
1819 let yaml = r#"
1820name: weather
1821command: /opt/mcp/weather
1822args: ["--port", "0"]
1823"#;
1824 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1825 assert_eq!(entry.name, "weather");
1826 assert_eq!(entry.binary_sha256, None);
1827 assert_eq!(entry.description_hash, None);
1828 assert_eq!(entry.publisher, None);
1829 assert_eq!(entry.installed_at, None);
1830
1831 let out = serde_yaml_ng::to_string(&entry).unwrap();
1834 assert!(!out.contains("binary_sha256"), "got {out}");
1835 assert!(!out.contains("description_hash"), "got {out}");
1836 assert!(!out.contains("publisher"), "got {out}");
1837 assert!(!out.contains("installed_at"), "got {out}");
1838 }
1839
1840 #[test]
1842 fn full_m9_entry_roundtrips_all_fields() {
1843 let yaml = r#"
1844name: weather
1845command: /opt/mcp/weather
1846args: []
1847binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1848description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1849publisher:
1850 name: "@anthropic-mcp/weather"
1851 homepage: "https://github.com/anthropic-mcp/weather"
1852 registry_id: "@anthropic-mcp/weather@1.2.3"
1853installed_at: "2026-05-06T08:00:00Z"
1854"#;
1855 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1856 assert!(
1857 entry
1858 .binary_sha256
1859 .as_deref()
1860 .unwrap()
1861 .starts_with("3f4abca8")
1862 );
1863 assert!(
1864 entry
1865 .description_hash
1866 .as_deref()
1867 .unwrap()
1868 .starts_with("9a01b2c3")
1869 );
1870 let pub_info = entry.publisher.clone().unwrap();
1871 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1872 assert_eq!(
1873 pub_info.homepage.as_deref(),
1874 Some("https://github.com/anthropic-mcp/weather"),
1875 );
1876 assert_eq!(
1877 pub_info.registry_id.as_deref(),
1878 Some("@anthropic-mcp/weather@1.2.3"),
1879 );
1880 let installed = entry.installed_at.unwrap();
1881 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1882 }
1883
1884 #[test]
1888 fn partial_pin_only_binary_sha_roundtrips() {
1889 let yaml = r#"
1890name: weather
1891command: /opt/mcp/weather
1892args: []
1893binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1894"#;
1895 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1896 assert_eq!(
1897 entry.binary_sha256.as_deref(),
1898 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1899 );
1900 assert_eq!(entry.description_hash, None);
1901 assert_eq!(entry.publisher, None);
1902 }
1903
1904 #[test]
1907 fn publisher_minimal_just_name() {
1908 let yaml = r#"
1909name: weather
1910command: /opt/mcp/weather
1911args: []
1912publisher:
1913 name: "alice"
1914"#;
1915 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1916 let p = entry.publisher.as_ref().unwrap();
1917 assert_eq!(p.name, "alice");
1918 assert_eq!(p.homepage, None);
1919 assert_eq!(p.registry_id, None);
1920
1921 let out = serde_yaml_ng::to_string(&entry).unwrap();
1923 assert!(!out.contains("homepage:"), "got {out}");
1924 assert!(!out.contains("registry_id:"), "got {out}");
1925 }
1926}
1927
1928#[cfg(test)]
1929mod voice_tests {
1930 use super::*;
1931 use std::str::FromStr;
1932
1933 #[test]
1934 fn voice_config_round_trips() {
1935 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1937 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1938
1939 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1940 assert!(profile.voice.enabled);
1941 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1942
1943 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1945 assert!(!legacy.voice.enabled);
1946 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1947 }
1948
1949 #[test]
1950 fn voice_id_from_str_roundtrips() {
1951 let cases = [
1952 ("af_heart", VoiceId::AfHeart),
1953 ("af_bella", VoiceId::AfBella),
1954 ("af_nicole", VoiceId::AfNicole),
1955 ("am_adam", VoiceId::AmAdam),
1956 ("am_michael", VoiceId::AmMichael),
1957 ];
1958 for (s, expected) in cases {
1959 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1960 assert_eq!(expected.as_str(), s);
1961 }
1962 }
1963
1964 #[test]
1965 fn voice_id_from_str_rejects_unknown() {
1966 assert!(VoiceId::from_str("bogus").is_err());
1967 }
1968}
1969
1970#[cfg(test)]
1971mod idle_trigger_tests {
1972 use super::*;
1973
1974 #[test]
1975 fn idle_trigger_yaml_round_trip() {
1976 let yaml = r#"
1977restart: on_failure
1978idle_triggers:
1979 - after_secs: 3600
1980 message: "still there?"
1981 sends_to: other_agent
1982 cooldown_secs: 1800
1983 respect_quiet_hours: true
1984"#;
1985 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1986 assert_eq!(cfg.idle_triggers.len(), 1);
1987 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1988 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1989 assert_eq!(
1990 cfg.idle_triggers[0].sends_to.as_deref(),
1991 Some("other_agent")
1992 );
1993 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1994 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1995 }
1996
1997 #[test]
1998 fn idle_trigger_defaults_when_omitted() {
1999 let yaml = "restart: on_failure\n";
2000 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2001 assert!(cfg.idle_triggers.is_empty());
2002 }
2003}
2004
2005#[cfg(test)]
2006mod appearance_tests {
2007 use super::*;
2008
2009 #[test]
2010 fn appearance_default_style_preset_is_default_blob() {
2011 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
2012 }
2013
2014 #[test]
2015 fn appearance_default_behavior_is_normal() {
2016 assert_eq!(
2017 AgentAppearance::default().behavior_preset,
2018 BehaviorPreset::Normal
2019 );
2020 }
2021
2022 #[test]
2023 fn appearance_default_render_status_is_pending() {
2024 assert_eq!(
2025 AgentAppearance::default().render_status,
2026 RenderStatus::Pending
2027 );
2028 }
2029
2030 #[test]
2031 fn render_status_serde_round_trip() {
2032 let cases = [
2033 RenderStatus::Pending,
2034 RenderStatus::Rendering { done: 3, total: 12 },
2035 RenderStatus::Ready,
2036 RenderStatus::Failed {
2037 reason: "out of quota".into(),
2038 },
2039 ];
2040 for status in cases {
2041 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
2042 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
2043 assert_eq!(status, back);
2044 }
2045 }
2046
2047 #[test]
2048 fn agent_profile_with_appearance_round_trips() {
2049 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2050 let yaml = format!(
2051 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
2052 );
2053 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2054 assert_eq!(profile.appearance.style_preset, "chiikawa");
2055 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2056
2057 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2058 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2059 assert_eq!(profile.appearance, back.appearance);
2060 }
2061
2062 #[test]
2063 fn legacy_profile_without_appearance_uses_default() {
2064 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2065 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2066 assert_eq!(profile.appearance.style_preset, "default-blob");
2067 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2068 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2069 }
2070
2071 #[test]
2072 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2073 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2074 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2075 assert!(p.file_actions.is_empty());
2076 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2077 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2078 }
2079}
2080
2081#[cfg(test)]
2082mod federation_tests {
2083 use super::*;
2084
2085 #[test]
2086 fn test_pattern_filter_default() {
2087 let f = PatternFilter::default();
2088 assert_eq!(f.max_count, 200);
2089 assert_eq!(f.importance_min, 0.0);
2090 assert!(f.tier.is_empty());
2091 }
2092
2093 #[test]
2094 fn test_federation_config_roundtrip() {
2095 let cfg = FederationConfig {
2096 filter: PatternFilter {
2097 tier: vec!["core".into()],
2098 max_count: 50,
2099 ..Default::default()
2100 },
2101 snapshot_ref: Some(SnapshotRef {
2102 knowledge_commit: "abc123def456".into(),
2103 taken_at: "2026-05-19T00:00:00Z".into(),
2104 filter: PatternFilter::default(),
2105 }),
2106 evidence_flush_interval_minutes: 15,
2107 };
2108 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2109 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2110 assert_eq!(cfg, back);
2111 }
2112
2113 #[test]
2114 fn test_agent_profile_federation_defaults() {
2115 let cfg = FederationConfig::default();
2119 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2120 assert!(cfg.snapshot_ref.is_none());
2121 }
2122}
2123
2124#[cfg(test)]
2125mod skill_card_tests {
2126 use super::*;
2127
2128 #[test]
2129 fn installed_skills_default_to_empty_when_absent() {
2130 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2131 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2132 assert!(p.installed_skills.is_empty());
2133 }
2134
2135 #[test]
2136 fn installed_skills_roundtrip_preserves_entries() {
2137 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2138 let yaml = format!(
2139 "{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"
2140 );
2141 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2142 assert_eq!(p.installed_skills.len(), 1);
2143 assert_eq!(p.installed_skills[0].name, "s1");
2144 assert_eq!(p.installed_skills[0].abstract_text, "does things");
2145 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2146
2147 let out = serde_yaml_ng::to_string(&p).unwrap();
2148 assert!(out.contains("abstract: does things"));
2149 assert!(out.contains("pattern: /find"));
2150
2151 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2152 assert_eq!(p.installed_skills, back.installed_skills);
2153 }
2154
2155 #[test]
2156 fn installed_skills_minimal_entry_serializes_compactly() {
2157 let entry = SkillCardEntry {
2159 name: "minimal".into(),
2160 ..Default::default()
2161 };
2162 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2163 assert!(yaml.contains("name: minimal"));
2164 assert!(
2165 !yaml.contains("version:"),
2166 "empty version must be skipped: {yaml}"
2167 );
2168 assert!(
2169 !yaml.contains("publisher:"),
2170 "empty publisher must be skipped: {yaml}"
2171 );
2172 assert!(
2173 !yaml.contains("abstract:"),
2174 "empty abstract must be skipped: {yaml}"
2175 );
2176 }
2177}
2178
2179#[cfg(test)]
2180mod tool_policy_tests {
2181 use super::*;
2182
2183 fn rules() -> Vec<ToolRule> {
2184 vec![
2185 ToolRule {
2186 pattern: "mcp__github__merge_pr".into(),
2187 policy: ToolPolicy::Ask,
2188 risk: None,
2189 },
2190 ToolRule {
2191 pattern: "mcp__github__*".into(),
2192 policy: ToolPolicy::Allow,
2193 risk: None,
2194 },
2195 ToolRule {
2196 pattern: "mcp__*".into(),
2197 policy: ToolPolicy::Deny,
2198 risk: None,
2199 },
2200 ToolRule {
2201 pattern: "bash".into(),
2202 policy: ToolPolicy::Allow,
2203 risk: None,
2204 },
2205 ]
2206 }
2207
2208 #[test]
2209 fn exact_beats_glob() {
2210 assert_eq!(
2211 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2212 ToolPolicy::Ask
2213 );
2214 }
2215
2216 #[test]
2217 fn longer_glob_wins() {
2218 assert_eq!(
2219 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2220 ToolPolicy::Allow
2221 );
2222 }
2223
2224 #[test]
2225 fn shorter_glob_fallback() {
2226 assert_eq!(
2227 resolve_tool_policy(&rules(), "mcp__slack__send"),
2228 ToolPolicy::Deny
2229 );
2230 }
2231
2232 #[test]
2233 fn exact_bash() {
2234 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2235 }
2236
2237 #[test]
2238 fn unknown_tool_defaults_ask() {
2239 assert_eq!(
2240 resolve_tool_policy(&rules(), "unknown_tool"),
2241 ToolPolicy::Ask
2242 );
2243 }
2244
2245 #[test]
2246 fn empty_rules_defaults_ask() {
2247 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2248 }
2249
2250 fn minimal_entitlements_yaml() -> &'static str {
2251 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2252 }
2253
2254 #[test]
2255 fn entitlements_tools_defaults_empty() {
2256 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2257 assert!(e.tools.is_empty());
2258 }
2259
2260 #[test]
2261 fn entitlements_tools_roundtrip() {
2262 let base = minimal_entitlements_yaml();
2263 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2264 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2265 assert_eq!(e.tools.len(), 1);
2266 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2267 let y = serde_yaml_ng::to_string(&e).unwrap();
2268 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2269 assert_eq!(back.tools.len(), 1);
2270 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2271 }
2272 #[test]
2273 fn denylist_membership_and_mutation() {
2274 let mut list: Vec<String> = vec![];
2275 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2276
2277 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2279 assert_eq!(list, ["a"]);
2280
2281 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2283
2284 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2286 assert!(list.is_empty());
2287
2288 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2290 }
2291
2292 #[test]
2293 fn addon_group_rule_truth_table() {
2294 let mut p = AgentProfile::default_for_tests();
2295 p.addons.push(AddonRef {
2296 id: "grp".into(),
2297 source: "claude-local:grp@1.0.0".into(),
2298 enabled: false,
2299 skills: vec!["g_skill".into()],
2300 mcp: vec!["g_mcp".into()],
2301 commands: vec!["g_cmd".into()],
2302 content_hash: None,
2303 fetch_ref: None,
2304 fetch_plugin: None,
2305 });
2306
2307 assert!(p.skill_enabled("standalone"));
2309 assert!(p.mcp_enabled("standalone_mcp"));
2310
2311 assert!(!p.skill_enabled("g_skill"));
2313 assert!(!p.mcp_enabled("g_mcp"));
2314
2315 assert!(p.set_addon_enabled("grp", true));
2317 assert!(p.skill_enabled("g_skill"));
2318 assert!(p.mcp_enabled("g_mcp"));
2319
2320 p.set_skill_enabled("g_skill", false);
2322 assert!(!p.skill_enabled("g_skill"));
2323
2324 assert!(!p.set_addon_enabled("nope", true));
2326
2327 p.disable_all_addons();
2329 assert!(p.addons.iter().all(|g| !g.enabled));
2330 assert!(!p.skill_enabled("g_skill"));
2331 assert!(!p.skill_enabled("g_cmd"));
2332 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2338 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);
2344 assert!(p.skill_enabled("g_skill"));
2345 }
2346
2347 #[test]
2348 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2349 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2351 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2352 assert_eq!(r.content_hash, None);
2353 assert_eq!(r.fetch_ref, None);
2354
2355 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2357 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2358 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2359 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2360 let back = serde_yaml_ng::to_string(&r2).unwrap();
2361 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2362 assert_eq!(r2, r3);
2363 }
2364}
2365
2366#[cfg(test)]
2367mod lockfile_compat_tests {
2368 use super::*;
2369
2370 #[test]
2371 fn lockfile_new_fields_default_for_old_locks() {
2372 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2375 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2376 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2377 let lock: LockFile = serde_json::from_str(old).unwrap();
2378 assert_eq!(lock.build_sha, "");
2379 assert_eq!(lock.proto_version, 0);
2380 }
2381}
2382
2383#[cfg(test)]
2384mod remote_mcp_tests {
2385 use super::*;
2386
2387 #[test]
2388 fn mcp_entry_roundtrips_remote_bearer() {
2389 let e = McpServerEntry {
2390 name: "gh".into(),
2391 command: String::new(),
2392 url: Some("https://api.example.com/mcp".into()),
2393 auth: Some(McpAuth::Bearer {
2394 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2395 }),
2396 ..Default::default()
2397 };
2398 let y = serde_yaml_ng::to_string(&e).unwrap();
2399 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2400 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2401 assert!(matches!(
2402 back.auth,
2403 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2404 ));
2405 let legacy: McpServerEntry =
2407 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2408 assert!(legacy.url.is_none());
2409 assert!(legacy.auth.is_none());
2410 }
2411}
2412
2413#[cfg(test)]
2414mod requires_programs_tests {
2415 #[test]
2416 fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2417 let with = r#"
2418name: research-gateway
2419command: mur-research-gateway
2420requires_programs:
2421 - name: lightpanda
2422 detect: { file: "~/.mur/aura/lightpanda" }
2423 reason: "render tier"
2424 registry: lightpanda
2425"#;
2426 let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2427 assert_eq!(e.requires_programs.len(), 1);
2428 assert_eq!(e.requires_programs[0].name, "lightpanda");
2429
2430 let without = "name: x\ncommand: y\n";
2432 let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2433 assert!(e2.requires_programs.is_empty());
2434 }
2435}