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; 3] = ["skills", "workflows", "artifacts"];
840
841#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
842pub struct FilesystemEntitlement {
843 #[serde(default)]
844 pub read: Vec<String>,
845 #[serde(default)]
846 pub write: Vec<String>,
847 #[serde(default)]
848 pub deny: Vec<String>,
849}
850
851#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
852pub struct ProcessesEntitlement {
853 pub spawn: SpawnEntitlement,
854}
855
856#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
857pub struct SpawnEntitlement {
858 pub mode: SpawnMode,
859 #[serde(default)]
860 pub allowed: Vec<String>,
861 #[serde(default)]
876 pub allowed_dirs: Vec<String>,
877}
878
879#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
880#[serde(rename_all = "lowercase")]
881pub enum SpawnMode {
882 Allowlist,
883 Any,
884 None,
885 Strict,
891}
892
893#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
894pub struct SyscallsEntitlement {
895 #[serde(default = "default_syscalls_mode")]
896 pub mode: String,
897 #[serde(default)]
898 pub extra_deny: Vec<String>,
899}
900fn default_syscalls_mode() -> String {
901 "default".to_string()
902}
903
904#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
905pub struct LimitsEntitlement {
906 #[serde(default)]
907 pub cpu_seconds: Option<u64>,
908 #[serde(default = "default_memory_mb")]
909 pub memory_mb: u64,
910 #[serde(default = "default_fds")]
911 pub file_descriptors: u32,
912 #[serde(default = "default_procs")]
913 pub processes: u32,
914}
915fn default_memory_mb() -> u64 {
916 512
917}
918fn default_fds() -> u32 {
919 1024
920}
921fn default_procs() -> u32 {
922 32
923}
924
925#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
926#[serde(rename_all = "lowercase")]
927pub enum ToolPolicy {
928 Allow,
929 #[default]
930 Ask,
931 Deny,
932}
933
934#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
935pub struct ToolRule {
936 pub pattern: String,
937 pub policy: ToolPolicy,
938 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub risk: Option<crate::hitl::RiskTier>,
942}
943
944pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
948 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
949}
950
951pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
956 for rule in rules {
957 if rule.pattern == tool_name {
958 return Some(rule.policy);
959 }
960 }
961 let mut best: Option<(&ToolRule, usize)> = None;
962 for rule in rules {
963 if let Some(prefix) = rule.pattern.strip_suffix('*')
964 && tool_name.starts_with(prefix)
965 {
966 let len = prefix.len();
967 if best.is_none_or(|(_, best_len)| len > best_len) {
968 best = Some((rule, len));
969 }
970 }
971 }
972 best.map(|(rule, _)| rule.policy)
973}
974
975#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
976pub struct NotificationsConfig {
977 #[serde(default)]
978 pub on_task_complete: Vec<NotificationTarget>,
979 #[serde(default)]
980 pub on_error: Vec<NotificationTarget>,
981 #[serde(default)]
982 pub on_shutdown: Vec<NotificationTarget>,
983}
984
985#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
986#[serde(tag = "target", rename_all = "lowercase")]
987pub enum NotificationTarget {
988 Agent {
989 name: String,
990 },
991 Commander,
992 Email {
993 address: String,
994 #[serde(default)]
995 smtp_config_file: Option<String>,
996 },
997 Slack {
998 #[serde(default)]
999 channel: Option<String>,
1000 #[serde(default)]
1001 webhook_url_env: Option<String>,
1002 },
1003 Webpush {
1004 url: String,
1005 },
1006 Webhook {
1007 url: String,
1008 #[serde(default = "default_post")]
1009 method: String,
1010 #[serde(default)]
1011 auth: Option<String>,
1012 },
1013}
1014fn default_post() -> String {
1015 "POST".to_string()
1016}
1017
1018#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1019pub struct RetryConfig {
1020 pub llm: RetryPolicy,
1021 pub tool: RetryPolicy,
1022}
1023
1024#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1025pub struct RetryPolicy {
1026 pub max_retries: u32,
1027 pub backoff: BackoffStrategy,
1028 pub initial_delay_ms: u64,
1029 #[serde(default)]
1030 pub max_delay_ms: Option<u64>,
1031 #[serde(default)]
1032 pub retry_on: Vec<String>,
1033}
1034
1035#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1036#[serde(rename_all = "lowercase")]
1037pub enum BackoffStrategy {
1038 Linear,
1039 Exponential,
1040 Fixed,
1041}
1042
1043#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1044pub struct LifecycleConfig {
1045 pub restart: RestartPolicy,
1046 #[serde(default = "default_max_restarts")]
1047 pub max_restarts: u32,
1048 #[serde(default = "default_window")]
1049 pub restart_window_secs: u64,
1050 #[serde(default = "default_stop_timeout")]
1051 pub stop_timeout_secs: u64,
1052 #[serde(default = "default_mcp_required")]
1053 pub mcp_required: bool,
1054 #[serde(default)]
1055 pub execution: ExecutionMode,
1056 #[serde(default)]
1057 pub schedule: Vec<ScheduleEntry>,
1058 #[serde(default)]
1059 pub idle_triggers: Vec<IdleTrigger>,
1060}
1061fn default_max_restarts() -> u32 {
1062 3
1063}
1064fn default_window() -> u64 {
1065 600
1066}
1067fn default_stop_timeout() -> u64 {
1068 15
1069}
1070fn default_mcp_required() -> bool {
1071 true
1072}
1073
1074#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1075#[serde(rename_all = "snake_case")]
1076pub enum RestartPolicy {
1077 Never,
1078 OnFailure,
1079 Always,
1080}
1081
1082#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1083#[serde(rename_all = "snake_case")]
1084pub enum ExecutionMode {
1085 #[default]
1086 Daemon,
1087 OnDemand,
1088}
1089
1090pub const SCHEDULE_PROPOSAL_DIR: &str = "schedule-proposals";
1105
1106pub const SCHEDULE_CHANNEL_FILE: &str = "schedule-channel";
1110
1111pub const HITL_CHANNEL_FILE: &str = "hitl-channel";
1116
1117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1119pub struct ScheduleProposal {
1120 pub cron: String,
1121 pub message: String,
1122 #[serde(default, skip_serializing_if = "Option::is_none")]
1126 pub asked_for: Option<String>,
1127 #[serde(default, skip_serializing_if = "Option::is_none")]
1131 pub not_after: Option<String>,
1132 pub proposed_at: String,
1133}
1134
1135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1136pub struct ScheduleEntry {
1137 pub cron: String,
1138 pub message: String,
1139 #[serde(default, skip_serializing_if = "Option::is_none")]
1140 pub sends_to: Option<String>,
1141 #[serde(default, skip_serializing_if = "Option::is_none")]
1153 pub not_after: Option<String>,
1154}
1155
1156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1157pub struct IdleTrigger {
1158 pub after_secs: u64,
1160 pub message: String,
1162 #[serde(default, skip_serializing_if = "Option::is_none")]
1164 pub sends_to: Option<String>,
1165 #[serde(default = "default_idle_cooldown")]
1168 pub cooldown_secs: u64,
1169 #[serde(default = "default_true")]
1172 pub respect_quiet_hours: bool,
1173}
1174
1175fn default_idle_cooldown() -> u64 {
1176 600
1177}
1178pub fn name_enabled(denylist: &[String], name: &str) -> bool {
1180 !denylist.iter().any(|n| n == name)
1181}
1182
1183pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
1186 if enabled {
1187 list.retain(|n| n != name);
1188 } else if !list.iter().any(|n| n == name) {
1189 list.push(name.to_string());
1190 }
1191}
1192
1193fn default_true() -> bool {
1194 true
1195}
1196
1197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1198pub struct FileTransferConfig {
1199 #[serde(default = "default_accept_max")]
1200 pub accept_incoming_file_max_bytes: u64,
1201 #[serde(default = "default_accept_total")]
1202 pub accept_incoming_total_per_hour: u64,
1203 #[serde(default = "default_approval_threshold")]
1204 pub require_approval_above_bytes: u64,
1205 #[serde(default = "default_reject_paths")]
1206 pub reject_paths: Vec<String>,
1207 #[serde(default = "default_allowed_mime")]
1208 pub allowed_mime_types: Vec<String>,
1209}
1210
1211impl Default for FileTransferConfig {
1212 fn default() -> Self {
1213 Self {
1214 accept_incoming_file_max_bytes: default_accept_max(),
1215 accept_incoming_total_per_hour: default_accept_total(),
1216 require_approval_above_bytes: default_approval_threshold(),
1217 reject_paths: default_reject_paths(),
1218 allowed_mime_types: default_allowed_mime(),
1219 }
1220 }
1221}
1222
1223fn default_accept_max() -> u64 {
1224 10_485_760
1225}
1226fn default_accept_total() -> u64 {
1227 104_857_600
1228}
1229fn default_approval_threshold() -> u64 {
1230 10_485_760
1231}
1232fn default_reject_paths() -> Vec<String> {
1233 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
1234}
1235fn default_allowed_mime() -> Vec<String> {
1236 vec!["*".into()]
1237}
1238
1239#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1240#[serde(rename_all = "snake_case")]
1241pub enum DeploymentType {
1242 #[default]
1243 Laptop,
1244 Vm,
1245 Docker,
1246 K8s,
1247 Lambda,
1248}
1249
1250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1251pub struct DeploymentConfig {
1252 #[serde(rename = "type", default)]
1253 pub deployment_type: DeploymentType,
1254 #[serde(default, skip_serializing_if = "Option::is_none")]
1255 pub region: Option<String>,
1256 #[serde(default = "default_env")]
1257 pub environment: Option<String>,
1258}
1259
1260impl Default for DeploymentConfig {
1261 fn default() -> Self {
1262 Self {
1263 deployment_type: DeploymentType::default(),
1264 region: None,
1265 environment: default_env(),
1266 }
1267 }
1268}
1269
1270fn default_env() -> Option<String> {
1271 Some("dev".into())
1272}
1273
1274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1280pub struct DroppedGrant {
1281 pub path: String,
1282 pub verb: String,
1284 pub reason: String,
1285}
1286
1287pub fn filesystem_grants_digest(fs: &FilesystemEntitlement) -> String {
1294 use sha2::{Digest, Sha256};
1295 let mut h = Sha256::new();
1296 for (label, list) in [("r", &fs.read), ("w", &fs.write), ("d", &fs.deny)] {
1297 let mut sorted = list.clone();
1298 sorted.sort();
1299 for p in sorted {
1300 h.update(label.as_bytes());
1301 h.update(b"\0");
1302 h.update(p.as_bytes());
1303 h.update(b"\0");
1304 }
1305 }
1306 format!("sha256:{:x}", h.finalize())
1307}
1308
1309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1317pub struct SandboxRecord {
1318 pub enforcing: bool,
1322 pub mode: String,
1324 pub granted_digest: String,
1329 #[serde(default)]
1331 pub dropped: Vec<DroppedGrant>,
1332}
1333
1334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1335pub struct LockFile {
1336 pub schema: u32,
1337 pub uuid: String,
1338 pub name: String,
1339 pub pid: u32,
1340 pub ppid: u32,
1341 pub started_at: String,
1342 pub binary_version: String,
1343 pub transports: LockTransports,
1344 pub card_digest: String,
1345 pub capabilities: Vec<String>,
1346 #[serde(default)]
1349 pub build_sha: String,
1350 #[serde(default)]
1353 pub proto_version: u32,
1354 #[serde(default, skip_serializing_if = "Option::is_none")]
1357 pub sandbox: Option<SandboxRecord>,
1358}
1359
1360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1361pub struct LockTransports {
1362 pub stdio: bool,
1363 #[serde(default)]
1364 pub unix_socket: Option<String>,
1365 #[serde(default)]
1366 pub tcp: Option<String>,
1367 #[serde(default)]
1372 pub webhook: Option<String>,
1373}
1374
1375#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1382#[serde(rename_all = "snake_case")]
1383pub enum VoiceId {
1384 #[default]
1386 AfHeart,
1387 AfBella,
1388 AfNicole,
1389 AmAdam,
1390 AmMichael,
1391}
1392
1393impl VoiceId {
1394 pub fn style_index(&self) -> usize {
1396 match self {
1397 VoiceId::AfHeart => 0,
1398 VoiceId::AfBella => 1,
1399 VoiceId::AfNicole => 2,
1400 VoiceId::AmAdam => 3,
1401 VoiceId::AmMichael => 4,
1402 }
1403 }
1404
1405 pub fn as_str(&self) -> &'static str {
1407 match self {
1408 VoiceId::AfHeart => "af_heart",
1409 VoiceId::AfBella => "af_bella",
1410 VoiceId::AfNicole => "af_nicole",
1411 VoiceId::AmAdam => "am_adam",
1412 VoiceId::AmMichael => "am_michael",
1413 }
1414 }
1415}
1416
1417impl std::str::FromStr for VoiceId {
1418 type Err = anyhow::Error;
1419
1420 fn from_str(s: &str) -> anyhow::Result<Self> {
1421 match s {
1422 "af_heart" => Ok(VoiceId::AfHeart),
1423 "af_bella" => Ok(VoiceId::AfBella),
1424 "af_nicole" => Ok(VoiceId::AfNicole),
1425 "am_adam" => Ok(VoiceId::AmAdam),
1426 "am_michael" => Ok(VoiceId::AmMichael),
1427 other => anyhow::bail!(
1428 "unknown voice ID '{other}' \
1429 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1430 ),
1431 }
1432 }
1433}
1434
1435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1438pub struct VoiceConfig {
1439 #[serde(default)]
1441 pub enabled: bool,
1442 #[serde(default)]
1444 pub voice_id: VoiceId,
1445 #[serde(default, skip_serializing_if = "Option::is_none")]
1448 pub input_device: Option<String>,
1449}
1450
1451#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1456pub struct HitlConfig {
1457 #[serde(default = "default_hitl_timeout_secs")]
1458 pub timeout_secs: u32,
1459 #[serde(default)]
1462 pub max_iterations: Option<u32>,
1463 #[serde(default)]
1466 pub max_tokens: Option<u64>,
1467 #[serde(default, skip_serializing_if = "Option::is_none")]
1476 pub autonomy: Option<crate::hitl::Autonomy>,
1477}
1478
1479fn default_hitl_timeout_secs() -> u32 {
1480 300
1481}
1482
1483impl Default for HitlConfig {
1484 fn default() -> Self {
1485 Self {
1486 timeout_secs: default_hitl_timeout_secs(),
1487 max_iterations: None,
1488 max_tokens: None,
1489 autonomy: None,
1490 }
1491 }
1492}
1493
1494#[cfg(test)]
1495mod hitl_tests {
1496 use super::*;
1497
1498 #[test]
1501 fn hitl_config_autonomy_absent_means_inherit_not_continue() {
1502 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60").unwrap();
1503 assert_eq!(cfg.autonomy, None);
1504 assert_eq!(cfg.autonomy.unwrap_or_default(), crate::hitl::Autonomy::Ask);
1505 }
1506
1507 #[test]
1508 fn hitl_config_autonomy_parses_all_three_modes() {
1509 for (yaml, want) in [
1510 ("continue", crate::hitl::Autonomy::Continue),
1511 ("review", crate::hitl::Autonomy::Review),
1512 ("ask", crate::hitl::Autonomy::Ask),
1513 ] {
1514 let cfg: HitlConfig =
1515 serde_yaml::from_str(&format!("timeout_secs: 60\nautonomy: {yaml}")).unwrap();
1516 assert_eq!(cfg.autonomy, Some(want), "yaml={yaml}");
1517 }
1518 }
1519
1520 #[test]
1521 fn hitl_config_default_max_iterations_is_none() {
1522 let cfg = HitlConfig::default();
1523 assert!(cfg.max_iterations.is_none());
1524 }
1525
1526 #[test]
1527 fn hitl_config_max_iterations_explicit() {
1528 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1529 assert_eq!(cfg.max_iterations, Some(5));
1530 }
1531
1532 #[test]
1533 fn hitl_config_default_max_tokens_is_none() {
1534 let cfg = HitlConfig::default();
1535 assert!(cfg.max_tokens.is_none());
1536 }
1537
1538 #[test]
1539 fn hitl_config_max_tokens_explicit() {
1540 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1541 assert_eq!(cfg.max_tokens, Some(250_000));
1542 }
1543}
1544
1545#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1551pub struct CompanionConfig {
1552 #[serde(default)]
1553 pub enabled: bool,
1554 #[serde(default = "default_locale")]
1555 pub locale: String,
1556 #[serde(default)]
1557 pub relationship: Relationship,
1558 #[serde(default)]
1559 pub voice_overrides: VoiceOverrides,
1560 #[serde(default)]
1561 pub onboarding: OnboardingState,
1562 #[serde(default)]
1563 pub rhythm: RhythmConfig,
1564 #[serde(default)]
1565 pub proactive: ProactiveConfig,
1566}
1567
1568pub fn default_locale() -> String {
1577 sys_locale::get_locale()
1578 .filter(|l| !l.is_empty())
1579 .or_else(|| std::env::var("LANG").ok().and_then(|v| normalize_lang(&v)))
1580 .unwrap_or_else(|| "en-US".into())
1581}
1582
1583fn normalize_lang(v: &str) -> Option<String> {
1585 v.split('.')
1586 .next()
1587 .map(|s| s.replace('_', "-"))
1588 .filter(|s| !s.is_empty())
1589}
1590
1591#[cfg(test)]
1592mod locale_tests {
1593 use super::normalize_lang;
1594
1595 #[test]
1596 fn lang_with_encoding_and_region_normalizes() {
1597 assert_eq!(normalize_lang("zh_TW.UTF-8").as_deref(), Some("zh-TW"));
1598 }
1599
1600 #[test]
1601 fn lang_without_encoding_normalizes() {
1602 assert_eq!(normalize_lang("en_US").as_deref(), Some("en-US"));
1603 }
1604
1605 #[test]
1606 fn lang_with_script_keeps_script() {
1607 assert_eq!(
1608 normalize_lang("zh_Hant_TW.UTF-8").as_deref(),
1609 Some("zh-Hant-TW")
1610 );
1611 }
1612
1613 #[test]
1614 fn empty_lang_yields_none() {
1615 assert_eq!(normalize_lang(""), None);
1616 }
1617}
1618
1619#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1620pub struct VoiceOverrides {
1621 #[serde(default, skip_serializing_if = "Option::is_none")]
1622 pub name_for_user: Option<String>,
1623 #[serde(default, skip_serializing_if = "Option::is_none")]
1624 pub formality: Option<Formality>,
1625 #[serde(default, skip_serializing_if = "Option::is_none")]
1626 pub extra_instructions: Option<String>,
1627}
1628
1629#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1630pub struct FirstMemory {
1631 pub text: String,
1632 pub established_at: chrono::DateTime<chrono::Utc>,
1633}
1634
1635#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1636pub struct OnboardingState {
1637 #[serde(default, skip_serializing_if = "Option::is_none")]
1638 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1639 #[serde(default)]
1640 pub version: u32,
1641 #[serde(default, skip_serializing_if = "Option::is_none")]
1642 pub agent_display_name: Option<String>,
1643 #[serde(default, skip_serializing_if = "Option::is_none")]
1644 pub first_memory: Option<FirstMemory>,
1645}
1646
1647#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1650pub struct RhythmConfig {
1651 #[serde(default)]
1652 pub enabled: bool,
1653}
1654
1655#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1656pub struct ProactiveConfig {
1657 #[serde(default)]
1658 pub enabled: bool,
1659 #[serde(default, skip_serializing_if = "Option::is_none")]
1661 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1662 #[serde(default, skip_serializing_if = "Option::is_none")]
1663 pub quiet_hours: Option<QuietHours>,
1664 #[serde(default, skip_serializing_if = "Option::is_none")]
1665 pub active_hours: Option<ActiveHours>,
1666 #[serde(default = "default_daily_cap")]
1667 pub daily_cap: u8,
1668 #[serde(default = "default_channels")]
1669 pub channels: Vec<String>,
1670 #[serde(default, skip_serializing_if = "Option::is_none")]
1671 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1672}
1673
1674impl Default for ProactiveConfig {
1675 fn default() -> Self {
1676 Self {
1677 enabled: false,
1678 learning_until: None,
1679 quiet_hours: None,
1680 active_hours: None,
1681 daily_cap: default_daily_cap(),
1682 channels: default_channels(),
1683 paused_until: None,
1684 }
1685 }
1686}
1687
1688fn default_daily_cap() -> u8 {
1689 3
1690}
1691fn default_channels() -> Vec<String> {
1692 vec!["stdout".into()]
1693}
1694
1695#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1696pub struct QuietHours {
1697 pub start: String,
1698 pub end: String,
1699}
1700
1701#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1702pub struct ActiveHours {
1703 pub start: String,
1704 pub end: String,
1705}
1706
1707#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1712pub struct AgentAppearance {
1713 #[serde(default = "default_style_preset")]
1715 pub style_preset: String,
1716 #[serde(default)]
1717 pub behavior_preset: BehaviorPreset,
1718 #[serde(default, skip_serializing_if = "Option::is_none")]
1720 pub source_image_path: Option<std::path::PathBuf>,
1721 #[serde(default = "default_expressions_dir")]
1723 pub expressions_dir: std::path::PathBuf,
1724 #[serde(default, skip_serializing_if = "Option::is_none")]
1725 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1726 #[serde(default)]
1727 pub render_status: RenderStatus,
1728}
1729
1730fn default_style_preset() -> String {
1731 "default-blob".into()
1732}
1733
1734fn default_expressions_dir() -> std::path::PathBuf {
1735 std::path::PathBuf::from("expressions")
1736}
1737
1738impl Default for AgentAppearance {
1739 fn default() -> Self {
1740 Self {
1741 style_preset: default_style_preset(),
1742 behavior_preset: BehaviorPreset::Normal,
1743 source_image_path: None,
1744 expressions_dir: default_expressions_dir(),
1745 last_rendered_at: None,
1746 render_status: RenderStatus::Pending,
1747 }
1748 }
1749}
1750
1751#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1752#[serde(rename_all = "snake_case")]
1753pub enum BehaviorPreset {
1754 Quiet,
1755 #[default]
1756 Normal,
1757 Lively,
1758}
1759
1760#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1761#[serde(tag = "status", rename_all = "snake_case")]
1762pub enum RenderStatus {
1763 #[default]
1764 Pending,
1765 Rendering {
1766 done: u8,
1767 total: u8,
1768 },
1769 Ready,
1770 Failed {
1771 reason: String,
1772 },
1773}
1774
1775#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1781#[serde(rename_all = "kebab-case")]
1782pub enum SnapshotPolicy {
1783 #[default]
1784 PullOnStart,
1785 PullPeriodic,
1786 Manual,
1787}
1788
1789#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1791pub struct PatternFilter {
1792 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1793 pub applies_in: Vec<String>,
1794 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1795 pub tier: Vec<String>,
1796 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1797 pub maturity: Vec<String>,
1798 #[serde(default)]
1799 pub importance_min: f64,
1800 #[serde(default = "default_max_snapshot_count")]
1801 pub max_count: usize,
1802 #[serde(default)]
1803 pub snapshot_policy: SnapshotPolicy,
1804}
1805
1806fn default_max_snapshot_count() -> usize {
1807 200
1808}
1809
1810impl Default for PatternFilter {
1811 fn default() -> Self {
1812 Self {
1813 applies_in: vec![],
1814 tier: vec![],
1815 maturity: vec![],
1816 importance_min: 0.0,
1817 max_count: 200,
1818 snapshot_policy: SnapshotPolicy::default(),
1819 }
1820 }
1821}
1822
1823#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1825pub struct SnapshotRef {
1826 pub knowledge_commit: String,
1827 pub taken_at: String,
1828 pub filter: PatternFilter,
1829}
1830
1831#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1833pub struct FederationConfig {
1834 #[serde(default)]
1835 pub filter: PatternFilter,
1836 #[serde(default, skip_serializing_if = "Option::is_none")]
1837 pub snapshot_ref: Option<SnapshotRef>,
1838 #[serde(default)]
1839 pub evidence_flush_interval_minutes: u32,
1840}
1841
1842impl AgentProfile {
1843 #[doc(hidden)]
1849 pub fn default_for_tests() -> Self {
1850 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1851 .expect("minimal profile fixture")
1852 }
1853
1854 pub fn smart_override(&self) -> Option<&crate::config::SmartOverride> {
1863 self.smart
1864 .as_ref()
1865 .or_else(|| self.routing.as_ref().and_then(|r| r.smart.as_ref()))
1866 }
1867
1868 pub fn effective_smart(
1871 &self,
1872 cfg: &crate::config::ModelSwitchConfig,
1873 ) -> crate::config::SmartConfig {
1874 cfg.smart.merged(self.smart_override())
1875 }
1876
1877 pub fn effective_routing(
1879 &self,
1880 cfg: &crate::config::ModelSwitchConfig,
1881 ) -> crate::config::RoutingConfig {
1882 cfg.routing.merged(self.routing.as_ref())
1883 }
1884
1885 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1893 let path = mur_home.join("agents").join(name).join("profile.yaml");
1894 let yaml = std::fs::read_to_string(&path)
1895 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1896 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1897 }
1898
1899 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1901 self.addons.iter().find(|g| {
1902 g.skills.iter().any(|n| n == name)
1903 || g.mcp.iter().any(|n| n == name)
1904 || g.commands.iter().any(|n| n == name)
1905 })
1906 }
1907
1908 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1911 name_enabled(&self.disabled_skills, skill_name)
1912 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1913 }
1914
1915 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1917 name_enabled(&self.disabled_mcp, server_id)
1918 && self.group_of(server_id).is_none_or(|g| g.enabled)
1919 }
1920
1921 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1923 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1924 }
1925
1926 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1928 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1929 }
1930
1931 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1934 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1935 Some(g) => {
1936 g.enabled = enabled;
1937 true
1938 }
1939 None => false,
1940 }
1941 }
1942
1943 pub fn disable_all_addons(&mut self) {
1949 for g in &mut self.addons {
1950 g.enabled = false;
1951 }
1952 }
1953
1954 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1956 self.mcp_servers
1957 .iter()
1958 .filter(|m| self.mcp_enabled(&m.name))
1959 .cloned()
1960 .collect()
1961 }
1962}
1963
1964#[cfg(test)]
1965mod tests {
1966 #[test]
1970 fn grants_digest_ignores_order_but_not_content() {
1971 let a = FilesystemEntitlement {
1972 read: vec!["/a".into(), "/b".into()],
1973 write: vec!["/w".into()],
1974 deny: vec![],
1975 };
1976 let reordered = FilesystemEntitlement {
1977 read: vec!["/b".into(), "/a".into()],
1978 ..a.clone()
1979 };
1980 let changed = FilesystemEntitlement {
1981 write: vec!["/w".into(), "/x".into()],
1982 ..a.clone()
1983 };
1984 assert_eq!(
1985 filesystem_grants_digest(&a),
1986 filesystem_grants_digest(&reordered)
1987 );
1988 assert_ne!(
1989 filesystem_grants_digest(&a),
1990 filesystem_grants_digest(&changed)
1991 );
1992 }
1993
1994 #[test]
1996 fn grants_digest_separates_the_verbs() {
1997 let r = FilesystemEntitlement {
1998 read: vec!["/p".into()],
1999 write: vec![],
2000 deny: vec![],
2001 };
2002 let w = FilesystemEntitlement {
2003 read: vec![],
2004 write: vec!["/p".into()],
2005 deny: vec![],
2006 };
2007 assert_ne!(filesystem_grants_digest(&r), filesystem_grants_digest(&w));
2008 }
2009
2010 #[test]
2013 fn a_lock_without_the_sandbox_block_still_deserialises() {
2014 let old = r#"{"schema":1,"uuid":"u","name":"n","pid":1,"ppid":0,
2015 "started_at":"t","binary_version":"v",
2016 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2017 let lf: LockFile = serde_json::from_str(old).expect("old lock must load");
2018 assert!(lf.sandbox.is_none());
2019 }
2020
2021 #[test]
2022 fn the_sandbox_block_round_trips() {
2023 let rec = SandboxRecord {
2024 enforcing: false,
2025 mode: "advisory-only".into(),
2026 granted_digest: "sha256:x".into(),
2027 dropped: vec![DroppedGrant {
2028 path: "/gone".into(),
2029 verb: "write".into(),
2030 reason: "path does not exist on disk".into(),
2031 }],
2032 };
2033 let back: SandboxRecord =
2034 serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
2035 assert_eq!(back, rec);
2036 }
2037
2038 use super::*;
2039
2040 #[test]
2041 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
2042 let net = McpServerNetwork {
2043 mode: McpNetMode::BroadAudited,
2044 allow_hosts: vec![],
2045 deny_hosts: vec!["evil.example".into()],
2046 authorization: Some(EgressAuthorization {
2047 authorized_by: "david".into(),
2048 authorized_at_ms: 1_750_000_000_000,
2049 }),
2050 };
2051 let y = serde_yaml::to_string(&net).unwrap();
2052 assert!(y.contains("broad_audited"));
2053 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
2054 assert_eq!(back, net);
2055 let legacy: McpServerNetwork =
2057 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
2058 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
2059 assert!(legacy.authorization.is_none());
2060 }
2061
2062 #[test]
2063 fn mcp_entry_network_is_optional_and_round_trips() {
2064 let bare = "name: x\ncommand: npx\n";
2066 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
2067 assert!(e.network.is_none());
2068
2069 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
2071 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
2072 let net = e2.network.expect("network present");
2073 assert_eq!(net.mode, McpNetMode::Restricted);
2074 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
2075
2076 let out = serde_yaml_ng::to_string(&e).unwrap();
2078 assert!(!out.contains("network"));
2079 }
2080
2081 #[test]
2082 fn profile_round_trip_yaml() {
2083 let yaml = r#"
2084schema: 1
2085id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
2086name: agent_a
2087display_name: "Price Hunter"
2088version: "0.1.0"
2089persona:
2090 category: research
2091 description: "Finds prices"
2092 traits: { tone: concise, risk: cautious, verbosity: low }
2093sys_prompt_file: "sys_prompt.md"
2094model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
2095mcp_servers: []
2096skills: []
2097transport:
2098 stdio: true
2099 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
2100communication: { accepts_from: ["*"], sends_to: [] }
2101capabilities: ["a2a.message.send", "a2a.tasks"]
2102entitlements:
2103 network:
2104 inbound: { ports: [] }
2105 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
2106 filesystem: { read: [], write: [], deny: [] }
2107 processes: { spawn: { mode: allowlist, allowed: [] } }
2108 syscalls: { mode: default }
2109 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
2110notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
2111retry:
2112 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
2113 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
2114lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
2115created_at: "2026-04-22T10:00:00+08:00"
2116updated_at: "2026-04-22T10:00:00+08:00"
2117"#;
2118 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
2119 assert_eq!(profile.name, "agent_a");
2120 assert_eq!(profile.persona.category, PersonaCategory::Research);
2121 assert_eq!(
2122 profile.entitlements.network.outbound.mode,
2123 NetworkOutboundMode::Restricted
2124 );
2125 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
2126 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
2127 assert_eq!(profile.id, round_tripped.id);
2128 }
2129
2130 #[test]
2131 fn requires_capabilities_defaults_empty_and_round_trips() {
2132 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2133 let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
2134 assert!(p.requires_capabilities.is_empty());
2135 let with = format!("{base}\nrequires_capabilities:\n - media\n");
2136 let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
2137 assert_eq!(p2.requires_capabilities, vec!["media"]);
2138 }
2139}
2140
2141#[cfg(test)]
2142mod model_ref_tests {
2143 use super::*;
2144
2145 #[test]
2146 fn legacy_profile_without_model_ref_still_parses() {
2147 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2148 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2149 assert!(
2150 p.model_ref.is_none(),
2151 "legacy profile must not have model_ref"
2152 );
2153 }
2154
2155 #[test]
2156 fn round_trip_with_model_ref_preserves_field() {
2157 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2158 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2159 p.model_ref = Some("anthropic_opus_4_7".into());
2160 let s = serde_yaml_ng::to_string(&p).unwrap();
2161 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
2162 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2163 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
2164 }
2165
2166 #[test]
2167 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
2168 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2170 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2171 assert!(
2172 p.fallback_chain.is_empty(),
2173 "legacy profile must have empty fallback_chain"
2174 );
2175 assert!(
2176 p.routing.is_none(),
2177 "legacy profile must have no routing override"
2178 );
2179
2180 let mut p = p.clone();
2182 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
2183 p.routing = Some(crate::config::RoutingOverride {
2184 enabled: Some(true),
2185 ..Default::default()
2186 });
2187 let s = serde_yaml_ng::to_string(&p).unwrap();
2188 assert!(
2189 s.contains("fallback_chain:"),
2190 "yaml must contain fallback_chain"
2191 );
2192 assert!(s.contains("routing:"), "yaml must contain routing");
2193 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2194 assert_eq!(
2195 p2.fallback_chain,
2196 vec!["claude_opus", "claude_sonnet"],
2197 "fallback_chain must round-trip"
2198 );
2199 assert_eq!(
2200 p2.routing.as_ref().unwrap().enabled,
2201 Some(true),
2202 "routing.enabled must round-trip"
2203 );
2204 }
2205
2206 #[test]
2207 fn effective_smart_prefers_the_promoted_field_then_the_legacy_nesting() {
2208 use crate::config::{ModelSwitchConfig, SmartConfig, SmartOverride};
2209 let cfg = ModelSwitchConfig {
2210 smart: SmartConfig {
2211 enabled: false,
2212 cheap: Some("g".into()),
2213 max_escalations: 2,
2214 },
2215 ..Default::default()
2216 };
2217 let p = AgentProfile::default_for_tests();
2219 assert_eq!(p.effective_smart(&cfg), cfg.smart);
2220
2221 let mut legacy = AgentProfile::default_for_tests();
2223 legacy.routing = Some(crate::config::RoutingOverride {
2224 smart: Some(SmartOverride {
2225 enabled: Some(true),
2226 ..Default::default()
2227 }),
2228 ..Default::default()
2229 });
2230 assert!(
2231 legacy.effective_smart(&cfg).enabled,
2232 "legacy nesting is read"
2233 );
2234 assert_eq!(
2235 legacy.effective_smart(&cfg).cheap.as_deref(),
2236 Some("g"),
2237 "unset fields still inherit"
2238 );
2239
2240 let mut both = legacy.clone();
2242 both.smart = Some(SmartOverride {
2243 enabled: Some(false),
2244 ..Default::default()
2245 });
2246 assert!(!both.effective_smart(&cfg).enabled);
2247 }
2248}
2249
2250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2258#[serde(rename_all = "snake_case")]
2259pub enum ProactiveTier {
2260 Off,
2261 WarmOnly,
2262 WarmAndBehavior,
2263 All,
2264}
2265
2266impl ProactiveTier {
2267 pub fn from_config(c: &CompanionConfig) -> Self {
2268 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
2269 (false, _, _) => Self::Off,
2270 (true, false, false) => Self::WarmOnly,
2271 (true, true, false) => Self::WarmAndBehavior,
2272 (true, _, true) => Self::All,
2273 }
2274 }
2275
2276 pub fn apply(&self, c: &mut CompanionConfig) {
2277 match self {
2278 Self::Off => {
2279 c.enabled = false;
2280 c.rhythm.enabled = false;
2281 c.proactive.enabled = false;
2282 }
2283 Self::WarmOnly => {
2284 c.enabled = true;
2285 c.rhythm.enabled = false;
2286 c.proactive.enabled = false;
2287 }
2288 Self::WarmAndBehavior => {
2289 c.enabled = true;
2290 c.rhythm.enabled = true;
2291 c.proactive.enabled = false;
2292 }
2293 Self::All => {
2294 c.enabled = true;
2295 c.rhythm.enabled = true;
2296 c.proactive.enabled = true;
2297 }
2298 }
2299 }
2300}
2301
2302#[cfg(test)]
2303mod mcp_pin_tests {
2304 use super::*;
2305
2306 #[test]
2310 fn pre_m9_entry_roundtrips_without_pin_fields() {
2311 let yaml = r#"
2312name: weather
2313command: /opt/mcp/weather
2314args: ["--port", "0"]
2315"#;
2316 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2317 assert_eq!(entry.name, "weather");
2318 assert_eq!(entry.binary_sha256, None);
2319 assert_eq!(entry.description_hash, None);
2320 assert_eq!(entry.publisher, None);
2321 assert_eq!(entry.installed_at, None);
2322
2323 let out = serde_yaml_ng::to_string(&entry).unwrap();
2326 assert!(!out.contains("binary_sha256"), "got {out}");
2327 assert!(!out.contains("description_hash"), "got {out}");
2328 assert!(!out.contains("publisher"), "got {out}");
2329 assert!(!out.contains("installed_at"), "got {out}");
2330 }
2331
2332 #[test]
2334 fn full_m9_entry_roundtrips_all_fields() {
2335 let yaml = r#"
2336name: weather
2337command: /opt/mcp/weather
2338args: []
2339binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
2340description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
2341publisher:
2342 name: "@anthropic-mcp/weather"
2343 homepage: "https://github.com/anthropic-mcp/weather"
2344 registry_id: "@anthropic-mcp/weather@1.2.3"
2345installed_at: "2026-05-06T08:00:00Z"
2346"#;
2347 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2348 assert!(
2349 entry
2350 .binary_sha256
2351 .as_deref()
2352 .unwrap()
2353 .starts_with("3f4abca8")
2354 );
2355 assert!(
2356 entry
2357 .description_hash
2358 .as_deref()
2359 .unwrap()
2360 .starts_with("9a01b2c3")
2361 );
2362 let pub_info = entry.publisher.clone().unwrap();
2363 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
2364 assert_eq!(
2365 pub_info.homepage.as_deref(),
2366 Some("https://github.com/anthropic-mcp/weather"),
2367 );
2368 assert_eq!(
2369 pub_info.registry_id.as_deref(),
2370 Some("@anthropic-mcp/weather@1.2.3"),
2371 );
2372 let installed = entry.installed_at.unwrap();
2373 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
2374 }
2375
2376 #[test]
2380 fn partial_pin_only_binary_sha_roundtrips() {
2381 let yaml = r#"
2382name: weather
2383command: /opt/mcp/weather
2384args: []
2385binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
2386"#;
2387 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2388 assert_eq!(
2389 entry.binary_sha256.as_deref(),
2390 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
2391 );
2392 assert_eq!(entry.description_hash, None);
2393 assert_eq!(entry.publisher, None);
2394 }
2395
2396 #[test]
2399 fn publisher_minimal_just_name() {
2400 let yaml = r#"
2401name: weather
2402command: /opt/mcp/weather
2403args: []
2404publisher:
2405 name: "alice"
2406"#;
2407 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2408 let p = entry.publisher.as_ref().unwrap();
2409 assert_eq!(p.name, "alice");
2410 assert_eq!(p.homepage, None);
2411 assert_eq!(p.registry_id, None);
2412
2413 let out = serde_yaml_ng::to_string(&entry).unwrap();
2415 assert!(!out.contains("homepage:"), "got {out}");
2416 assert!(!out.contains("registry_id:"), "got {out}");
2417 }
2418}
2419
2420#[cfg(test)]
2421mod voice_tests {
2422 use super::*;
2423 use std::str::FromStr;
2424
2425 #[test]
2426 fn voice_config_round_trips() {
2427 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2429 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
2430
2431 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
2432 assert!(profile.voice.enabled);
2433 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
2434
2435 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
2437 assert!(!legacy.voice.enabled);
2438 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
2439 }
2440
2441 #[test]
2442 fn voice_id_from_str_roundtrips() {
2443 let cases = [
2444 ("af_heart", VoiceId::AfHeart),
2445 ("af_bella", VoiceId::AfBella),
2446 ("af_nicole", VoiceId::AfNicole),
2447 ("am_adam", VoiceId::AmAdam),
2448 ("am_michael", VoiceId::AmMichael),
2449 ];
2450 for (s, expected) in cases {
2451 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
2452 assert_eq!(expected.as_str(), s);
2453 }
2454 }
2455
2456 #[test]
2457 fn voice_id_from_str_rejects_unknown() {
2458 assert!(VoiceId::from_str("bogus").is_err());
2459 }
2460}
2461
2462#[cfg(test)]
2463mod idle_trigger_tests {
2464 use super::*;
2465
2466 #[test]
2467 fn idle_trigger_yaml_round_trip() {
2468 let yaml = r#"
2469restart: on_failure
2470idle_triggers:
2471 - after_secs: 3600
2472 message: "still there?"
2473 sends_to: other_agent
2474 cooldown_secs: 1800
2475 respect_quiet_hours: true
2476"#;
2477 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2478 assert_eq!(cfg.idle_triggers.len(), 1);
2479 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
2480 assert_eq!(cfg.idle_triggers[0].message, "still there?");
2481 assert_eq!(
2482 cfg.idle_triggers[0].sends_to.as_deref(),
2483 Some("other_agent")
2484 );
2485 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
2486 assert!(cfg.idle_triggers[0].respect_quiet_hours);
2487 }
2488
2489 #[test]
2490 fn idle_trigger_defaults_when_omitted() {
2491 let yaml = "restart: on_failure\n";
2492 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2493 assert!(cfg.idle_triggers.is_empty());
2494 }
2495}
2496
2497#[cfg(test)]
2498mod appearance_tests {
2499 use super::*;
2500
2501 #[test]
2502 fn appearance_default_style_preset_is_default_blob() {
2503 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
2504 }
2505
2506 #[test]
2507 fn appearance_default_behavior_is_normal() {
2508 assert_eq!(
2509 AgentAppearance::default().behavior_preset,
2510 BehaviorPreset::Normal
2511 );
2512 }
2513
2514 #[test]
2515 fn appearance_default_render_status_is_pending() {
2516 assert_eq!(
2517 AgentAppearance::default().render_status,
2518 RenderStatus::Pending
2519 );
2520 }
2521
2522 #[test]
2523 fn render_status_serde_round_trip() {
2524 let cases = [
2525 RenderStatus::Pending,
2526 RenderStatus::Rendering { done: 3, total: 12 },
2527 RenderStatus::Ready,
2528 RenderStatus::Failed {
2529 reason: "out of quota".into(),
2530 },
2531 ];
2532 for status in cases {
2533 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
2534 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
2535 assert_eq!(status, back);
2536 }
2537 }
2538
2539 #[test]
2540 fn agent_profile_with_appearance_round_trips() {
2541 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2542 let yaml = format!(
2543 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
2544 );
2545 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2546 assert_eq!(profile.appearance.style_preset, "chiikawa");
2547 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2548
2549 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2550 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2551 assert_eq!(profile.appearance, back.appearance);
2552 }
2553
2554 #[test]
2555 fn legacy_profile_without_appearance_uses_default() {
2556 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2557 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2558 assert_eq!(profile.appearance.style_preset, "default-blob");
2559 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2560 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2561 }
2562
2563 #[test]
2564 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2565 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2566 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2567 assert!(p.file_actions.is_empty());
2568 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2569 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2570 }
2571}
2572
2573#[cfg(test)]
2574mod federation_tests {
2575 use super::*;
2576
2577 #[test]
2578 fn test_pattern_filter_default() {
2579 let f = PatternFilter::default();
2580 assert_eq!(f.max_count, 200);
2581 assert_eq!(f.importance_min, 0.0);
2582 assert!(f.tier.is_empty());
2583 }
2584
2585 #[test]
2586 fn test_federation_config_roundtrip() {
2587 let cfg = FederationConfig {
2588 filter: PatternFilter {
2589 tier: vec!["core".into()],
2590 max_count: 50,
2591 ..Default::default()
2592 },
2593 snapshot_ref: Some(SnapshotRef {
2594 knowledge_commit: "abc123def456".into(),
2595 taken_at: "2026-05-19T00:00:00Z".into(),
2596 filter: PatternFilter::default(),
2597 }),
2598 evidence_flush_interval_minutes: 15,
2599 };
2600 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2601 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2602 assert_eq!(cfg, back);
2603 }
2604
2605 #[test]
2606 fn test_agent_profile_federation_defaults() {
2607 let cfg = FederationConfig::default();
2611 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2612 assert!(cfg.snapshot_ref.is_none());
2613 }
2614}
2615
2616#[cfg(test)]
2617mod skill_card_tests {
2618 use super::*;
2619
2620 #[test]
2621 fn installed_skills_default_to_empty_when_absent() {
2622 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2623 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2624 assert!(p.installed_skills.is_empty());
2625 }
2626
2627 #[test]
2628 fn installed_skills_roundtrip_preserves_entries() {
2629 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2630 let yaml = format!(
2631 "{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"
2632 );
2633 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2634 assert_eq!(p.installed_skills.len(), 1);
2635 assert_eq!(p.installed_skills[0].name, "s1");
2636 assert_eq!(p.installed_skills[0].abstract_text, "does things");
2637 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2638
2639 let out = serde_yaml_ng::to_string(&p).unwrap();
2640 assert!(out.contains("abstract: does things"));
2641 assert!(out.contains("pattern: /find"));
2642
2643 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2644 assert_eq!(p.installed_skills, back.installed_skills);
2645 }
2646
2647 #[test]
2648 fn installed_skills_minimal_entry_serializes_compactly() {
2649 let entry = SkillCardEntry {
2651 name: "minimal".into(),
2652 ..Default::default()
2653 };
2654 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2655 assert!(yaml.contains("name: minimal"));
2656 assert!(
2657 !yaml.contains("version:"),
2658 "empty version must be skipped: {yaml}"
2659 );
2660 assert!(
2661 !yaml.contains("publisher:"),
2662 "empty publisher must be skipped: {yaml}"
2663 );
2664 assert!(
2665 !yaml.contains("abstract:"),
2666 "empty abstract must be skipped: {yaml}"
2667 );
2668 }
2669}
2670
2671#[cfg(test)]
2672mod tool_policy_tests {
2673 use super::*;
2674
2675 fn rules() -> Vec<ToolRule> {
2676 vec![
2677 ToolRule {
2678 pattern: "mcp__github__merge_pr".into(),
2679 policy: ToolPolicy::Ask,
2680 risk: None,
2681 },
2682 ToolRule {
2683 pattern: "mcp__github__*".into(),
2684 policy: ToolPolicy::Allow,
2685 risk: None,
2686 },
2687 ToolRule {
2688 pattern: "mcp__*".into(),
2689 policy: ToolPolicy::Deny,
2690 risk: None,
2691 },
2692 ToolRule {
2693 pattern: "bash".into(),
2694 policy: ToolPolicy::Allow,
2695 risk: None,
2696 },
2697 ]
2698 }
2699
2700 #[test]
2701 fn exact_beats_glob() {
2702 assert_eq!(
2703 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2704 ToolPolicy::Ask
2705 );
2706 }
2707
2708 #[test]
2709 fn longer_glob_wins() {
2710 assert_eq!(
2711 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2712 ToolPolicy::Allow
2713 );
2714 }
2715
2716 #[test]
2717 fn shorter_glob_fallback() {
2718 assert_eq!(
2719 resolve_tool_policy(&rules(), "mcp__slack__send"),
2720 ToolPolicy::Deny
2721 );
2722 }
2723
2724 #[test]
2725 fn exact_bash() {
2726 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2727 }
2728
2729 #[test]
2730 fn unknown_tool_defaults_ask() {
2731 assert_eq!(
2732 resolve_tool_policy(&rules(), "unknown_tool"),
2733 ToolPolicy::Ask
2734 );
2735 }
2736
2737 #[test]
2738 fn empty_rules_defaults_ask() {
2739 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2740 }
2741
2742 fn minimal_entitlements_yaml() -> &'static str {
2743 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2744 }
2745
2746 #[test]
2747 fn entitlements_tools_defaults_empty() {
2748 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2749 assert!(e.tools.is_empty());
2750 }
2751
2752 #[test]
2753 fn entitlements_tools_roundtrip() {
2754 let base = minimal_entitlements_yaml();
2755 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2756 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2757 assert_eq!(e.tools.len(), 1);
2758 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2759 let y = serde_yaml_ng::to_string(&e).unwrap();
2760 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2761 assert_eq!(back.tools.len(), 1);
2762 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2763 }
2764 #[test]
2765 fn denylist_membership_and_mutation() {
2766 let mut list: Vec<String> = vec![];
2767 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2768
2769 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2771 assert_eq!(list, ["a"]);
2772
2773 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2775
2776 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2778 assert!(list.is_empty());
2779
2780 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2782 }
2783
2784 #[test]
2785 fn addon_group_rule_truth_table() {
2786 let mut p = crate::agent::AgentProfile::default_for_tests();
2787 p.addons.push(AddonRef {
2788 id: "grp".into(),
2789 source: "claude-local:grp@1.0.0".into(),
2790 enabled: false,
2791 skills: vec!["g_skill".into()],
2792 mcp: vec!["g_mcp".into()],
2793 commands: vec!["g_cmd".into()],
2794 content_hash: None,
2795 fetch_ref: None,
2796 fetch_plugin: None,
2797 });
2798
2799 assert!(p.skill_enabled("standalone"));
2801 assert!(p.mcp_enabled("standalone_mcp"));
2802
2803 assert!(!p.skill_enabled("g_skill"));
2805 assert!(!p.mcp_enabled("g_mcp"));
2806
2807 assert!(p.set_addon_enabled("grp", true));
2809 assert!(p.skill_enabled("g_skill"));
2810 assert!(p.mcp_enabled("g_mcp"));
2811
2812 p.set_skill_enabled("g_skill", false);
2814 assert!(!p.skill_enabled("g_skill"));
2815
2816 assert!(!p.set_addon_enabled("nope", true));
2818
2819 p.disable_all_addons();
2821 assert!(p.addons.iter().all(|g| !g.enabled));
2822 assert!(!p.skill_enabled("g_skill"));
2823 assert!(!p.skill_enabled("g_cmd"));
2824 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2830 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);
2836 assert!(p.skill_enabled("g_skill"));
2837 }
2838
2839 #[test]
2840 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2841 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2843 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2844 assert_eq!(r.content_hash, None);
2845 assert_eq!(r.fetch_ref, None);
2846
2847 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2849 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2850 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2851 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2852 let back = serde_yaml_ng::to_string(&r2).unwrap();
2853 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2854 assert_eq!(r2, r3);
2855 }
2856}
2857
2858#[cfg(test)]
2859mod lockfile_compat_tests {
2860 use super::*;
2861
2862 #[test]
2863 fn lockfile_new_fields_default_for_old_locks() {
2864 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2867 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2868 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2869 let lock: LockFile = serde_json::from_str(old).unwrap();
2870 assert_eq!(lock.build_sha, "");
2871 assert_eq!(lock.proto_version, 0);
2872 }
2873}
2874
2875#[cfg(test)]
2876mod remote_mcp_tests {
2877 use super::*;
2878
2879 #[test]
2880 fn mcp_entry_roundtrips_remote_bearer() {
2881 let e = McpServerEntry {
2882 name: "gh".into(),
2883 command: String::new(),
2884 url: Some("https://api.example.com/mcp".into()),
2885 auth: Some(McpAuth::Bearer {
2886 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2887 }),
2888 ..Default::default()
2889 };
2890 let y = serde_yaml_ng::to_string(&e).unwrap();
2891 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2892 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2893 assert!(matches!(
2894 back.auth,
2895 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2896 ));
2897 let legacy: McpServerEntry =
2899 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2900 assert!(legacy.url.is_none());
2901 assert!(legacy.auth.is_none());
2902 }
2903}
2904
2905#[cfg(test)]
2906mod requires_programs_tests {
2907 #[test]
2908 fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2909 let with = r#"
2910name: research-gateway
2911command: mur-research-gateway
2912requires_programs:
2913 - name: lightpanda
2914 detect: { file: "~/.mur/aura/lightpanda" }
2915 reason: "render tier"
2916 registry: lightpanda
2917"#;
2918 let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2919 assert_eq!(e.requires_programs.len(), 1);
2920 assert_eq!(e.requires_programs[0].name, "lightpanda");
2921
2922 let without = "name: x\ncommand: y\n";
2924 let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2925 assert!(e2.requires_programs.is_empty());
2926 }
2927}
2928
2929#[cfg(test)]
2930mod secrets_field_tests {
2931 #[test]
2935 fn secrets_names_round_trip_and_are_absent_when_empty() {
2936 let mut p = crate::agent::AgentProfile::default_for_tests();
2937 let yaml = serde_yaml::to_string(&p).unwrap();
2938 assert!(
2939 !yaml.contains("secrets:"),
2940 "empty list must not be written: {yaml}"
2941 );
2942 p.secrets = vec!["GITEA_TOKEN".into()];
2943 let yaml = serde_yaml::to_string(&p).unwrap();
2944 let back: crate::agent::AgentProfile = serde_yaml::from_str(&yaml).unwrap();
2945 assert_eq!(back.secrets, vec!["GITEA_TOKEN".to_string()]);
2946 }
2947}