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::RoutingOverride>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub smart: Option<crate::config::SmartOverride>,
95 #[serde(default)]
96 pub mcp_servers: Vec<McpServerEntry>,
97 #[serde(default)]
98 pub skills: Vec<String>,
99 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub installed_skills: Vec<SkillCardEntry>,
104 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub disabled_skills: Vec<String>,
110
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub disabled_mcp: Vec<String>,
116
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub secrets: Vec<String>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub addons: Vec<AddonRef>,
129 pub transport: TransportConfig,
130 pub communication: CommunicationConfig,
131 #[serde(default)]
132 pub capabilities: Vec<String>,
133 pub entitlements: Entitlements,
134 #[serde(default)]
135 pub notifications: NotificationsConfig,
136 pub retry: RetryConfig,
137 pub lifecycle: LifecycleConfig,
138 #[serde(default)]
141 pub identity: IdentityConfig,
142 #[serde(default)]
143 pub file_transfer: FileTransferConfig,
144 #[serde(default)]
145 pub deployment: DeploymentConfig,
146 #[serde(default)]
149 pub companion: CompanionConfig,
150 #[serde(default)]
152 pub hitl: HitlConfig,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub limits: Option<crate::limits::Limits>,
159 #[serde(default)]
161 pub voice: VoiceConfig,
162 #[serde(default)]
164 pub hooks: crate::HooksConfig,
165 #[serde(default)]
168 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
169 pub created_at: String,
170 pub updated_at: String,
171 #[serde(default)]
173 pub appearance: AgentAppearance,
174 #[serde(default)]
176 pub federation: FederationConfig,
177
178 #[serde(default)]
182 pub file_actions: Vec<crate::action::FileAction>,
183
184 #[serde(default)]
186 pub action_pipeline: crate::action::ActionPipelineConfig,
187
188 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub requires_programs: Vec<ProgramDep>,
192
193 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 pub requires_capabilities: Vec<String>,
197}
198
199fn default_algorithm() -> String {
200 "ed25519".into()
201}
202
203pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
205
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
207pub struct IdentityConfig {
208 #[serde(default)]
211 pub pubkey: String,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub owner: Option<String>,
215
216 #[serde(default = "default_algorithm")]
219 pub algorithm: String,
220 #[serde(default)]
222 pub key_version: u32,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub created_at_key: Option<String>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub previous_pubkey: Option<String>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub previous_key_version: Option<u32>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub grace_expires_at: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub rotated_at: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub emergency_rekey_at: Option<String>,
242}
243
244impl Default for IdentityConfig {
245 fn default() -> Self {
246 Self {
247 pubkey: String::new(),
248 owner: None,
249 algorithm: default_algorithm(),
250 key_version: 0,
251 created_at_key: None,
252 previous_pubkey: None,
253 previous_key_version: None,
254 grace_expires_at: None,
255 rotated_at: None,
256 emergency_rekey_at: None,
257 }
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
262pub struct Persona {
263 pub category: PersonaCategory,
264 pub description: String,
265 pub traits: PersonaTraits,
266}
267
268#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
269#[serde(rename_all = "lowercase")]
270pub enum PersonaCategory {
271 Research,
272 Automation,
273 Monitor,
274 Notify,
275 Commerce,
276 Custom,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280pub struct PersonaTraits {
281 pub tone: String,
282 pub risk: String,
283 pub verbosity: String,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287pub struct ModelConfig {
288 pub provider: String,
289 pub name: String,
290 #[serde(default)]
291 pub params: BTreeMap<String, serde_yaml_ng::Value>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
295pub struct McpServerEntry {
296 pub name: String,
297 pub command: String,
298 #[serde(default)]
299 pub args: Vec<String>,
300
301 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub binary_sha256: Option<String>,
308
309 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub description_hash: Option<String>,
316
317 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub publisher: Option<McpPublisherInfo>,
322
323 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
328
329 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub timeout_secs: Option<u32>,
334
335 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub network: Option<McpServerNetwork>,
341
342 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub url: Option<String>,
346
347 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub auth: Option<McpAuth>,
351
352 #[serde(default, skip_serializing_if = "Vec::is_empty")]
355 pub requires_programs: Vec<ProgramDep>,
356
357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
372 pub state_paths: Vec<String>,
373
374 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub package: Option<McpPackagePin>,
383}
384
385#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
398pub struct McpPackagePin {
399 pub runner: String,
401 pub name: String,
403 pub version: String,
405 pub install_dir: String,
407 pub lockfile_sha256: String,
409
410 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub signatures_missing: Option<u32>,
423
424 #[serde(default, skip_serializing_if = "Option::is_none")]
434 pub provenance: Option<String>,
435}
436
437impl McpPackagePin {
438 pub fn lockfile_name(&self) -> &'static str {
445 match self.runner.as_str() {
446 "pypi" => "requirements.lock",
447 _ => "package-lock.json",
448 }
449 }
450
451 pub fn lockfile_path(&self) -> std::path::PathBuf {
457 std::path::Path::new(&self.install_dir).join(self.lockfile_name())
458 }
459}
460
461#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
463#[serde(rename_all = "snake_case", tag = "kind")]
464pub enum McpAuth {
465 Bearer { token: crate::secret::SecretRef },
467 Oauth(OauthAuth),
469}
470
471#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
473pub struct OauthAuth {
474 pub token_endpoint: String,
476 pub client_id: String,
478 pub access_token: crate::secret::SecretRef,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub refresh_token: Option<crate::secret::SecretRef>,
483 #[serde(default)]
485 pub expires_at: u64,
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
490#[serde(rename_all = "snake_case")]
491pub enum McpNetMode {
492 #[default]
506 Inherit,
507 Restricted,
509 BroadAudited,
515 Off,
517}
518
519pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
528
529#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
531pub struct McpServerNetwork {
532 #[serde(default)]
533 pub mode: McpNetMode,
534 #[serde(default)]
535 pub allow_hosts: Vec<String>,
536 #[serde(default)]
539 pub deny_hosts: Vec<String>,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub authorization: Option<EgressAuthorization>,
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
554pub struct AddonRef {
555 pub id: String,
557 pub source: String,
559 #[serde(default)]
560 pub enabled: bool,
561 #[serde(default, skip_serializing_if = "Vec::is_empty")]
562 pub skills: Vec<String>,
563 #[serde(default, skip_serializing_if = "Vec::is_empty")]
564 pub mcp: Vec<String>,
565 #[serde(default, skip_serializing_if = "Vec::is_empty")]
566 pub commands: Vec<String>,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub content_hash: Option<String>,
571 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub fetch_ref: Option<String>,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
581 pub fetch_plugin: Option<String>,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
590pub struct McpPublisherInfo {
591 pub name: String,
594
595 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub homepage: Option<String>,
600
601 #[serde(default, skip_serializing_if = "Option::is_none")]
604 pub registry_id: Option<String>,
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
608pub struct TransportConfig {
609 pub stdio: bool,
610 pub socket: SocketTransportConfig,
611 #[serde(default)]
612 pub tcp: TcpTransportConfig,
613 #[serde(default)]
617 pub webhook: WebhookTransportConfig,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
621pub struct TcpTransportConfig {
622 #[serde(default)]
623 pub enabled: bool,
624 #[serde(default)]
625 pub bind: String,
626 #[serde(default)]
627 pub noise: NoiseConfig,
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
644pub struct WebhookTransportConfig {
645 #[serde(default)]
646 pub enabled: bool,
647 #[serde(default = "default_webhook_bind")]
648 pub bind: String,
649 #[serde(default = "default_webhook_port")]
650 pub port: u16,
651 #[serde(default)]
655 pub hmac_secret_ref: String,
656}
657
658fn default_webhook_bind() -> String {
659 "127.0.0.1".to_string()
660}
661
662fn default_webhook_port() -> u16 {
663 6789
664}
665
666impl Default for WebhookTransportConfig {
667 fn default() -> Self {
668 Self {
669 enabled: false,
670 bind: default_webhook_bind(),
671 port: default_webhook_port(),
672 hmac_secret_ref: String::new(),
673 }
674 }
675}
676
677#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
678pub struct NoiseConfig {
679 pub pattern: String,
680}
681
682impl Default for NoiseConfig {
683 fn default() -> Self {
684 Self {
685 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
686 }
687 }
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
691pub struct SocketTransportConfig {
692 pub enabled: bool,
693 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub auth: Option<AuthConfig>,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
699pub struct AuthConfig {
700 pub scheme: String,
701 pub token_file: String,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
705pub struct CommunicationConfig {
706 #[serde(default = "default_accepts_all")]
707 pub accepts_from: Vec<String>,
708 #[serde(default)]
709 pub sends_to: Vec<String>,
710}
711fn default_accepts_all() -> Vec<String> {
712 vec!["*".to_string()]
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
716pub struct Entitlements {
717 pub network: NetworkEntitlement,
718 pub filesystem: FilesystemEntitlement,
719 pub processes: ProcessesEntitlement,
720 #[serde(default)]
721 pub syscalls: SyscallsEntitlement,
722 #[serde(default)]
723 pub limits: LimitsEntitlement,
724 #[serde(default)]
727 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
728 #[serde(default, skip_serializing_if = "Vec::is_empty")]
730 pub tools: Vec<ToolRule>,
731 #[serde(default = "default_true")]
736 pub fail_closed_on_sandbox_error: bool,
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
740pub struct NetworkEntitlement {
741 pub inbound: InboundNetwork,
742 pub outbound: OutboundNetwork,
743}
744
745#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
746pub struct InboundNetwork {
747 #[serde(default)]
748 pub ports: Vec<u16>,
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
752pub struct OutboundNetwork {
753 pub mode: NetworkOutboundMode,
754 #[serde(default)]
755 pub allow_hosts: Vec<String>,
756 #[serde(default, skip_serializing_if = "Vec::is_empty")]
771 pub allow_ports: Vec<u16>,
772 #[serde(default = "default_protocols")]
773 pub protocols: Vec<String>,
774 #[serde(default)]
775 pub resolve_dns: ResolveDnsConfig,
776}
777fn default_protocols() -> Vec<String> {
778 vec!["tcp".to_string()]
779}
780
781#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
785pub struct EgressAuthorization {
786 pub authorized_by: String,
787 pub authorized_at_ms: u64,
788}
789
790#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
791#[serde(rename_all = "lowercase")]
792pub enum NetworkOutboundMode {
793 Unrestricted,
794 Restricted,
795 ProxyOnly,
799 Off,
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
803pub struct ResolveDnsConfig {
804 #[serde(default = "default_dns_mode")]
805 pub mode: String,
806 #[serde(default)]
807 pub servers: Vec<String>,
808}
809impl Default for ResolveDnsConfig {
810 fn default() -> Self {
811 Self {
812 mode: default_dns_mode(),
813 servers: vec![],
814 }
815 }
816}
817fn default_dns_mode() -> String {
818 "system".to_string()
819}
820
821pub const AUTHORING_DIRS: [&str; 4] = ["skills", "workflows", "fleets", "artifacts"];
832
833#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
834pub struct FilesystemEntitlement {
835 #[serde(default)]
836 pub read: Vec<String>,
837 #[serde(default)]
838 pub write: Vec<String>,
839 #[serde(default)]
840 pub deny: Vec<String>,
841}
842
843#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
844pub struct ProcessesEntitlement {
845 pub spawn: SpawnEntitlement,
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
849pub struct SpawnEntitlement {
850 pub mode: SpawnMode,
851 #[serde(default)]
852 pub allowed: Vec<String>,
853 #[serde(default)]
868 pub allowed_dirs: Vec<String>,
869}
870
871#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
872#[serde(rename_all = "lowercase")]
873pub enum SpawnMode {
874 Allowlist,
875 Any,
876 None,
877 Strict,
883}
884
885#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
886pub struct SyscallsEntitlement {
887 #[serde(default = "default_syscalls_mode")]
888 pub mode: String,
889 #[serde(default)]
890 pub extra_deny: Vec<String>,
891}
892fn default_syscalls_mode() -> String {
893 "default".to_string()
894}
895
896#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
897pub struct LimitsEntitlement {
898 #[serde(default)]
899 pub cpu_seconds: Option<u64>,
900 #[serde(default = "default_memory_mb")]
901 pub memory_mb: u64,
902 #[serde(default = "default_fds")]
903 pub file_descriptors: u32,
904 #[serde(default = "default_procs")]
905 pub processes: u32,
906}
907fn default_memory_mb() -> u64 {
908 512
909}
910fn default_fds() -> u32 {
911 1024
912}
913fn default_procs() -> u32 {
914 32
915}
916
917#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
918#[serde(rename_all = "lowercase")]
919pub enum ToolPolicy {
920 Allow,
921 #[default]
922 Ask,
923 Deny,
924}
925
926#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
927pub struct ToolRule {
928 pub pattern: String,
929 pub policy: ToolPolicy,
930 #[serde(default, skip_serializing_if = "Option::is_none")]
933 pub risk: Option<crate::hitl::RiskTier>,
934}
935
936pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
940 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
941}
942
943pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
948 for rule in rules {
949 if rule.pattern == tool_name {
950 return Some(rule.policy);
951 }
952 }
953 let mut best: Option<(&ToolRule, usize)> = None;
954 for rule in rules {
955 if let Some(prefix) = rule.pattern.strip_suffix('*')
956 && tool_name.starts_with(prefix)
957 {
958 let len = prefix.len();
959 if best.is_none_or(|(_, best_len)| len > best_len) {
960 best = Some((rule, len));
961 }
962 }
963 }
964 best.map(|(rule, _)| rule.policy)
965}
966
967#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
968pub struct NotificationsConfig {
969 #[serde(default)]
970 pub on_task_complete: Vec<NotificationTarget>,
971 #[serde(default)]
972 pub on_error: Vec<NotificationTarget>,
973 #[serde(default)]
974 pub on_shutdown: Vec<NotificationTarget>,
975}
976
977#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
978#[serde(tag = "target", rename_all = "lowercase")]
979pub enum NotificationTarget {
980 Agent {
981 name: String,
982 },
983 Commander,
984 Email {
985 address: String,
986 #[serde(default)]
987 smtp_config_file: Option<String>,
988 },
989 Slack {
990 #[serde(default)]
991 channel: Option<String>,
992 #[serde(default)]
993 webhook_url_env: Option<String>,
994 },
995 Webpush {
996 url: String,
997 },
998 Webhook {
999 url: String,
1000 #[serde(default = "default_post")]
1001 method: String,
1002 #[serde(default)]
1003 auth: Option<String>,
1004 },
1005}
1006fn default_post() -> String {
1007 "POST".to_string()
1008}
1009
1010#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1011pub struct RetryConfig {
1012 pub llm: RetryPolicy,
1013 pub tool: RetryPolicy,
1014}
1015
1016#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1017pub struct RetryPolicy {
1018 pub max_retries: u32,
1019 pub backoff: BackoffStrategy,
1020 pub initial_delay_ms: u64,
1021 #[serde(default)]
1022 pub max_delay_ms: Option<u64>,
1023 #[serde(default)]
1024 pub retry_on: Vec<String>,
1025}
1026
1027#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1028#[serde(rename_all = "lowercase")]
1029pub enum BackoffStrategy {
1030 Linear,
1031 Exponential,
1032 Fixed,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1036pub struct LifecycleConfig {
1037 pub restart: RestartPolicy,
1038 #[serde(default = "default_max_restarts")]
1039 pub max_restarts: u32,
1040 #[serde(default = "default_window")]
1041 pub restart_window_secs: u64,
1042 #[serde(default = "default_stop_timeout")]
1043 pub stop_timeout_secs: u64,
1044 #[serde(default = "default_mcp_required")]
1045 pub mcp_required: bool,
1046 #[serde(default)]
1047 pub execution: ExecutionMode,
1048 #[serde(default)]
1049 pub schedule: Vec<ScheduleEntry>,
1050 #[serde(default)]
1051 pub idle_triggers: Vec<IdleTrigger>,
1052}
1053fn default_max_restarts() -> u32 {
1054 3
1055}
1056fn default_window() -> u64 {
1057 600
1058}
1059fn default_stop_timeout() -> u64 {
1060 15
1061}
1062fn default_mcp_required() -> bool {
1063 true
1064}
1065
1066#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1067#[serde(rename_all = "snake_case")]
1068pub enum RestartPolicy {
1069 Never,
1070 OnFailure,
1071 Always,
1072}
1073
1074#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1075#[serde(rename_all = "snake_case")]
1076pub enum ExecutionMode {
1077 #[default]
1078 Daemon,
1079 OnDemand,
1080}
1081
1082pub const SCHEDULE_PROPOSAL_DIR: &str = "schedule-proposals";
1097
1098pub const SCHEDULE_CHANNEL_FILE: &str = "schedule-channel";
1102
1103pub const HITL_CHANNEL_FILE: &str = "hitl-channel";
1108
1109#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1111pub struct ScheduleProposal {
1112 pub cron: String,
1113 pub message: String,
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub asked_for: Option<String>,
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub not_after: Option<String>,
1124 pub proposed_at: String,
1125}
1126
1127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1128pub struct ScheduleEntry {
1129 pub cron: String,
1130 pub message: String,
1131 #[serde(default, skip_serializing_if = "Option::is_none")]
1132 pub sends_to: Option<String>,
1133 #[serde(default, skip_serializing_if = "Option::is_none")]
1145 pub not_after: Option<String>,
1146}
1147
1148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1149pub struct IdleTrigger {
1150 pub after_secs: u64,
1152 pub message: String,
1154 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 pub sends_to: Option<String>,
1157 #[serde(default = "default_idle_cooldown")]
1160 pub cooldown_secs: u64,
1161 #[serde(default = "default_true")]
1164 pub respect_quiet_hours: bool,
1165}
1166
1167fn default_idle_cooldown() -> u64 {
1168 600
1169}
1170pub fn name_enabled(denylist: &[String], name: &str) -> bool {
1172 !denylist.iter().any(|n| n == name)
1173}
1174
1175pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
1178 if enabled {
1179 list.retain(|n| n != name);
1180 } else if !list.iter().any(|n| n == name) {
1181 list.push(name.to_string());
1182 }
1183}
1184
1185fn default_true() -> bool {
1186 true
1187}
1188
1189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1190pub struct FileTransferConfig {
1191 #[serde(default = "default_accept_max")]
1192 pub accept_incoming_file_max_bytes: u64,
1193 #[serde(default = "default_accept_total")]
1194 pub accept_incoming_total_per_hour: u64,
1195 #[serde(default = "default_approval_threshold")]
1196 pub require_approval_above_bytes: u64,
1197 #[serde(default = "default_reject_paths")]
1198 pub reject_paths: Vec<String>,
1199 #[serde(default = "default_allowed_mime")]
1200 pub allowed_mime_types: Vec<String>,
1201}
1202
1203impl Default for FileTransferConfig {
1204 fn default() -> Self {
1205 Self {
1206 accept_incoming_file_max_bytes: default_accept_max(),
1207 accept_incoming_total_per_hour: default_accept_total(),
1208 require_approval_above_bytes: default_approval_threshold(),
1209 reject_paths: default_reject_paths(),
1210 allowed_mime_types: default_allowed_mime(),
1211 }
1212 }
1213}
1214
1215fn default_accept_max() -> u64 {
1216 10_485_760
1217}
1218fn default_accept_total() -> u64 {
1219 104_857_600
1220}
1221fn default_approval_threshold() -> u64 {
1222 10_485_760
1223}
1224fn default_reject_paths() -> Vec<String> {
1225 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
1226}
1227fn default_allowed_mime() -> Vec<String> {
1228 vec!["*".into()]
1229}
1230
1231#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1232#[serde(rename_all = "snake_case")]
1233pub enum DeploymentType {
1234 #[default]
1235 Laptop,
1236 Vm,
1237 Docker,
1238 K8s,
1239 Lambda,
1240}
1241
1242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1243pub struct DeploymentConfig {
1244 #[serde(rename = "type", default)]
1245 pub deployment_type: DeploymentType,
1246 #[serde(default, skip_serializing_if = "Option::is_none")]
1247 pub region: Option<String>,
1248 #[serde(default = "default_env")]
1249 pub environment: Option<String>,
1250}
1251
1252impl Default for DeploymentConfig {
1253 fn default() -> Self {
1254 Self {
1255 deployment_type: DeploymentType::default(),
1256 region: None,
1257 environment: default_env(),
1258 }
1259 }
1260}
1261
1262fn default_env() -> Option<String> {
1263 Some("dev".into())
1264}
1265
1266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1272pub struct DroppedGrant {
1273 pub path: String,
1274 pub verb: String,
1276 pub reason: String,
1277}
1278
1279pub fn filesystem_grants_digest(fs: &FilesystemEntitlement) -> String {
1286 use sha2::{Digest, Sha256};
1287 let mut h = Sha256::new();
1288 for (label, list) in [("r", &fs.read), ("w", &fs.write), ("d", &fs.deny)] {
1289 let mut sorted = list.clone();
1290 sorted.sort();
1291 for p in sorted {
1292 h.update(label.as_bytes());
1293 h.update(b"\0");
1294 h.update(p.as_bytes());
1295 h.update(b"\0");
1296 }
1297 }
1298 format!("sha256:{:x}", h.finalize())
1299}
1300
1301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1309pub struct SandboxRecord {
1310 pub enforcing: bool,
1314 pub mode: String,
1316 pub granted_digest: String,
1321 #[serde(default)]
1323 pub dropped: Vec<DroppedGrant>,
1324}
1325
1326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1327pub struct LockFile {
1328 pub schema: u32,
1329 pub uuid: String,
1330 pub name: String,
1331 pub pid: u32,
1332 pub ppid: u32,
1333 pub started_at: String,
1334 pub binary_version: String,
1335 pub transports: LockTransports,
1336 pub card_digest: String,
1337 pub capabilities: Vec<String>,
1338 #[serde(default)]
1341 pub build_sha: String,
1342 #[serde(default)]
1345 pub proto_version: u32,
1346 #[serde(default, skip_serializing_if = "Option::is_none")]
1349 pub sandbox: Option<SandboxRecord>,
1350}
1351
1352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1353pub struct LockTransports {
1354 pub stdio: bool,
1355 #[serde(default)]
1356 pub unix_socket: Option<String>,
1357 #[serde(default)]
1358 pub tcp: Option<String>,
1359 #[serde(default)]
1364 pub webhook: Option<String>,
1365}
1366
1367#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1374#[serde(rename_all = "snake_case")]
1375pub enum VoiceId {
1376 #[default]
1378 AfHeart,
1379 AfBella,
1380 AfNicole,
1381 AmAdam,
1382 AmMichael,
1383}
1384
1385impl VoiceId {
1386 pub fn style_index(&self) -> usize {
1388 match self {
1389 VoiceId::AfHeart => 0,
1390 VoiceId::AfBella => 1,
1391 VoiceId::AfNicole => 2,
1392 VoiceId::AmAdam => 3,
1393 VoiceId::AmMichael => 4,
1394 }
1395 }
1396
1397 pub fn as_str(&self) -> &'static str {
1399 match self {
1400 VoiceId::AfHeart => "af_heart",
1401 VoiceId::AfBella => "af_bella",
1402 VoiceId::AfNicole => "af_nicole",
1403 VoiceId::AmAdam => "am_adam",
1404 VoiceId::AmMichael => "am_michael",
1405 }
1406 }
1407}
1408
1409impl std::str::FromStr for VoiceId {
1410 type Err = anyhow::Error;
1411
1412 fn from_str(s: &str) -> anyhow::Result<Self> {
1413 match s {
1414 "af_heart" => Ok(VoiceId::AfHeart),
1415 "af_bella" => Ok(VoiceId::AfBella),
1416 "af_nicole" => Ok(VoiceId::AfNicole),
1417 "am_adam" => Ok(VoiceId::AmAdam),
1418 "am_michael" => Ok(VoiceId::AmMichael),
1419 other => anyhow::bail!(
1420 "unknown voice ID '{other}' \
1421 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1422 ),
1423 }
1424 }
1425}
1426
1427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1430pub struct VoiceConfig {
1431 #[serde(default)]
1433 pub enabled: bool,
1434 #[serde(default)]
1436 pub voice_id: VoiceId,
1437 #[serde(default, skip_serializing_if = "Option::is_none")]
1440 pub input_device: Option<String>,
1441}
1442
1443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1448pub struct HitlConfig {
1449 #[serde(default = "default_hitl_timeout_secs")]
1450 pub timeout_secs: u32,
1451 #[serde(default)]
1454 pub max_iterations: Option<u32>,
1455 #[serde(default)]
1458 pub max_tokens: Option<u64>,
1459 #[serde(default, skip_serializing_if = "Option::is_none")]
1468 pub autonomy: Option<crate::hitl::Autonomy>,
1469}
1470
1471fn default_hitl_timeout_secs() -> u32 {
1472 300
1473}
1474
1475impl Default for HitlConfig {
1476 fn default() -> Self {
1477 Self {
1478 timeout_secs: default_hitl_timeout_secs(),
1479 max_iterations: None,
1480 max_tokens: None,
1481 autonomy: None,
1482 }
1483 }
1484}
1485
1486#[cfg(test)]
1487mod hitl_tests {
1488 use super::*;
1489
1490 #[test]
1493 fn hitl_config_autonomy_absent_means_inherit_not_continue() {
1494 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60").unwrap();
1495 assert_eq!(cfg.autonomy, None);
1496 assert_eq!(cfg.autonomy.unwrap_or_default(), crate::hitl::Autonomy::Ask);
1497 }
1498
1499 #[test]
1500 fn hitl_config_autonomy_parses_all_three_modes() {
1501 for (yaml, want) in [
1502 ("continue", crate::hitl::Autonomy::Continue),
1503 ("review", crate::hitl::Autonomy::Review),
1504 ("ask", crate::hitl::Autonomy::Ask),
1505 ] {
1506 let cfg: HitlConfig =
1507 serde_yaml::from_str(&format!("timeout_secs: 60\nautonomy: {yaml}")).unwrap();
1508 assert_eq!(cfg.autonomy, Some(want), "yaml={yaml}");
1509 }
1510 }
1511
1512 #[test]
1513 fn hitl_config_default_max_iterations_is_none() {
1514 let cfg = HitlConfig::default();
1515 assert!(cfg.max_iterations.is_none());
1516 }
1517
1518 #[test]
1519 fn hitl_config_max_iterations_explicit() {
1520 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1521 assert_eq!(cfg.max_iterations, Some(5));
1522 }
1523
1524 #[test]
1525 fn hitl_config_default_max_tokens_is_none() {
1526 let cfg = HitlConfig::default();
1527 assert!(cfg.max_tokens.is_none());
1528 }
1529
1530 #[test]
1531 fn hitl_config_max_tokens_explicit() {
1532 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1533 assert_eq!(cfg.max_tokens, Some(250_000));
1534 }
1535}
1536
1537#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1543pub struct CompanionConfig {
1544 #[serde(default)]
1545 pub enabled: bool,
1546 #[serde(default = "default_locale")]
1547 pub locale: String,
1548 #[serde(default)]
1549 pub relationship: Relationship,
1550 #[serde(default)]
1551 pub voice_overrides: VoiceOverrides,
1552 #[serde(default)]
1553 pub onboarding: OnboardingState,
1554 #[serde(default)]
1555 pub rhythm: RhythmConfig,
1556 #[serde(default)]
1557 pub proactive: ProactiveConfig,
1558}
1559
1560pub fn default_locale() -> String {
1569 sys_locale::get_locale()
1570 .filter(|l| !l.is_empty())
1571 .or_else(|| std::env::var("LANG").ok().and_then(|v| normalize_lang(&v)))
1572 .unwrap_or_else(|| "en-US".into())
1573}
1574
1575fn normalize_lang(v: &str) -> Option<String> {
1577 v.split('.')
1578 .next()
1579 .map(|s| s.replace('_', "-"))
1580 .filter(|s| !s.is_empty())
1581}
1582
1583#[cfg(test)]
1584mod locale_tests {
1585 use super::normalize_lang;
1586
1587 #[test]
1588 fn lang_with_encoding_and_region_normalizes() {
1589 assert_eq!(normalize_lang("zh_TW.UTF-8").as_deref(), Some("zh-TW"));
1590 }
1591
1592 #[test]
1593 fn lang_without_encoding_normalizes() {
1594 assert_eq!(normalize_lang("en_US").as_deref(), Some("en-US"));
1595 }
1596
1597 #[test]
1598 fn lang_with_script_keeps_script() {
1599 assert_eq!(
1600 normalize_lang("zh_Hant_TW.UTF-8").as_deref(),
1601 Some("zh-Hant-TW")
1602 );
1603 }
1604
1605 #[test]
1606 fn empty_lang_yields_none() {
1607 assert_eq!(normalize_lang(""), None);
1608 }
1609}
1610
1611#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1612pub struct VoiceOverrides {
1613 #[serde(default, skip_serializing_if = "Option::is_none")]
1614 pub name_for_user: Option<String>,
1615 #[serde(default, skip_serializing_if = "Option::is_none")]
1616 pub formality: Option<Formality>,
1617 #[serde(default, skip_serializing_if = "Option::is_none")]
1618 pub extra_instructions: Option<String>,
1619}
1620
1621#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1622pub struct FirstMemory {
1623 pub text: String,
1624 pub established_at: chrono::DateTime<chrono::Utc>,
1625}
1626
1627#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1628pub struct OnboardingState {
1629 #[serde(default, skip_serializing_if = "Option::is_none")]
1630 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1631 #[serde(default)]
1632 pub version: u32,
1633 #[serde(default, skip_serializing_if = "Option::is_none")]
1634 pub agent_display_name: Option<String>,
1635 #[serde(default, skip_serializing_if = "Option::is_none")]
1636 pub first_memory: Option<FirstMemory>,
1637}
1638
1639#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1642pub struct RhythmConfig {
1643 #[serde(default)]
1644 pub enabled: bool,
1645}
1646
1647#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1648pub struct ProactiveConfig {
1649 #[serde(default)]
1650 pub enabled: bool,
1651 #[serde(default, skip_serializing_if = "Option::is_none")]
1653 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1654 #[serde(default, skip_serializing_if = "Option::is_none")]
1655 pub quiet_hours: Option<QuietHours>,
1656 #[serde(default, skip_serializing_if = "Option::is_none")]
1657 pub active_hours: Option<ActiveHours>,
1658 #[serde(default = "default_daily_cap")]
1659 pub daily_cap: u8,
1660 #[serde(default = "default_channels")]
1661 pub channels: Vec<String>,
1662 #[serde(default, skip_serializing_if = "Option::is_none")]
1663 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1664}
1665
1666impl Default for ProactiveConfig {
1667 fn default() -> Self {
1668 Self {
1669 enabled: false,
1670 learning_until: None,
1671 quiet_hours: None,
1672 active_hours: None,
1673 daily_cap: default_daily_cap(),
1674 channels: default_channels(),
1675 paused_until: None,
1676 }
1677 }
1678}
1679
1680fn default_daily_cap() -> u8 {
1681 3
1682}
1683fn default_channels() -> Vec<String> {
1684 vec!["stdout".into()]
1685}
1686
1687#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1688pub struct QuietHours {
1689 pub start: String,
1690 pub end: String,
1691}
1692
1693#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1694pub struct ActiveHours {
1695 pub start: String,
1696 pub end: String,
1697}
1698
1699#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1704pub struct AgentAppearance {
1705 #[serde(default = "default_style_preset")]
1707 pub style_preset: String,
1708 #[serde(default)]
1709 pub behavior_preset: BehaviorPreset,
1710 #[serde(default, skip_serializing_if = "Option::is_none")]
1712 pub source_image_path: Option<std::path::PathBuf>,
1713 #[serde(default = "default_expressions_dir")]
1715 pub expressions_dir: std::path::PathBuf,
1716 #[serde(default, skip_serializing_if = "Option::is_none")]
1717 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1718 #[serde(default)]
1719 pub render_status: RenderStatus,
1720}
1721
1722fn default_style_preset() -> String {
1723 "default-blob".into()
1724}
1725
1726fn default_expressions_dir() -> std::path::PathBuf {
1727 std::path::PathBuf::from("expressions")
1728}
1729
1730impl Default for AgentAppearance {
1731 fn default() -> Self {
1732 Self {
1733 style_preset: default_style_preset(),
1734 behavior_preset: BehaviorPreset::Normal,
1735 source_image_path: None,
1736 expressions_dir: default_expressions_dir(),
1737 last_rendered_at: None,
1738 render_status: RenderStatus::Pending,
1739 }
1740 }
1741}
1742
1743#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1744#[serde(rename_all = "snake_case")]
1745pub enum BehaviorPreset {
1746 Quiet,
1747 #[default]
1748 Normal,
1749 Lively,
1750}
1751
1752#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1753#[serde(tag = "status", rename_all = "snake_case")]
1754pub enum RenderStatus {
1755 #[default]
1756 Pending,
1757 Rendering {
1758 done: u8,
1759 total: u8,
1760 },
1761 Ready,
1762 Failed {
1763 reason: String,
1764 },
1765}
1766
1767#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1773#[serde(rename_all = "kebab-case")]
1774pub enum SnapshotPolicy {
1775 #[default]
1776 PullOnStart,
1777 PullPeriodic,
1778 Manual,
1779}
1780
1781#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1783pub struct PatternFilter {
1784 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1785 pub applies_in: Vec<String>,
1786 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1787 pub tier: Vec<String>,
1788 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1789 pub maturity: Vec<String>,
1790 #[serde(default)]
1791 pub importance_min: f64,
1792 #[serde(default = "default_max_snapshot_count")]
1793 pub max_count: usize,
1794 #[serde(default)]
1795 pub snapshot_policy: SnapshotPolicy,
1796}
1797
1798fn default_max_snapshot_count() -> usize {
1799 200
1800}
1801
1802impl Default for PatternFilter {
1803 fn default() -> Self {
1804 Self {
1805 applies_in: vec![],
1806 tier: vec![],
1807 maturity: vec![],
1808 importance_min: 0.0,
1809 max_count: 200,
1810 snapshot_policy: SnapshotPolicy::default(),
1811 }
1812 }
1813}
1814
1815#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1817pub struct SnapshotRef {
1818 pub knowledge_commit: String,
1819 pub taken_at: String,
1820 pub filter: PatternFilter,
1821}
1822
1823#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1825pub struct FederationConfig {
1826 #[serde(default)]
1827 pub filter: PatternFilter,
1828 #[serde(default, skip_serializing_if = "Option::is_none")]
1829 pub snapshot_ref: Option<SnapshotRef>,
1830 #[serde(default)]
1831 pub evidence_flush_interval_minutes: u32,
1832}
1833
1834impl AgentProfile {
1835 #[doc(hidden)]
1841 pub fn default_for_tests() -> Self {
1842 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1843 .expect("minimal profile fixture")
1844 }
1845
1846 pub fn smart_override(&self) -> Option<&crate::config::SmartOverride> {
1855 self.smart
1856 .as_ref()
1857 .or_else(|| self.routing.as_ref().and_then(|r| r.smart.as_ref()))
1858 }
1859
1860 pub fn effective_smart(
1863 &self,
1864 cfg: &crate::config::ModelSwitchConfig,
1865 ) -> crate::config::SmartConfig {
1866 cfg.smart.merged(self.smart_override())
1867 }
1868
1869 pub fn effective_routing(
1871 &self,
1872 cfg: &crate::config::ModelSwitchConfig,
1873 ) -> crate::config::RoutingConfig {
1874 cfg.routing.merged(self.routing.as_ref())
1875 }
1876
1877 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1885 let path = mur_home.join("agents").join(name).join("profile.yaml");
1886 let yaml = std::fs::read_to_string(&path)
1887 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1888 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1889 }
1890
1891 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1893 self.addons.iter().find(|g| {
1894 g.skills.iter().any(|n| n == name)
1895 || g.mcp.iter().any(|n| n == name)
1896 || g.commands.iter().any(|n| n == name)
1897 })
1898 }
1899
1900 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1903 name_enabled(&self.disabled_skills, skill_name)
1904 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1905 }
1906
1907 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1909 name_enabled(&self.disabled_mcp, server_id)
1910 && self.group_of(server_id).is_none_or(|g| g.enabled)
1911 }
1912
1913 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1915 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1916 }
1917
1918 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1920 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1921 }
1922
1923 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1926 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1927 Some(g) => {
1928 g.enabled = enabled;
1929 true
1930 }
1931 None => false,
1932 }
1933 }
1934
1935 pub fn disable_all_addons(&mut self) {
1941 for g in &mut self.addons {
1942 g.enabled = false;
1943 }
1944 }
1945
1946 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1948 self.mcp_servers
1949 .iter()
1950 .filter(|m| self.mcp_enabled(&m.name))
1951 .cloned()
1952 .collect()
1953 }
1954}
1955
1956#[cfg(test)]
1957mod tests {
1958 #[test]
1962 fn grants_digest_ignores_order_but_not_content() {
1963 let a = FilesystemEntitlement {
1964 read: vec!["/a".into(), "/b".into()],
1965 write: vec!["/w".into()],
1966 deny: vec![],
1967 };
1968 let reordered = FilesystemEntitlement {
1969 read: vec!["/b".into(), "/a".into()],
1970 ..a.clone()
1971 };
1972 let changed = FilesystemEntitlement {
1973 write: vec!["/w".into(), "/x".into()],
1974 ..a.clone()
1975 };
1976 assert_eq!(
1977 filesystem_grants_digest(&a),
1978 filesystem_grants_digest(&reordered)
1979 );
1980 assert_ne!(
1981 filesystem_grants_digest(&a),
1982 filesystem_grants_digest(&changed)
1983 );
1984 }
1985
1986 #[test]
1988 fn grants_digest_separates_the_verbs() {
1989 let r = FilesystemEntitlement {
1990 read: vec!["/p".into()],
1991 write: vec![],
1992 deny: vec![],
1993 };
1994 let w = FilesystemEntitlement {
1995 read: vec![],
1996 write: vec!["/p".into()],
1997 deny: vec![],
1998 };
1999 assert_ne!(filesystem_grants_digest(&r), filesystem_grants_digest(&w));
2000 }
2001
2002 #[test]
2005 fn a_lock_without_the_sandbox_block_still_deserialises() {
2006 let old = r#"{"schema":1,"uuid":"u","name":"n","pid":1,"ppid":0,
2007 "started_at":"t","binary_version":"v",
2008 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2009 let lf: LockFile = serde_json::from_str(old).expect("old lock must load");
2010 assert!(lf.sandbox.is_none());
2011 }
2012
2013 #[test]
2014 fn the_sandbox_block_round_trips() {
2015 let rec = SandboxRecord {
2016 enforcing: false,
2017 mode: "advisory-only".into(),
2018 granted_digest: "sha256:x".into(),
2019 dropped: vec![DroppedGrant {
2020 path: "/gone".into(),
2021 verb: "write".into(),
2022 reason: "path does not exist on disk".into(),
2023 }],
2024 };
2025 let back: SandboxRecord =
2026 serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
2027 assert_eq!(back, rec);
2028 }
2029
2030 use super::*;
2031
2032 #[test]
2033 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
2034 let net = McpServerNetwork {
2035 mode: McpNetMode::BroadAudited,
2036 allow_hosts: vec![],
2037 deny_hosts: vec!["evil.example".into()],
2038 authorization: Some(EgressAuthorization {
2039 authorized_by: "david".into(),
2040 authorized_at_ms: 1_750_000_000_000,
2041 }),
2042 };
2043 let y = serde_yaml::to_string(&net).unwrap();
2044 assert!(y.contains("broad_audited"));
2045 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
2046 assert_eq!(back, net);
2047 let legacy: McpServerNetwork =
2049 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
2050 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
2051 assert!(legacy.authorization.is_none());
2052 }
2053
2054 #[test]
2055 fn mcp_entry_network_is_optional_and_round_trips() {
2056 let bare = "name: x\ncommand: npx\n";
2058 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
2059 assert!(e.network.is_none());
2060
2061 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
2063 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
2064 let net = e2.network.expect("network present");
2065 assert_eq!(net.mode, McpNetMode::Restricted);
2066 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
2067
2068 let out = serde_yaml_ng::to_string(&e).unwrap();
2070 assert!(!out.contains("network"));
2071 }
2072
2073 #[test]
2074 fn profile_round_trip_yaml() {
2075 let yaml = r#"
2076schema: 1
2077id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
2078name: agent_a
2079display_name: "Price Hunter"
2080version: "0.1.0"
2081persona:
2082 category: research
2083 description: "Finds prices"
2084 traits: { tone: concise, risk: cautious, verbosity: low }
2085sys_prompt_file: "sys_prompt.md"
2086model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
2087mcp_servers: []
2088skills: []
2089transport:
2090 stdio: true
2091 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
2092communication: { accepts_from: ["*"], sends_to: [] }
2093capabilities: ["a2a.message.send", "a2a.tasks"]
2094entitlements:
2095 network:
2096 inbound: { ports: [] }
2097 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
2098 filesystem: { read: [], write: [], deny: [] }
2099 processes: { spawn: { mode: allowlist, allowed: [] } }
2100 syscalls: { mode: default }
2101 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
2102notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
2103retry:
2104 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
2105 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
2106lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
2107created_at: "2026-04-22T10:00:00+08:00"
2108updated_at: "2026-04-22T10:00:00+08:00"
2109"#;
2110 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
2111 assert_eq!(profile.name, "agent_a");
2112 assert_eq!(profile.persona.category, PersonaCategory::Research);
2113 assert_eq!(
2114 profile.entitlements.network.outbound.mode,
2115 NetworkOutboundMode::Restricted
2116 );
2117 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
2118 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
2119 assert_eq!(profile.id, round_tripped.id);
2120 }
2121
2122 #[test]
2123 fn requires_capabilities_defaults_empty_and_round_trips() {
2124 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2125 let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
2126 assert!(p.requires_capabilities.is_empty());
2127 let with = format!("{base}\nrequires_capabilities:\n - media\n");
2128 let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
2129 assert_eq!(p2.requires_capabilities, vec!["media"]);
2130 }
2131}
2132
2133#[cfg(test)]
2134mod model_ref_tests {
2135 use super::*;
2136
2137 #[test]
2138 fn legacy_profile_without_model_ref_still_parses() {
2139 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2140 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2141 assert!(
2142 p.model_ref.is_none(),
2143 "legacy profile must not have model_ref"
2144 );
2145 }
2146
2147 #[test]
2148 fn round_trip_with_model_ref_preserves_field() {
2149 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2150 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2151 p.model_ref = Some("anthropic_opus_4_7".into());
2152 let s = serde_yaml_ng::to_string(&p).unwrap();
2153 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
2154 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2155 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
2156 }
2157
2158 #[test]
2159 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
2160 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2162 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2163 assert!(
2164 p.fallback_chain.is_empty(),
2165 "legacy profile must have empty fallback_chain"
2166 );
2167 assert!(
2168 p.routing.is_none(),
2169 "legacy profile must have no routing override"
2170 );
2171
2172 let mut p = p.clone();
2174 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
2175 p.routing = Some(crate::config::RoutingOverride {
2176 enabled: Some(true),
2177 ..Default::default()
2178 });
2179 let s = serde_yaml_ng::to_string(&p).unwrap();
2180 assert!(
2181 s.contains("fallback_chain:"),
2182 "yaml must contain fallback_chain"
2183 );
2184 assert!(s.contains("routing:"), "yaml must contain routing");
2185 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2186 assert_eq!(
2187 p2.fallback_chain,
2188 vec!["claude_opus", "claude_sonnet"],
2189 "fallback_chain must round-trip"
2190 );
2191 assert_eq!(
2192 p2.routing.as_ref().unwrap().enabled,
2193 Some(true),
2194 "routing.enabled must round-trip"
2195 );
2196 }
2197
2198 #[test]
2199 fn effective_smart_prefers_the_promoted_field_then_the_legacy_nesting() {
2200 use crate::config::{ModelSwitchConfig, SmartConfig, SmartOverride};
2201 let cfg = ModelSwitchConfig {
2202 smart: SmartConfig {
2203 enabled: false,
2204 cheap: Some("g".into()),
2205 max_escalations: 2,
2206 },
2207 ..Default::default()
2208 };
2209 let p = AgentProfile::default_for_tests();
2211 assert_eq!(p.effective_smart(&cfg), cfg.smart);
2212
2213 let mut legacy = AgentProfile::default_for_tests();
2215 legacy.routing = Some(crate::config::RoutingOverride {
2216 smart: Some(SmartOverride {
2217 enabled: Some(true),
2218 ..Default::default()
2219 }),
2220 ..Default::default()
2221 });
2222 assert!(
2223 legacy.effective_smart(&cfg).enabled,
2224 "legacy nesting is read"
2225 );
2226 assert_eq!(
2227 legacy.effective_smart(&cfg).cheap.as_deref(),
2228 Some("g"),
2229 "unset fields still inherit"
2230 );
2231
2232 let mut both = legacy.clone();
2234 both.smart = Some(SmartOverride {
2235 enabled: Some(false),
2236 ..Default::default()
2237 });
2238 assert!(!both.effective_smart(&cfg).enabled);
2239 }
2240}
2241
2242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2250#[serde(rename_all = "snake_case")]
2251pub enum ProactiveTier {
2252 Off,
2253 WarmOnly,
2254 WarmAndBehavior,
2255 All,
2256}
2257
2258impl ProactiveTier {
2259 pub fn from_config(c: &CompanionConfig) -> Self {
2260 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
2261 (false, _, _) => Self::Off,
2262 (true, false, false) => Self::WarmOnly,
2263 (true, true, false) => Self::WarmAndBehavior,
2264 (true, _, true) => Self::All,
2265 }
2266 }
2267
2268 pub fn apply(&self, c: &mut CompanionConfig) {
2269 match self {
2270 Self::Off => {
2271 c.enabled = false;
2272 c.rhythm.enabled = false;
2273 c.proactive.enabled = false;
2274 }
2275 Self::WarmOnly => {
2276 c.enabled = true;
2277 c.rhythm.enabled = false;
2278 c.proactive.enabled = false;
2279 }
2280 Self::WarmAndBehavior => {
2281 c.enabled = true;
2282 c.rhythm.enabled = true;
2283 c.proactive.enabled = false;
2284 }
2285 Self::All => {
2286 c.enabled = true;
2287 c.rhythm.enabled = true;
2288 c.proactive.enabled = true;
2289 }
2290 }
2291 }
2292}
2293
2294#[cfg(test)]
2295mod mcp_pin_tests {
2296 use super::*;
2297
2298 #[test]
2302 fn pre_m9_entry_roundtrips_without_pin_fields() {
2303 let yaml = r#"
2304name: weather
2305command: /opt/mcp/weather
2306args: ["--port", "0"]
2307"#;
2308 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2309 assert_eq!(entry.name, "weather");
2310 assert_eq!(entry.binary_sha256, None);
2311 assert_eq!(entry.description_hash, None);
2312 assert_eq!(entry.publisher, None);
2313 assert_eq!(entry.installed_at, None);
2314
2315 let out = serde_yaml_ng::to_string(&entry).unwrap();
2318 assert!(!out.contains("binary_sha256"), "got {out}");
2319 assert!(!out.contains("description_hash"), "got {out}");
2320 assert!(!out.contains("publisher"), "got {out}");
2321 assert!(!out.contains("installed_at"), "got {out}");
2322 }
2323
2324 #[test]
2326 fn full_m9_entry_roundtrips_all_fields() {
2327 let yaml = r#"
2328name: weather
2329command: /opt/mcp/weather
2330args: []
2331binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
2332description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
2333publisher:
2334 name: "@anthropic-mcp/weather"
2335 homepage: "https://github.com/anthropic-mcp/weather"
2336 registry_id: "@anthropic-mcp/weather@1.2.3"
2337installed_at: "2026-05-06T08:00:00Z"
2338"#;
2339 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2340 assert!(
2341 entry
2342 .binary_sha256
2343 .as_deref()
2344 .unwrap()
2345 .starts_with("3f4abca8")
2346 );
2347 assert!(
2348 entry
2349 .description_hash
2350 .as_deref()
2351 .unwrap()
2352 .starts_with("9a01b2c3")
2353 );
2354 let pub_info = entry.publisher.clone().unwrap();
2355 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
2356 assert_eq!(
2357 pub_info.homepage.as_deref(),
2358 Some("https://github.com/anthropic-mcp/weather"),
2359 );
2360 assert_eq!(
2361 pub_info.registry_id.as_deref(),
2362 Some("@anthropic-mcp/weather@1.2.3"),
2363 );
2364 let installed = entry.installed_at.unwrap();
2365 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
2366 }
2367
2368 #[test]
2372 fn partial_pin_only_binary_sha_roundtrips() {
2373 let yaml = r#"
2374name: weather
2375command: /opt/mcp/weather
2376args: []
2377binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
2378"#;
2379 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2380 assert_eq!(
2381 entry.binary_sha256.as_deref(),
2382 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
2383 );
2384 assert_eq!(entry.description_hash, None);
2385 assert_eq!(entry.publisher, None);
2386 }
2387
2388 #[test]
2391 fn publisher_minimal_just_name() {
2392 let yaml = r#"
2393name: weather
2394command: /opt/mcp/weather
2395args: []
2396publisher:
2397 name: "alice"
2398"#;
2399 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2400 let p = entry.publisher.as_ref().unwrap();
2401 assert_eq!(p.name, "alice");
2402 assert_eq!(p.homepage, None);
2403 assert_eq!(p.registry_id, None);
2404
2405 let out = serde_yaml_ng::to_string(&entry).unwrap();
2407 assert!(!out.contains("homepage:"), "got {out}");
2408 assert!(!out.contains("registry_id:"), "got {out}");
2409 }
2410}
2411
2412#[cfg(test)]
2413mod voice_tests {
2414 use super::*;
2415 use std::str::FromStr;
2416
2417 #[test]
2418 fn voice_config_round_trips() {
2419 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2421 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
2422
2423 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
2424 assert!(profile.voice.enabled);
2425 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
2426
2427 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
2429 assert!(!legacy.voice.enabled);
2430 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
2431 }
2432
2433 #[test]
2434 fn voice_id_from_str_roundtrips() {
2435 let cases = [
2436 ("af_heart", VoiceId::AfHeart),
2437 ("af_bella", VoiceId::AfBella),
2438 ("af_nicole", VoiceId::AfNicole),
2439 ("am_adam", VoiceId::AmAdam),
2440 ("am_michael", VoiceId::AmMichael),
2441 ];
2442 for (s, expected) in cases {
2443 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
2444 assert_eq!(expected.as_str(), s);
2445 }
2446 }
2447
2448 #[test]
2449 fn voice_id_from_str_rejects_unknown() {
2450 assert!(VoiceId::from_str("bogus").is_err());
2451 }
2452}
2453
2454#[cfg(test)]
2455mod idle_trigger_tests {
2456 use super::*;
2457
2458 #[test]
2459 fn idle_trigger_yaml_round_trip() {
2460 let yaml = r#"
2461restart: on_failure
2462idle_triggers:
2463 - after_secs: 3600
2464 message: "still there?"
2465 sends_to: other_agent
2466 cooldown_secs: 1800
2467 respect_quiet_hours: true
2468"#;
2469 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2470 assert_eq!(cfg.idle_triggers.len(), 1);
2471 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
2472 assert_eq!(cfg.idle_triggers[0].message, "still there?");
2473 assert_eq!(
2474 cfg.idle_triggers[0].sends_to.as_deref(),
2475 Some("other_agent")
2476 );
2477 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
2478 assert!(cfg.idle_triggers[0].respect_quiet_hours);
2479 }
2480
2481 #[test]
2482 fn idle_trigger_defaults_when_omitted() {
2483 let yaml = "restart: on_failure\n";
2484 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2485 assert!(cfg.idle_triggers.is_empty());
2486 }
2487}
2488
2489#[cfg(test)]
2490mod appearance_tests {
2491 use super::*;
2492
2493 #[test]
2494 fn appearance_default_style_preset_is_default_blob() {
2495 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
2496 }
2497
2498 #[test]
2499 fn appearance_default_behavior_is_normal() {
2500 assert_eq!(
2501 AgentAppearance::default().behavior_preset,
2502 BehaviorPreset::Normal
2503 );
2504 }
2505
2506 #[test]
2507 fn appearance_default_render_status_is_pending() {
2508 assert_eq!(
2509 AgentAppearance::default().render_status,
2510 RenderStatus::Pending
2511 );
2512 }
2513
2514 #[test]
2515 fn render_status_serde_round_trip() {
2516 let cases = [
2517 RenderStatus::Pending,
2518 RenderStatus::Rendering { done: 3, total: 12 },
2519 RenderStatus::Ready,
2520 RenderStatus::Failed {
2521 reason: "out of quota".into(),
2522 },
2523 ];
2524 for status in cases {
2525 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
2526 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
2527 assert_eq!(status, back);
2528 }
2529 }
2530
2531 #[test]
2532 fn agent_profile_with_appearance_round_trips() {
2533 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2534 let yaml = format!(
2535 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
2536 );
2537 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2538 assert_eq!(profile.appearance.style_preset, "chiikawa");
2539 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2540
2541 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2542 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2543 assert_eq!(profile.appearance, back.appearance);
2544 }
2545
2546 #[test]
2547 fn legacy_profile_without_appearance_uses_default() {
2548 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2549 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2550 assert_eq!(profile.appearance.style_preset, "default-blob");
2551 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2552 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2553 }
2554
2555 #[test]
2556 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2557 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2558 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2559 assert!(p.file_actions.is_empty());
2560 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2561 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2562 }
2563}
2564
2565#[cfg(test)]
2566mod federation_tests {
2567 use super::*;
2568
2569 #[test]
2570 fn test_pattern_filter_default() {
2571 let f = PatternFilter::default();
2572 assert_eq!(f.max_count, 200);
2573 assert_eq!(f.importance_min, 0.0);
2574 assert!(f.tier.is_empty());
2575 }
2576
2577 #[test]
2578 fn test_federation_config_roundtrip() {
2579 let cfg = FederationConfig {
2580 filter: PatternFilter {
2581 tier: vec!["core".into()],
2582 max_count: 50,
2583 ..Default::default()
2584 },
2585 snapshot_ref: Some(SnapshotRef {
2586 knowledge_commit: "abc123def456".into(),
2587 taken_at: "2026-05-19T00:00:00Z".into(),
2588 filter: PatternFilter::default(),
2589 }),
2590 evidence_flush_interval_minutes: 15,
2591 };
2592 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2593 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2594 assert_eq!(cfg, back);
2595 }
2596
2597 #[test]
2598 fn test_agent_profile_federation_defaults() {
2599 let cfg = FederationConfig::default();
2603 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2604 assert!(cfg.snapshot_ref.is_none());
2605 }
2606}
2607
2608#[cfg(test)]
2609mod skill_card_tests {
2610 use super::*;
2611
2612 #[test]
2613 fn installed_skills_default_to_empty_when_absent() {
2614 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2615 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2616 assert!(p.installed_skills.is_empty());
2617 }
2618
2619 #[test]
2620 fn installed_skills_roundtrip_preserves_entries() {
2621 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2622 let yaml = format!(
2623 "{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"
2624 );
2625 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2626 assert_eq!(p.installed_skills.len(), 1);
2627 assert_eq!(p.installed_skills[0].name, "s1");
2628 assert_eq!(p.installed_skills[0].abstract_text, "does things");
2629 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2630
2631 let out = serde_yaml_ng::to_string(&p).unwrap();
2632 assert!(out.contains("abstract: does things"));
2633 assert!(out.contains("pattern: /find"));
2634
2635 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2636 assert_eq!(p.installed_skills, back.installed_skills);
2637 }
2638
2639 #[test]
2640 fn installed_skills_minimal_entry_serializes_compactly() {
2641 let entry = SkillCardEntry {
2643 name: "minimal".into(),
2644 ..Default::default()
2645 };
2646 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2647 assert!(yaml.contains("name: minimal"));
2648 assert!(
2649 !yaml.contains("version:"),
2650 "empty version must be skipped: {yaml}"
2651 );
2652 assert!(
2653 !yaml.contains("publisher:"),
2654 "empty publisher must be skipped: {yaml}"
2655 );
2656 assert!(
2657 !yaml.contains("abstract:"),
2658 "empty abstract must be skipped: {yaml}"
2659 );
2660 }
2661}
2662
2663#[cfg(test)]
2664mod tool_policy_tests {
2665 use super::*;
2666
2667 fn rules() -> Vec<ToolRule> {
2668 vec![
2669 ToolRule {
2670 pattern: "mcp__github__merge_pr".into(),
2671 policy: ToolPolicy::Ask,
2672 risk: None,
2673 },
2674 ToolRule {
2675 pattern: "mcp__github__*".into(),
2676 policy: ToolPolicy::Allow,
2677 risk: None,
2678 },
2679 ToolRule {
2680 pattern: "mcp__*".into(),
2681 policy: ToolPolicy::Deny,
2682 risk: None,
2683 },
2684 ToolRule {
2685 pattern: "bash".into(),
2686 policy: ToolPolicy::Allow,
2687 risk: None,
2688 },
2689 ]
2690 }
2691
2692 #[test]
2693 fn exact_beats_glob() {
2694 assert_eq!(
2695 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2696 ToolPolicy::Ask
2697 );
2698 }
2699
2700 #[test]
2701 fn longer_glob_wins() {
2702 assert_eq!(
2703 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2704 ToolPolicy::Allow
2705 );
2706 }
2707
2708 #[test]
2709 fn shorter_glob_fallback() {
2710 assert_eq!(
2711 resolve_tool_policy(&rules(), "mcp__slack__send"),
2712 ToolPolicy::Deny
2713 );
2714 }
2715
2716 #[test]
2717 fn exact_bash() {
2718 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2719 }
2720
2721 #[test]
2722 fn unknown_tool_defaults_ask() {
2723 assert_eq!(
2724 resolve_tool_policy(&rules(), "unknown_tool"),
2725 ToolPolicy::Ask
2726 );
2727 }
2728
2729 #[test]
2730 fn empty_rules_defaults_ask() {
2731 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2732 }
2733
2734 fn minimal_entitlements_yaml() -> &'static str {
2735 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2736 }
2737
2738 #[test]
2739 fn entitlements_tools_defaults_empty() {
2740 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2741 assert!(e.tools.is_empty());
2742 }
2743
2744 #[test]
2745 fn entitlements_tools_roundtrip() {
2746 let base = minimal_entitlements_yaml();
2747 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2748 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2749 assert_eq!(e.tools.len(), 1);
2750 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2751 let y = serde_yaml_ng::to_string(&e).unwrap();
2752 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2753 assert_eq!(back.tools.len(), 1);
2754 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2755 }
2756 #[test]
2757 fn denylist_membership_and_mutation() {
2758 let mut list: Vec<String> = vec![];
2759 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2760
2761 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2763 assert_eq!(list, ["a"]);
2764
2765 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2767
2768 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2770 assert!(list.is_empty());
2771
2772 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2774 }
2775
2776 #[test]
2777 fn addon_group_rule_truth_table() {
2778 let mut p = crate::agent::AgentProfile::default_for_tests();
2779 p.addons.push(AddonRef {
2780 id: "grp".into(),
2781 source: "claude-local:grp@1.0.0".into(),
2782 enabled: false,
2783 skills: vec!["g_skill".into()],
2784 mcp: vec!["g_mcp".into()],
2785 commands: vec!["g_cmd".into()],
2786 content_hash: None,
2787 fetch_ref: None,
2788 fetch_plugin: None,
2789 });
2790
2791 assert!(p.skill_enabled("standalone"));
2793 assert!(p.mcp_enabled("standalone_mcp"));
2794
2795 assert!(!p.skill_enabled("g_skill"));
2797 assert!(!p.mcp_enabled("g_mcp"));
2798
2799 assert!(p.set_addon_enabled("grp", true));
2801 assert!(p.skill_enabled("g_skill"));
2802 assert!(p.mcp_enabled("g_mcp"));
2803
2804 p.set_skill_enabled("g_skill", false);
2806 assert!(!p.skill_enabled("g_skill"));
2807
2808 assert!(!p.set_addon_enabled("nope", true));
2810
2811 p.disable_all_addons();
2813 assert!(p.addons.iter().all(|g| !g.enabled));
2814 assert!(!p.skill_enabled("g_skill"));
2815 assert!(!p.skill_enabled("g_cmd"));
2816 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2822 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);
2828 assert!(p.skill_enabled("g_skill"));
2829 }
2830
2831 #[test]
2832 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2833 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2835 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2836 assert_eq!(r.content_hash, None);
2837 assert_eq!(r.fetch_ref, None);
2838
2839 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2841 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2842 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2843 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2844 let back = serde_yaml_ng::to_string(&r2).unwrap();
2845 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2846 assert_eq!(r2, r3);
2847 }
2848}
2849
2850#[cfg(test)]
2851mod lockfile_compat_tests {
2852 use super::*;
2853
2854 #[test]
2855 fn lockfile_new_fields_default_for_old_locks() {
2856 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2859 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2860 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2861 let lock: LockFile = serde_json::from_str(old).unwrap();
2862 assert_eq!(lock.build_sha, "");
2863 assert_eq!(lock.proto_version, 0);
2864 }
2865}
2866
2867#[cfg(test)]
2868mod remote_mcp_tests {
2869 use super::*;
2870
2871 #[test]
2872 fn mcp_entry_roundtrips_remote_bearer() {
2873 let e = McpServerEntry {
2874 name: "gh".into(),
2875 command: String::new(),
2876 url: Some("https://api.example.com/mcp".into()),
2877 auth: Some(McpAuth::Bearer {
2878 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2879 }),
2880 ..Default::default()
2881 };
2882 let y = serde_yaml_ng::to_string(&e).unwrap();
2883 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2884 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2885 assert!(matches!(
2886 back.auth,
2887 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2888 ));
2889 let legacy: McpServerEntry =
2891 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2892 assert!(legacy.url.is_none());
2893 assert!(legacy.auth.is_none());
2894 }
2895}
2896
2897#[cfg(test)]
2898mod requires_programs_tests {
2899 #[test]
2900 fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2901 let with = r#"
2902name: research-gateway
2903command: mur-research-gateway
2904requires_programs:
2905 - name: lightpanda
2906 detect: { file: "~/.mur/aura/lightpanda" }
2907 reason: "render tier"
2908 registry: lightpanda
2909"#;
2910 let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2911 assert_eq!(e.requires_programs.len(), 1);
2912 assert_eq!(e.requires_programs[0].name, "lightpanda");
2913
2914 let without = "name: x\ncommand: y\n";
2916 let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2917 assert!(e2.requires_programs.is_empty());
2918 }
2919}
2920
2921#[cfg(test)]
2922mod secrets_field_tests {
2923 #[test]
2927 fn secrets_names_round_trip_and_are_absent_when_empty() {
2928 let mut p = crate::agent::AgentProfile::default_for_tests();
2929 let yaml = serde_yaml::to_string(&p).unwrap();
2930 assert!(
2931 !yaml.contains("secrets:"),
2932 "empty list must not be written: {yaml}"
2933 );
2934 p.secrets = vec!["GITEA_TOKEN".into()];
2935 let yaml = serde_yaml::to_string(&p).unwrap();
2936 let back: crate::agent::AgentProfile = serde_yaml::from_str(&yaml).unwrap();
2937 assert_eq!(back.secrets, vec!["GITEA_TOKEN".to_string()]);
2938 }
2939}