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 #[serde(default)]
791 pub allowed_dirs: Vec<String>,
792}
793
794#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
795#[serde(rename_all = "lowercase")]
796pub enum SpawnMode {
797 Allowlist,
798 Any,
799 None,
800 Strict,
806}
807
808#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
809pub struct SyscallsEntitlement {
810 #[serde(default = "default_syscalls_mode")]
811 pub mode: String,
812 #[serde(default)]
813 pub extra_deny: Vec<String>,
814}
815fn default_syscalls_mode() -> String {
816 "default".to_string()
817}
818
819#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
820pub struct LimitsEntitlement {
821 #[serde(default)]
822 pub cpu_seconds: Option<u64>,
823 #[serde(default = "default_memory_mb")]
824 pub memory_mb: u64,
825 #[serde(default = "default_fds")]
826 pub file_descriptors: u32,
827 #[serde(default = "default_procs")]
828 pub processes: u32,
829}
830fn default_memory_mb() -> u64 {
831 512
832}
833fn default_fds() -> u32 {
834 1024
835}
836fn default_procs() -> u32 {
837 32
838}
839
840#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
841#[serde(rename_all = "lowercase")]
842pub enum ToolPolicy {
843 Allow,
844 #[default]
845 Ask,
846 Deny,
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
850pub struct ToolRule {
851 pub pattern: String,
852 pub policy: ToolPolicy,
853 #[serde(default, skip_serializing_if = "Option::is_none")]
856 pub risk: Option<crate::hitl::RiskTier>,
857}
858
859pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
863 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
864}
865
866pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
871 for rule in rules {
872 if rule.pattern == tool_name {
873 return Some(rule.policy);
874 }
875 }
876 let mut best: Option<(&ToolRule, usize)> = None;
877 for rule in rules {
878 if let Some(prefix) = rule.pattern.strip_suffix('*')
879 && tool_name.starts_with(prefix)
880 {
881 let len = prefix.len();
882 if best.is_none_or(|(_, best_len)| len > best_len) {
883 best = Some((rule, len));
884 }
885 }
886 }
887 best.map(|(rule, _)| rule.policy)
888}
889
890#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
891pub struct NotificationsConfig {
892 #[serde(default)]
893 pub on_task_complete: Vec<NotificationTarget>,
894 #[serde(default)]
895 pub on_error: Vec<NotificationTarget>,
896 #[serde(default)]
897 pub on_shutdown: Vec<NotificationTarget>,
898}
899
900#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
901#[serde(tag = "target", rename_all = "lowercase")]
902pub enum NotificationTarget {
903 Agent {
904 name: String,
905 },
906 Commander,
907 Email {
908 address: String,
909 #[serde(default)]
910 smtp_config_file: Option<String>,
911 },
912 Slack {
913 #[serde(default)]
914 channel: Option<String>,
915 #[serde(default)]
916 webhook_url_env: Option<String>,
917 },
918 Webpush {
919 url: String,
920 },
921 Webhook {
922 url: String,
923 #[serde(default = "default_post")]
924 method: String,
925 #[serde(default)]
926 auth: Option<String>,
927 },
928}
929fn default_post() -> String {
930 "POST".to_string()
931}
932
933#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
934pub struct RetryConfig {
935 pub llm: RetryPolicy,
936 pub tool: RetryPolicy,
937}
938
939#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
940pub struct RetryPolicy {
941 pub max_retries: u32,
942 pub backoff: BackoffStrategy,
943 pub initial_delay_ms: u64,
944 #[serde(default)]
945 pub max_delay_ms: Option<u64>,
946 #[serde(default)]
947 pub retry_on: Vec<String>,
948}
949
950#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
951#[serde(rename_all = "lowercase")]
952pub enum BackoffStrategy {
953 Linear,
954 Exponential,
955 Fixed,
956}
957
958#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
959pub struct LifecycleConfig {
960 pub restart: RestartPolicy,
961 #[serde(default = "default_max_restarts")]
962 pub max_restarts: u32,
963 #[serde(default = "default_window")]
964 pub restart_window_secs: u64,
965 #[serde(default = "default_stop_timeout")]
966 pub stop_timeout_secs: u64,
967 #[serde(default = "default_mcp_required")]
968 pub mcp_required: bool,
969 #[serde(default)]
970 pub execution: ExecutionMode,
971 #[serde(default)]
972 pub schedule: Vec<ScheduleEntry>,
973 #[serde(default)]
974 pub idle_triggers: Vec<IdleTrigger>,
975}
976fn default_max_restarts() -> u32 {
977 3
978}
979fn default_window() -> u64 {
980 600
981}
982fn default_stop_timeout() -> u64 {
983 15
984}
985fn default_mcp_required() -> bool {
986 true
987}
988
989#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
990#[serde(rename_all = "snake_case")]
991pub enum RestartPolicy {
992 Never,
993 OnFailure,
994 Always,
995}
996
997#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
998#[serde(rename_all = "snake_case")]
999pub enum ExecutionMode {
1000 #[default]
1001 Daemon,
1002 OnDemand,
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1006pub struct ScheduleEntry {
1007 pub cron: String,
1008 pub message: String,
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub sends_to: Option<String>,
1011}
1012
1013#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1014pub struct IdleTrigger {
1015 pub after_secs: u64,
1017 pub message: String,
1019 #[serde(default, skip_serializing_if = "Option::is_none")]
1021 pub sends_to: Option<String>,
1022 #[serde(default = "default_idle_cooldown")]
1025 pub cooldown_secs: u64,
1026 #[serde(default = "default_true")]
1029 pub respect_quiet_hours: bool,
1030}
1031
1032fn default_idle_cooldown() -> u64 {
1033 600
1034}
1035pub fn name_enabled(denylist: &[String], name: &str) -> bool {
1037 !denylist.iter().any(|n| n == name)
1038}
1039
1040pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
1043 if enabled {
1044 list.retain(|n| n != name);
1045 } else if !list.iter().any(|n| n == name) {
1046 list.push(name.to_string());
1047 }
1048}
1049
1050fn default_true() -> bool {
1051 true
1052}
1053
1054#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1055pub struct FileTransferConfig {
1056 #[serde(default = "default_accept_max")]
1057 pub accept_incoming_file_max_bytes: u64,
1058 #[serde(default = "default_accept_total")]
1059 pub accept_incoming_total_per_hour: u64,
1060 #[serde(default = "default_approval_threshold")]
1061 pub require_approval_above_bytes: u64,
1062 #[serde(default = "default_reject_paths")]
1063 pub reject_paths: Vec<String>,
1064 #[serde(default = "default_allowed_mime")]
1065 pub allowed_mime_types: Vec<String>,
1066}
1067
1068impl Default for FileTransferConfig {
1069 fn default() -> Self {
1070 Self {
1071 accept_incoming_file_max_bytes: default_accept_max(),
1072 accept_incoming_total_per_hour: default_accept_total(),
1073 require_approval_above_bytes: default_approval_threshold(),
1074 reject_paths: default_reject_paths(),
1075 allowed_mime_types: default_allowed_mime(),
1076 }
1077 }
1078}
1079
1080fn default_accept_max() -> u64 {
1081 10_485_760
1082}
1083fn default_accept_total() -> u64 {
1084 104_857_600
1085}
1086fn default_approval_threshold() -> u64 {
1087 10_485_760
1088}
1089fn default_reject_paths() -> Vec<String> {
1090 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
1091}
1092fn default_allowed_mime() -> Vec<String> {
1093 vec!["*".into()]
1094}
1095
1096#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1097#[serde(rename_all = "snake_case")]
1098pub enum DeploymentType {
1099 #[default]
1100 Laptop,
1101 Vm,
1102 Docker,
1103 K8s,
1104 Lambda,
1105}
1106
1107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1108pub struct DeploymentConfig {
1109 #[serde(rename = "type", default)]
1110 pub deployment_type: DeploymentType,
1111 #[serde(default, skip_serializing_if = "Option::is_none")]
1112 pub region: Option<String>,
1113 #[serde(default = "default_env")]
1114 pub environment: Option<String>,
1115}
1116
1117impl Default for DeploymentConfig {
1118 fn default() -> Self {
1119 Self {
1120 deployment_type: DeploymentType::default(),
1121 region: None,
1122 environment: default_env(),
1123 }
1124 }
1125}
1126
1127fn default_env() -> Option<String> {
1128 Some("dev".into())
1129}
1130
1131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1132pub struct LockFile {
1133 pub schema: u32,
1134 pub uuid: String,
1135 pub name: String,
1136 pub pid: u32,
1137 pub ppid: u32,
1138 pub started_at: String,
1139 pub binary_version: String,
1140 pub transports: LockTransports,
1141 pub card_digest: String,
1142 pub capabilities: Vec<String>,
1143 #[serde(default)]
1146 pub build_sha: String,
1147 #[serde(default)]
1150 pub proto_version: u32,
1151}
1152
1153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1154pub struct LockTransports {
1155 pub stdio: bool,
1156 #[serde(default)]
1157 pub unix_socket: Option<String>,
1158 #[serde(default)]
1159 pub tcp: Option<String>,
1160 #[serde(default)]
1165 pub webhook: Option<String>,
1166}
1167
1168#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1175#[serde(rename_all = "snake_case")]
1176pub enum VoiceId {
1177 #[default]
1179 AfHeart,
1180 AfBella,
1181 AfNicole,
1182 AmAdam,
1183 AmMichael,
1184}
1185
1186impl VoiceId {
1187 pub fn style_index(&self) -> usize {
1189 match self {
1190 VoiceId::AfHeart => 0,
1191 VoiceId::AfBella => 1,
1192 VoiceId::AfNicole => 2,
1193 VoiceId::AmAdam => 3,
1194 VoiceId::AmMichael => 4,
1195 }
1196 }
1197
1198 pub fn as_str(&self) -> &'static str {
1200 match self {
1201 VoiceId::AfHeart => "af_heart",
1202 VoiceId::AfBella => "af_bella",
1203 VoiceId::AfNicole => "af_nicole",
1204 VoiceId::AmAdam => "am_adam",
1205 VoiceId::AmMichael => "am_michael",
1206 }
1207 }
1208}
1209
1210impl std::str::FromStr for VoiceId {
1211 type Err = anyhow::Error;
1212
1213 fn from_str(s: &str) -> anyhow::Result<Self> {
1214 match s {
1215 "af_heart" => Ok(VoiceId::AfHeart),
1216 "af_bella" => Ok(VoiceId::AfBella),
1217 "af_nicole" => Ok(VoiceId::AfNicole),
1218 "am_adam" => Ok(VoiceId::AmAdam),
1219 "am_michael" => Ok(VoiceId::AmMichael),
1220 other => anyhow::bail!(
1221 "unknown voice ID '{other}' \
1222 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1223 ),
1224 }
1225 }
1226}
1227
1228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1231pub struct VoiceConfig {
1232 #[serde(default)]
1234 pub enabled: bool,
1235 #[serde(default)]
1237 pub voice_id: VoiceId,
1238 #[serde(default, skip_serializing_if = "Option::is_none")]
1241 pub input_device: Option<String>,
1242}
1243
1244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1249pub struct HitlConfig {
1250 #[serde(default = "default_hitl_timeout_secs")]
1251 pub timeout_secs: u32,
1252 #[serde(default)]
1256 pub max_iterations: Option<u32>,
1257 #[serde(default)]
1262 pub max_tokens: Option<u64>,
1263}
1264
1265fn default_hitl_timeout_secs() -> u32 {
1266 300
1267}
1268
1269impl Default for HitlConfig {
1270 fn default() -> Self {
1271 Self {
1272 timeout_secs: default_hitl_timeout_secs(),
1273 max_iterations: None,
1274 max_tokens: None,
1275 }
1276 }
1277}
1278
1279#[cfg(test)]
1280mod hitl_tests {
1281 use super::*;
1282
1283 #[test]
1284 fn hitl_config_default_max_iterations_is_none() {
1285 let cfg = HitlConfig::default();
1286 assert!(cfg.max_iterations.is_none());
1287 }
1288
1289 #[test]
1290 fn hitl_config_max_iterations_explicit() {
1291 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1292 assert_eq!(cfg.max_iterations, Some(5));
1293 }
1294
1295 #[test]
1296 fn hitl_config_default_max_tokens_is_none() {
1297 let cfg = HitlConfig::default();
1298 assert!(cfg.max_tokens.is_none());
1299 }
1300
1301 #[test]
1302 fn hitl_config_max_tokens_explicit() {
1303 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1304 assert_eq!(cfg.max_tokens, Some(250_000));
1305 }
1306}
1307
1308#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1314pub struct CompanionConfig {
1315 #[serde(default)]
1316 pub enabled: bool,
1317 #[serde(default = "default_locale")]
1318 pub locale: String,
1319 #[serde(default)]
1320 pub relationship: Relationship,
1321 #[serde(default)]
1322 pub voice_overrides: VoiceOverrides,
1323 #[serde(default)]
1324 pub onboarding: OnboardingState,
1325 #[serde(default)]
1326 pub rhythm: RhythmConfig,
1327 #[serde(default)]
1328 pub proactive: ProactiveConfig,
1329}
1330
1331pub fn default_locale() -> String {
1340 sys_locale::get_locale()
1341 .filter(|l| !l.is_empty())
1342 .or_else(|| std::env::var("LANG").ok().and_then(|v| normalize_lang(&v)))
1343 .unwrap_or_else(|| "en-US".into())
1344}
1345
1346fn normalize_lang(v: &str) -> Option<String> {
1348 v.split('.')
1349 .next()
1350 .map(|s| s.replace('_', "-"))
1351 .filter(|s| !s.is_empty())
1352}
1353
1354#[cfg(test)]
1355mod locale_tests {
1356 use super::normalize_lang;
1357
1358 #[test]
1359 fn lang_with_encoding_and_region_normalizes() {
1360 assert_eq!(normalize_lang("zh_TW.UTF-8").as_deref(), Some("zh-TW"));
1361 }
1362
1363 #[test]
1364 fn lang_without_encoding_normalizes() {
1365 assert_eq!(normalize_lang("en_US").as_deref(), Some("en-US"));
1366 }
1367
1368 #[test]
1369 fn lang_with_script_keeps_script() {
1370 assert_eq!(
1371 normalize_lang("zh_Hant_TW.UTF-8").as_deref(),
1372 Some("zh-Hant-TW")
1373 );
1374 }
1375
1376 #[test]
1377 fn empty_lang_yields_none() {
1378 assert_eq!(normalize_lang(""), None);
1379 }
1380}
1381
1382#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1383pub struct VoiceOverrides {
1384 #[serde(default, skip_serializing_if = "Option::is_none")]
1385 pub name_for_user: Option<String>,
1386 #[serde(default, skip_serializing_if = "Option::is_none")]
1387 pub formality: Option<Formality>,
1388 #[serde(default, skip_serializing_if = "Option::is_none")]
1389 pub extra_instructions: Option<String>,
1390}
1391
1392#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1393pub struct FirstMemory {
1394 pub text: String,
1395 pub established_at: chrono::DateTime<chrono::Utc>,
1396}
1397
1398#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1399pub struct OnboardingState {
1400 #[serde(default, skip_serializing_if = "Option::is_none")]
1401 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1402 #[serde(default)]
1403 pub version: u32,
1404 #[serde(default, skip_serializing_if = "Option::is_none")]
1405 pub agent_display_name: Option<String>,
1406 #[serde(default, skip_serializing_if = "Option::is_none")]
1407 pub first_memory: Option<FirstMemory>,
1408}
1409
1410#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1413pub struct RhythmConfig {
1414 #[serde(default)]
1415 pub enabled: bool,
1416}
1417
1418#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1419pub struct ProactiveConfig {
1420 #[serde(default)]
1421 pub enabled: bool,
1422 #[serde(default, skip_serializing_if = "Option::is_none")]
1424 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1425 #[serde(default, skip_serializing_if = "Option::is_none")]
1426 pub quiet_hours: Option<QuietHours>,
1427 #[serde(default, skip_serializing_if = "Option::is_none")]
1428 pub active_hours: Option<ActiveHours>,
1429 #[serde(default = "default_daily_cap")]
1430 pub daily_cap: u8,
1431 #[serde(default = "default_channels")]
1432 pub channels: Vec<String>,
1433 #[serde(default, skip_serializing_if = "Option::is_none")]
1434 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1435}
1436
1437impl Default for ProactiveConfig {
1438 fn default() -> Self {
1439 Self {
1440 enabled: false,
1441 learning_until: None,
1442 quiet_hours: None,
1443 active_hours: None,
1444 daily_cap: default_daily_cap(),
1445 channels: default_channels(),
1446 paused_until: None,
1447 }
1448 }
1449}
1450
1451fn default_daily_cap() -> u8 {
1452 3
1453}
1454fn default_channels() -> Vec<String> {
1455 vec!["stdout".into()]
1456}
1457
1458#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1459pub struct QuietHours {
1460 pub start: String,
1461 pub end: String,
1462}
1463
1464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1465pub struct ActiveHours {
1466 pub start: String,
1467 pub end: String,
1468}
1469
1470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1475pub struct AgentAppearance {
1476 #[serde(default = "default_style_preset")]
1478 pub style_preset: String,
1479 #[serde(default)]
1480 pub behavior_preset: BehaviorPreset,
1481 #[serde(default, skip_serializing_if = "Option::is_none")]
1483 pub source_image_path: Option<std::path::PathBuf>,
1484 #[serde(default = "default_expressions_dir")]
1486 pub expressions_dir: std::path::PathBuf,
1487 #[serde(default, skip_serializing_if = "Option::is_none")]
1488 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1489 #[serde(default)]
1490 pub render_status: RenderStatus,
1491}
1492
1493fn default_style_preset() -> String {
1494 "default-blob".into()
1495}
1496
1497fn default_expressions_dir() -> std::path::PathBuf {
1498 std::path::PathBuf::from("expressions")
1499}
1500
1501impl Default for AgentAppearance {
1502 fn default() -> Self {
1503 Self {
1504 style_preset: default_style_preset(),
1505 behavior_preset: BehaviorPreset::Normal,
1506 source_image_path: None,
1507 expressions_dir: default_expressions_dir(),
1508 last_rendered_at: None,
1509 render_status: RenderStatus::Pending,
1510 }
1511 }
1512}
1513
1514#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1515#[serde(rename_all = "snake_case")]
1516pub enum BehaviorPreset {
1517 Quiet,
1518 #[default]
1519 Normal,
1520 Lively,
1521}
1522
1523#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1524#[serde(tag = "status", rename_all = "snake_case")]
1525pub enum RenderStatus {
1526 #[default]
1527 Pending,
1528 Rendering {
1529 done: u8,
1530 total: u8,
1531 },
1532 Ready,
1533 Failed {
1534 reason: String,
1535 },
1536}
1537
1538#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1544#[serde(rename_all = "kebab-case")]
1545pub enum SnapshotPolicy {
1546 #[default]
1547 PullOnStart,
1548 PullPeriodic,
1549 Manual,
1550}
1551
1552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1554pub struct PatternFilter {
1555 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1556 pub applies_in: Vec<String>,
1557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1558 pub tier: Vec<String>,
1559 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1560 pub maturity: Vec<String>,
1561 #[serde(default)]
1562 pub importance_min: f64,
1563 #[serde(default = "default_max_snapshot_count")]
1564 pub max_count: usize,
1565 #[serde(default)]
1566 pub snapshot_policy: SnapshotPolicy,
1567}
1568
1569fn default_max_snapshot_count() -> usize {
1570 200
1571}
1572
1573impl Default for PatternFilter {
1574 fn default() -> Self {
1575 Self {
1576 applies_in: vec![],
1577 tier: vec![],
1578 maturity: vec![],
1579 importance_min: 0.0,
1580 max_count: 200,
1581 snapshot_policy: SnapshotPolicy::default(),
1582 }
1583 }
1584}
1585
1586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1588pub struct SnapshotRef {
1589 pub knowledge_commit: String,
1590 pub taken_at: String,
1591 pub filter: PatternFilter,
1592}
1593
1594#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1596pub struct FederationConfig {
1597 #[serde(default)]
1598 pub filter: PatternFilter,
1599 #[serde(default, skip_serializing_if = "Option::is_none")]
1600 pub snapshot_ref: Option<SnapshotRef>,
1601 #[serde(default)]
1602 pub evidence_flush_interval_minutes: u32,
1603}
1604
1605impl AgentProfile {
1606 #[doc(hidden)]
1612 pub fn default_for_tests() -> Self {
1613 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1614 .expect("minimal profile fixture")
1615 }
1616
1617 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1625 let path = mur_home.join("agents").join(name).join("profile.yaml");
1626 let yaml = std::fs::read_to_string(&path)
1627 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1628 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1629 }
1630
1631 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1633 self.addons.iter().find(|g| {
1634 g.skills.iter().any(|n| n == name)
1635 || g.mcp.iter().any(|n| n == name)
1636 || g.commands.iter().any(|n| n == name)
1637 })
1638 }
1639
1640 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1643 name_enabled(&self.disabled_skills, skill_name)
1644 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1645 }
1646
1647 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1649 name_enabled(&self.disabled_mcp, server_id)
1650 && self.group_of(server_id).is_none_or(|g| g.enabled)
1651 }
1652
1653 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1655 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1656 }
1657
1658 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1660 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1661 }
1662
1663 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1666 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1667 Some(g) => {
1668 g.enabled = enabled;
1669 true
1670 }
1671 None => false,
1672 }
1673 }
1674
1675 pub fn disable_all_addons(&mut self) {
1681 for g in &mut self.addons {
1682 g.enabled = false;
1683 }
1684 }
1685
1686 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1688 self.mcp_servers
1689 .iter()
1690 .filter(|m| self.mcp_enabled(&m.name))
1691 .cloned()
1692 .collect()
1693 }
1694}
1695
1696#[cfg(test)]
1697mod tests {
1698 use super::*;
1699
1700 #[test]
1701 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1702 let net = McpServerNetwork {
1703 mode: McpNetMode::BroadAudited,
1704 allow_hosts: vec![],
1705 deny_hosts: vec!["evil.example".into()],
1706 authorization: Some(EgressAuthorization {
1707 authorized_by: "david".into(),
1708 authorized_at_ms: 1_750_000_000_000,
1709 }),
1710 };
1711 let y = serde_yaml::to_string(&net).unwrap();
1712 assert!(y.contains("broad_audited"));
1713 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1714 assert_eq!(back, net);
1715 let legacy: McpServerNetwork =
1717 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1718 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1719 assert!(legacy.authorization.is_none());
1720 }
1721
1722 #[test]
1723 fn mcp_entry_network_is_optional_and_round_trips() {
1724 let bare = "name: x\ncommand: npx\n";
1726 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1727 assert!(e.network.is_none());
1728
1729 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1731 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1732 let net = e2.network.expect("network present");
1733 assert_eq!(net.mode, McpNetMode::Restricted);
1734 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1735
1736 let out = serde_yaml_ng::to_string(&e).unwrap();
1738 assert!(!out.contains("network"));
1739 }
1740
1741 #[test]
1742 fn profile_round_trip_yaml() {
1743 let yaml = r#"
1744schema: 1
1745id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1746name: agent_a
1747display_name: "Price Hunter"
1748version: "0.1.0"
1749persona:
1750 category: research
1751 description: "Finds prices"
1752 traits: { tone: concise, risk: cautious, verbosity: low }
1753sys_prompt_file: "sys_prompt.md"
1754model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1755mcp_servers: []
1756skills: []
1757transport:
1758 stdio: true
1759 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1760communication: { accepts_from: ["*"], sends_to: [] }
1761capabilities: ["a2a.message.send", "a2a.tasks"]
1762entitlements:
1763 network:
1764 inbound: { ports: [] }
1765 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1766 filesystem: { read: [], write: [], deny: [] }
1767 processes: { spawn: { mode: allowlist, allowed: [] } }
1768 syscalls: { mode: default }
1769 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1770notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1771retry:
1772 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1773 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1774lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1775created_at: "2026-04-22T10:00:00+08:00"
1776updated_at: "2026-04-22T10:00:00+08:00"
1777"#;
1778 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1779 assert_eq!(profile.name, "agent_a");
1780 assert_eq!(profile.persona.category, PersonaCategory::Research);
1781 assert_eq!(
1782 profile.entitlements.network.outbound.mode,
1783 NetworkOutboundMode::Restricted
1784 );
1785 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1786 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1787 assert_eq!(profile.id, round_tripped.id);
1788 }
1789
1790 #[test]
1791 fn requires_capabilities_defaults_empty_and_round_trips() {
1792 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1793 let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
1794 assert!(p.requires_capabilities.is_empty());
1795 let with = format!("{base}\nrequires_capabilities:\n - media\n");
1796 let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
1797 assert_eq!(p2.requires_capabilities, vec!["media"]);
1798 }
1799}
1800
1801#[cfg(test)]
1802mod model_ref_tests {
1803 use super::*;
1804
1805 #[test]
1806 fn legacy_profile_without_model_ref_still_parses() {
1807 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1808 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1809 assert!(
1810 p.model_ref.is_none(),
1811 "legacy profile must not have model_ref"
1812 );
1813 }
1814
1815 #[test]
1816 fn round_trip_with_model_ref_preserves_field() {
1817 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1818 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1819 p.model_ref = Some("anthropic_opus_4_7".into());
1820 let s = serde_yaml_ng::to_string(&p).unwrap();
1821 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1822 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1823 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1824 }
1825
1826 #[test]
1827 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
1828 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1830 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1831 assert!(
1832 p.fallback_chain.is_empty(),
1833 "legacy profile must have empty fallback_chain"
1834 );
1835 assert!(
1836 p.routing.is_none(),
1837 "legacy profile must have no routing override"
1838 );
1839
1840 let mut p = p.clone();
1842 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
1843 p.routing = Some(crate::config::RoutingConfig {
1844 enabled: true,
1845 ..Default::default()
1846 });
1847 let s = serde_yaml_ng::to_string(&p).unwrap();
1848 assert!(
1849 s.contains("fallback_chain:"),
1850 "yaml must contain fallback_chain"
1851 );
1852 assert!(s.contains("routing:"), "yaml must contain routing");
1853 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1854 assert_eq!(
1855 p2.fallback_chain,
1856 vec!["claude_opus", "claude_sonnet"],
1857 "fallback_chain must round-trip"
1858 );
1859 assert!(
1860 p2.routing.as_ref().unwrap().enabled,
1861 "routing.enabled must round-trip"
1862 );
1863 }
1864}
1865
1866#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1874#[serde(rename_all = "snake_case")]
1875pub enum ProactiveTier {
1876 Off,
1877 WarmOnly,
1878 WarmAndBehavior,
1879 All,
1880}
1881
1882impl ProactiveTier {
1883 pub fn from_config(c: &CompanionConfig) -> Self {
1884 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1885 (false, _, _) => Self::Off,
1886 (true, false, false) => Self::WarmOnly,
1887 (true, true, false) => Self::WarmAndBehavior,
1888 (true, _, true) => Self::All,
1889 }
1890 }
1891
1892 pub fn apply(&self, c: &mut CompanionConfig) {
1893 match self {
1894 Self::Off => {
1895 c.enabled = false;
1896 c.rhythm.enabled = false;
1897 c.proactive.enabled = false;
1898 }
1899 Self::WarmOnly => {
1900 c.enabled = true;
1901 c.rhythm.enabled = false;
1902 c.proactive.enabled = false;
1903 }
1904 Self::WarmAndBehavior => {
1905 c.enabled = true;
1906 c.rhythm.enabled = true;
1907 c.proactive.enabled = false;
1908 }
1909 Self::All => {
1910 c.enabled = true;
1911 c.rhythm.enabled = true;
1912 c.proactive.enabled = true;
1913 }
1914 }
1915 }
1916}
1917
1918#[cfg(test)]
1919mod mcp_pin_tests {
1920 use super::*;
1921
1922 #[test]
1926 fn pre_m9_entry_roundtrips_without_pin_fields() {
1927 let yaml = r#"
1928name: weather
1929command: /opt/mcp/weather
1930args: ["--port", "0"]
1931"#;
1932 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1933 assert_eq!(entry.name, "weather");
1934 assert_eq!(entry.binary_sha256, None);
1935 assert_eq!(entry.description_hash, None);
1936 assert_eq!(entry.publisher, None);
1937 assert_eq!(entry.installed_at, None);
1938
1939 let out = serde_yaml_ng::to_string(&entry).unwrap();
1942 assert!(!out.contains("binary_sha256"), "got {out}");
1943 assert!(!out.contains("description_hash"), "got {out}");
1944 assert!(!out.contains("publisher"), "got {out}");
1945 assert!(!out.contains("installed_at"), "got {out}");
1946 }
1947
1948 #[test]
1950 fn full_m9_entry_roundtrips_all_fields() {
1951 let yaml = r#"
1952name: weather
1953command: /opt/mcp/weather
1954args: []
1955binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1956description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1957publisher:
1958 name: "@anthropic-mcp/weather"
1959 homepage: "https://github.com/anthropic-mcp/weather"
1960 registry_id: "@anthropic-mcp/weather@1.2.3"
1961installed_at: "2026-05-06T08:00:00Z"
1962"#;
1963 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1964 assert!(
1965 entry
1966 .binary_sha256
1967 .as_deref()
1968 .unwrap()
1969 .starts_with("3f4abca8")
1970 );
1971 assert!(
1972 entry
1973 .description_hash
1974 .as_deref()
1975 .unwrap()
1976 .starts_with("9a01b2c3")
1977 );
1978 let pub_info = entry.publisher.clone().unwrap();
1979 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1980 assert_eq!(
1981 pub_info.homepage.as_deref(),
1982 Some("https://github.com/anthropic-mcp/weather"),
1983 );
1984 assert_eq!(
1985 pub_info.registry_id.as_deref(),
1986 Some("@anthropic-mcp/weather@1.2.3"),
1987 );
1988 let installed = entry.installed_at.unwrap();
1989 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1990 }
1991
1992 #[test]
1996 fn partial_pin_only_binary_sha_roundtrips() {
1997 let yaml = r#"
1998name: weather
1999command: /opt/mcp/weather
2000args: []
2001binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
2002"#;
2003 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2004 assert_eq!(
2005 entry.binary_sha256.as_deref(),
2006 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
2007 );
2008 assert_eq!(entry.description_hash, None);
2009 assert_eq!(entry.publisher, None);
2010 }
2011
2012 #[test]
2015 fn publisher_minimal_just_name() {
2016 let yaml = r#"
2017name: weather
2018command: /opt/mcp/weather
2019args: []
2020publisher:
2021 name: "alice"
2022"#;
2023 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2024 let p = entry.publisher.as_ref().unwrap();
2025 assert_eq!(p.name, "alice");
2026 assert_eq!(p.homepage, None);
2027 assert_eq!(p.registry_id, None);
2028
2029 let out = serde_yaml_ng::to_string(&entry).unwrap();
2031 assert!(!out.contains("homepage:"), "got {out}");
2032 assert!(!out.contains("registry_id:"), "got {out}");
2033 }
2034}
2035
2036#[cfg(test)]
2037mod voice_tests {
2038 use super::*;
2039 use std::str::FromStr;
2040
2041 #[test]
2042 fn voice_config_round_trips() {
2043 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2045 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
2046
2047 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
2048 assert!(profile.voice.enabled);
2049 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
2050
2051 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
2053 assert!(!legacy.voice.enabled);
2054 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
2055 }
2056
2057 #[test]
2058 fn voice_id_from_str_roundtrips() {
2059 let cases = [
2060 ("af_heart", VoiceId::AfHeart),
2061 ("af_bella", VoiceId::AfBella),
2062 ("af_nicole", VoiceId::AfNicole),
2063 ("am_adam", VoiceId::AmAdam),
2064 ("am_michael", VoiceId::AmMichael),
2065 ];
2066 for (s, expected) in cases {
2067 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
2068 assert_eq!(expected.as_str(), s);
2069 }
2070 }
2071
2072 #[test]
2073 fn voice_id_from_str_rejects_unknown() {
2074 assert!(VoiceId::from_str("bogus").is_err());
2075 }
2076}
2077
2078#[cfg(test)]
2079mod idle_trigger_tests {
2080 use super::*;
2081
2082 #[test]
2083 fn idle_trigger_yaml_round_trip() {
2084 let yaml = r#"
2085restart: on_failure
2086idle_triggers:
2087 - after_secs: 3600
2088 message: "still there?"
2089 sends_to: other_agent
2090 cooldown_secs: 1800
2091 respect_quiet_hours: true
2092"#;
2093 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2094 assert_eq!(cfg.idle_triggers.len(), 1);
2095 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
2096 assert_eq!(cfg.idle_triggers[0].message, "still there?");
2097 assert_eq!(
2098 cfg.idle_triggers[0].sends_to.as_deref(),
2099 Some("other_agent")
2100 );
2101 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
2102 assert!(cfg.idle_triggers[0].respect_quiet_hours);
2103 }
2104
2105 #[test]
2106 fn idle_trigger_defaults_when_omitted() {
2107 let yaml = "restart: on_failure\n";
2108 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2109 assert!(cfg.idle_triggers.is_empty());
2110 }
2111}
2112
2113#[cfg(test)]
2114mod appearance_tests {
2115 use super::*;
2116
2117 #[test]
2118 fn appearance_default_style_preset_is_default_blob() {
2119 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
2120 }
2121
2122 #[test]
2123 fn appearance_default_behavior_is_normal() {
2124 assert_eq!(
2125 AgentAppearance::default().behavior_preset,
2126 BehaviorPreset::Normal
2127 );
2128 }
2129
2130 #[test]
2131 fn appearance_default_render_status_is_pending() {
2132 assert_eq!(
2133 AgentAppearance::default().render_status,
2134 RenderStatus::Pending
2135 );
2136 }
2137
2138 #[test]
2139 fn render_status_serde_round_trip() {
2140 let cases = [
2141 RenderStatus::Pending,
2142 RenderStatus::Rendering { done: 3, total: 12 },
2143 RenderStatus::Ready,
2144 RenderStatus::Failed {
2145 reason: "out of quota".into(),
2146 },
2147 ];
2148 for status in cases {
2149 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
2150 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
2151 assert_eq!(status, back);
2152 }
2153 }
2154
2155 #[test]
2156 fn agent_profile_with_appearance_round_trips() {
2157 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2158 let yaml = format!(
2159 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
2160 );
2161 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2162 assert_eq!(profile.appearance.style_preset, "chiikawa");
2163 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2164
2165 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2166 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2167 assert_eq!(profile.appearance, back.appearance);
2168 }
2169
2170 #[test]
2171 fn legacy_profile_without_appearance_uses_default() {
2172 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2173 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2174 assert_eq!(profile.appearance.style_preset, "default-blob");
2175 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2176 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2177 }
2178
2179 #[test]
2180 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2181 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2182 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2183 assert!(p.file_actions.is_empty());
2184 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2185 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2186 }
2187}
2188
2189#[cfg(test)]
2190mod federation_tests {
2191 use super::*;
2192
2193 #[test]
2194 fn test_pattern_filter_default() {
2195 let f = PatternFilter::default();
2196 assert_eq!(f.max_count, 200);
2197 assert_eq!(f.importance_min, 0.0);
2198 assert!(f.tier.is_empty());
2199 }
2200
2201 #[test]
2202 fn test_federation_config_roundtrip() {
2203 let cfg = FederationConfig {
2204 filter: PatternFilter {
2205 tier: vec!["core".into()],
2206 max_count: 50,
2207 ..Default::default()
2208 },
2209 snapshot_ref: Some(SnapshotRef {
2210 knowledge_commit: "abc123def456".into(),
2211 taken_at: "2026-05-19T00:00:00Z".into(),
2212 filter: PatternFilter::default(),
2213 }),
2214 evidence_flush_interval_minutes: 15,
2215 };
2216 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2217 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2218 assert_eq!(cfg, back);
2219 }
2220
2221 #[test]
2222 fn test_agent_profile_federation_defaults() {
2223 let cfg = FederationConfig::default();
2227 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2228 assert!(cfg.snapshot_ref.is_none());
2229 }
2230}
2231
2232#[cfg(test)]
2233mod skill_card_tests {
2234 use super::*;
2235
2236 #[test]
2237 fn installed_skills_default_to_empty_when_absent() {
2238 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2239 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2240 assert!(p.installed_skills.is_empty());
2241 }
2242
2243 #[test]
2244 fn installed_skills_roundtrip_preserves_entries() {
2245 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2246 let yaml = format!(
2247 "{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"
2248 );
2249 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2250 assert_eq!(p.installed_skills.len(), 1);
2251 assert_eq!(p.installed_skills[0].name, "s1");
2252 assert_eq!(p.installed_skills[0].abstract_text, "does things");
2253 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2254
2255 let out = serde_yaml_ng::to_string(&p).unwrap();
2256 assert!(out.contains("abstract: does things"));
2257 assert!(out.contains("pattern: /find"));
2258
2259 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2260 assert_eq!(p.installed_skills, back.installed_skills);
2261 }
2262
2263 #[test]
2264 fn installed_skills_minimal_entry_serializes_compactly() {
2265 let entry = SkillCardEntry {
2267 name: "minimal".into(),
2268 ..Default::default()
2269 };
2270 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2271 assert!(yaml.contains("name: minimal"));
2272 assert!(
2273 !yaml.contains("version:"),
2274 "empty version must be skipped: {yaml}"
2275 );
2276 assert!(
2277 !yaml.contains("publisher:"),
2278 "empty publisher must be skipped: {yaml}"
2279 );
2280 assert!(
2281 !yaml.contains("abstract:"),
2282 "empty abstract must be skipped: {yaml}"
2283 );
2284 }
2285}
2286
2287#[cfg(test)]
2288mod tool_policy_tests {
2289 use super::*;
2290
2291 fn rules() -> Vec<ToolRule> {
2292 vec![
2293 ToolRule {
2294 pattern: "mcp__github__merge_pr".into(),
2295 policy: ToolPolicy::Ask,
2296 risk: None,
2297 },
2298 ToolRule {
2299 pattern: "mcp__github__*".into(),
2300 policy: ToolPolicy::Allow,
2301 risk: None,
2302 },
2303 ToolRule {
2304 pattern: "mcp__*".into(),
2305 policy: ToolPolicy::Deny,
2306 risk: None,
2307 },
2308 ToolRule {
2309 pattern: "bash".into(),
2310 policy: ToolPolicy::Allow,
2311 risk: None,
2312 },
2313 ]
2314 }
2315
2316 #[test]
2317 fn exact_beats_glob() {
2318 assert_eq!(
2319 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2320 ToolPolicy::Ask
2321 );
2322 }
2323
2324 #[test]
2325 fn longer_glob_wins() {
2326 assert_eq!(
2327 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2328 ToolPolicy::Allow
2329 );
2330 }
2331
2332 #[test]
2333 fn shorter_glob_fallback() {
2334 assert_eq!(
2335 resolve_tool_policy(&rules(), "mcp__slack__send"),
2336 ToolPolicy::Deny
2337 );
2338 }
2339
2340 #[test]
2341 fn exact_bash() {
2342 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2343 }
2344
2345 #[test]
2346 fn unknown_tool_defaults_ask() {
2347 assert_eq!(
2348 resolve_tool_policy(&rules(), "unknown_tool"),
2349 ToolPolicy::Ask
2350 );
2351 }
2352
2353 #[test]
2354 fn empty_rules_defaults_ask() {
2355 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2356 }
2357
2358 fn minimal_entitlements_yaml() -> &'static str {
2359 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2360 }
2361
2362 #[test]
2363 fn entitlements_tools_defaults_empty() {
2364 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2365 assert!(e.tools.is_empty());
2366 }
2367
2368 #[test]
2369 fn entitlements_tools_roundtrip() {
2370 let base = minimal_entitlements_yaml();
2371 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2372 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2373 assert_eq!(e.tools.len(), 1);
2374 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2375 let y = serde_yaml_ng::to_string(&e).unwrap();
2376 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2377 assert_eq!(back.tools.len(), 1);
2378 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2379 }
2380 #[test]
2381 fn denylist_membership_and_mutation() {
2382 let mut list: Vec<String> = vec![];
2383 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2384
2385 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2387 assert_eq!(list, ["a"]);
2388
2389 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2391
2392 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2394 assert!(list.is_empty());
2395
2396 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2398 }
2399
2400 #[test]
2401 fn addon_group_rule_truth_table() {
2402 let mut p = AgentProfile::default_for_tests();
2403 p.addons.push(AddonRef {
2404 id: "grp".into(),
2405 source: "claude-local:grp@1.0.0".into(),
2406 enabled: false,
2407 skills: vec!["g_skill".into()],
2408 mcp: vec!["g_mcp".into()],
2409 commands: vec!["g_cmd".into()],
2410 content_hash: None,
2411 fetch_ref: None,
2412 fetch_plugin: None,
2413 });
2414
2415 assert!(p.skill_enabled("standalone"));
2417 assert!(p.mcp_enabled("standalone_mcp"));
2418
2419 assert!(!p.skill_enabled("g_skill"));
2421 assert!(!p.mcp_enabled("g_mcp"));
2422
2423 assert!(p.set_addon_enabled("grp", true));
2425 assert!(p.skill_enabled("g_skill"));
2426 assert!(p.mcp_enabled("g_mcp"));
2427
2428 p.set_skill_enabled("g_skill", false);
2430 assert!(!p.skill_enabled("g_skill"));
2431
2432 assert!(!p.set_addon_enabled("nope", true));
2434
2435 p.disable_all_addons();
2437 assert!(p.addons.iter().all(|g| !g.enabled));
2438 assert!(!p.skill_enabled("g_skill"));
2439 assert!(!p.skill_enabled("g_cmd"));
2440 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2446 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);
2452 assert!(p.skill_enabled("g_skill"));
2453 }
2454
2455 #[test]
2456 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2457 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2459 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2460 assert_eq!(r.content_hash, None);
2461 assert_eq!(r.fetch_ref, None);
2462
2463 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2465 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2466 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2467 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2468 let back = serde_yaml_ng::to_string(&r2).unwrap();
2469 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2470 assert_eq!(r2, r3);
2471 }
2472}
2473
2474#[cfg(test)]
2475mod lockfile_compat_tests {
2476 use super::*;
2477
2478 #[test]
2479 fn lockfile_new_fields_default_for_old_locks() {
2480 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2483 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2484 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2485 let lock: LockFile = serde_json::from_str(old).unwrap();
2486 assert_eq!(lock.build_sha, "");
2487 assert_eq!(lock.proto_version, 0);
2488 }
2489}
2490
2491#[cfg(test)]
2492mod remote_mcp_tests {
2493 use super::*;
2494
2495 #[test]
2496 fn mcp_entry_roundtrips_remote_bearer() {
2497 let e = McpServerEntry {
2498 name: "gh".into(),
2499 command: String::new(),
2500 url: Some("https://api.example.com/mcp".into()),
2501 auth: Some(McpAuth::Bearer {
2502 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2503 }),
2504 ..Default::default()
2505 };
2506 let y = serde_yaml_ng::to_string(&e).unwrap();
2507 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2508 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2509 assert!(matches!(
2510 back.auth,
2511 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2512 ));
2513 let legacy: McpServerEntry =
2515 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2516 assert!(legacy.url.is_none());
2517 assert!(legacy.auth.is_none());
2518 }
2519}
2520
2521#[cfg(test)]
2522mod requires_programs_tests {
2523 #[test]
2524 fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2525 let with = r#"
2526name: research-gateway
2527command: mur-research-gateway
2528requires_programs:
2529 - name: lightpanda
2530 detect: { file: "~/.mur/aura/lightpanda" }
2531 reason: "render tier"
2532 registry: lightpanda
2533"#;
2534 let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2535 assert_eq!(e.requires_programs.len(), 1);
2536 assert_eq!(e.requires_programs[0].name, "lightpanda");
2537
2538 let without = "name: x\ncommand: y\n";
2540 let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2541 assert!(e2.requires_programs.is_empty());
2542 }
2543}