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