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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub addons: Vec<AddonRef>,
121 pub transport: TransportConfig,
122 pub communication: CommunicationConfig,
123 #[serde(default)]
124 pub capabilities: Vec<String>,
125 pub entitlements: Entitlements,
126 #[serde(default)]
127 pub notifications: NotificationsConfig,
128 pub retry: RetryConfig,
129 pub lifecycle: LifecycleConfig,
130 #[serde(default)]
133 pub identity: IdentityConfig,
134 #[serde(default)]
135 pub file_transfer: FileTransferConfig,
136 #[serde(default)]
137 pub deployment: DeploymentConfig,
138 #[serde(default)]
141 pub companion: CompanionConfig,
142 #[serde(default)]
144 pub hitl: HitlConfig,
145 #[serde(default)]
147 pub voice: VoiceConfig,
148 #[serde(default)]
150 pub hooks: crate::HooksConfig,
151 #[serde(default)]
154 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
155 pub created_at: String,
156 pub updated_at: String,
157 #[serde(default)]
159 pub appearance: AgentAppearance,
160 #[serde(default)]
162 pub federation: FederationConfig,
163
164 #[serde(default)]
168 pub file_actions: Vec<crate::action::FileAction>,
169
170 #[serde(default)]
172 pub action_pipeline: crate::action::ActionPipelineConfig,
173
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub requires_programs: Vec<ProgramDep>,
178
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub requires_capabilities: Vec<String>,
183}
184
185fn default_algorithm() -> String {
186 "ed25519".into()
187}
188
189pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
193pub struct IdentityConfig {
194 #[serde(default)]
197 pub pubkey: String,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub owner: Option<String>,
201
202 #[serde(default = "default_algorithm")]
205 pub algorithm: String,
206 #[serde(default)]
208 pub key_version: u32,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub created_at_key: Option<String>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub previous_pubkey: Option<String>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub previous_key_version: Option<u32>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub grace_expires_at: Option<String>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub rotated_at: Option<String>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub emergency_rekey_at: Option<String>,
228}
229
230impl Default for IdentityConfig {
231 fn default() -> Self {
232 Self {
233 pubkey: String::new(),
234 owner: None,
235 algorithm: default_algorithm(),
236 key_version: 0,
237 created_at_key: None,
238 previous_pubkey: None,
239 previous_key_version: None,
240 grace_expires_at: None,
241 rotated_at: None,
242 emergency_rekey_at: None,
243 }
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248pub struct Persona {
249 pub category: PersonaCategory,
250 pub description: String,
251 pub traits: PersonaTraits,
252}
253
254#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
255#[serde(rename_all = "lowercase")]
256pub enum PersonaCategory {
257 Research,
258 Automation,
259 Monitor,
260 Notify,
261 Commerce,
262 Custom,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
266pub struct PersonaTraits {
267 pub tone: String,
268 pub risk: String,
269 pub verbosity: String,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
273pub struct ModelConfig {
274 pub provider: String,
275 pub name: String,
276 #[serde(default)]
277 pub params: BTreeMap<String, serde_yaml_ng::Value>,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
281pub struct McpServerEntry {
282 pub name: String,
283 pub command: String,
284 #[serde(default)]
285 pub args: Vec<String>,
286
287 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub binary_sha256: Option<String>,
294
295 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub description_hash: Option<String>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub publisher: Option<McpPublisherInfo>,
308
309 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
314
315 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub timeout_secs: Option<u32>,
320
321 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub network: Option<McpServerNetwork>,
327
328 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub url: Option<String>,
332
333 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub auth: Option<McpAuth>,
337
338 #[serde(default, skip_serializing_if = "Vec::is_empty")]
341 pub requires_programs: Vec<ProgramDep>,
342
343 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub package: Option<McpPackagePin>,
352}
353
354#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
367pub struct McpPackagePin {
368 pub runner: String,
370 pub name: String,
372 pub version: String,
374 pub install_dir: String,
376 pub lockfile_sha256: String,
378
379 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub signatures_missing: Option<u32>,
392
393 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub provenance: Option<String>,
404}
405
406impl McpPackagePin {
407 pub fn lockfile_name(&self) -> &'static str {
414 match self.runner.as_str() {
415 "pypi" => "requirements.lock",
416 _ => "package-lock.json",
417 }
418 }
419
420 pub fn lockfile_path(&self) -> std::path::PathBuf {
426 std::path::Path::new(&self.install_dir).join(self.lockfile_name())
427 }
428}
429
430#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
432#[serde(rename_all = "snake_case", tag = "kind")]
433pub enum McpAuth {
434 Bearer { token: crate::secret::SecretRef },
436 Oauth(OauthAuth),
438}
439
440#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
442pub struct OauthAuth {
443 pub token_endpoint: String,
445 pub client_id: String,
447 pub access_token: crate::secret::SecretRef,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub refresh_token: Option<crate::secret::SecretRef>,
452 #[serde(default)]
454 pub expires_at: u64,
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
459#[serde(rename_all = "snake_case")]
460pub enum McpNetMode {
461 #[default]
475 Inherit,
476 Restricted,
478 BroadAudited,
484 Off,
486}
487
488pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
497
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
500pub struct McpServerNetwork {
501 #[serde(default)]
502 pub mode: McpNetMode,
503 #[serde(default)]
504 pub allow_hosts: Vec<String>,
505 #[serde(default)]
508 pub deny_hosts: Vec<String>,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub authorization: Option<EgressAuthorization>,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
523pub struct AddonRef {
524 pub id: String,
526 pub source: String,
528 #[serde(default)]
529 pub enabled: bool,
530 #[serde(default, skip_serializing_if = "Vec::is_empty")]
531 pub skills: Vec<String>,
532 #[serde(default, skip_serializing_if = "Vec::is_empty")]
533 pub mcp: Vec<String>,
534 #[serde(default, skip_serializing_if = "Vec::is_empty")]
535 pub commands: Vec<String>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub content_hash: Option<String>,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub fetch_ref: Option<String>,
545 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub fetch_plugin: Option<String>,
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
559pub struct McpPublisherInfo {
560 pub name: String,
563
564 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub homepage: Option<String>,
569
570 #[serde(default, skip_serializing_if = "Option::is_none")]
573 pub registry_id: Option<String>,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
577pub struct TransportConfig {
578 pub stdio: bool,
579 pub socket: SocketTransportConfig,
580 #[serde(default)]
581 pub tcp: TcpTransportConfig,
582 #[serde(default)]
586 pub webhook: WebhookTransportConfig,
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
590pub struct TcpTransportConfig {
591 #[serde(default)]
592 pub enabled: bool,
593 #[serde(default)]
594 pub bind: String,
595 #[serde(default)]
596 pub noise: NoiseConfig,
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
613pub struct WebhookTransportConfig {
614 #[serde(default)]
615 pub enabled: bool,
616 #[serde(default = "default_webhook_bind")]
617 pub bind: String,
618 #[serde(default = "default_webhook_port")]
619 pub port: u16,
620 #[serde(default)]
624 pub hmac_secret_ref: String,
625}
626
627fn default_webhook_bind() -> String {
628 "127.0.0.1".to_string()
629}
630
631fn default_webhook_port() -> u16 {
632 6789
633}
634
635impl Default for WebhookTransportConfig {
636 fn default() -> Self {
637 Self {
638 enabled: false,
639 bind: default_webhook_bind(),
640 port: default_webhook_port(),
641 hmac_secret_ref: String::new(),
642 }
643 }
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
647pub struct NoiseConfig {
648 pub pattern: String,
649}
650
651impl Default for NoiseConfig {
652 fn default() -> Self {
653 Self {
654 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
655 }
656 }
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
660pub struct SocketTransportConfig {
661 pub enabled: bool,
662 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub auth: Option<AuthConfig>,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
668pub struct AuthConfig {
669 pub scheme: String,
670 pub token_file: String,
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
674pub struct CommunicationConfig {
675 #[serde(default = "default_accepts_all")]
676 pub accepts_from: Vec<String>,
677 #[serde(default)]
678 pub sends_to: Vec<String>,
679}
680fn default_accepts_all() -> Vec<String> {
681 vec!["*".to_string()]
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
685pub struct Entitlements {
686 pub network: NetworkEntitlement,
687 pub filesystem: FilesystemEntitlement,
688 pub processes: ProcessesEntitlement,
689 #[serde(default)]
690 pub syscalls: SyscallsEntitlement,
691 #[serde(default)]
692 pub limits: LimitsEntitlement,
693 #[serde(default)]
696 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
697 #[serde(default, skip_serializing_if = "Vec::is_empty")]
699 pub tools: Vec<ToolRule>,
700 #[serde(default = "default_true")]
705 pub fail_closed_on_sandbox_error: bool,
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
709pub struct NetworkEntitlement {
710 pub inbound: InboundNetwork,
711 pub outbound: OutboundNetwork,
712}
713
714#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
715pub struct InboundNetwork {
716 #[serde(default)]
717 pub ports: Vec<u16>,
718}
719
720#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
721pub struct OutboundNetwork {
722 pub mode: NetworkOutboundMode,
723 #[serde(default)]
724 pub allow_hosts: Vec<String>,
725 #[serde(default = "default_protocols")]
726 pub protocols: Vec<String>,
727 #[serde(default)]
728 pub resolve_dns: ResolveDnsConfig,
729}
730fn default_protocols() -> Vec<String> {
731 vec!["tcp".to_string()]
732}
733
734#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
738pub struct EgressAuthorization {
739 pub authorized_by: String,
740 pub authorized_at_ms: u64,
741}
742
743#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
744#[serde(rename_all = "lowercase")]
745pub enum NetworkOutboundMode {
746 Unrestricted,
747 Restricted,
748 ProxyOnly,
752 Off,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
756pub struct ResolveDnsConfig {
757 #[serde(default = "default_dns_mode")]
758 pub mode: String,
759 #[serde(default)]
760 pub servers: Vec<String>,
761}
762impl Default for ResolveDnsConfig {
763 fn default() -> Self {
764 Self {
765 mode: default_dns_mode(),
766 servers: vec![],
767 }
768 }
769}
770fn default_dns_mode() -> String {
771 "system".to_string()
772}
773
774pub const AUTHORING_DIRS: [&str; 4] = ["skills", "workflows", "fleets", "artifacts"];
785
786#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
787pub struct FilesystemEntitlement {
788 #[serde(default)]
789 pub read: Vec<String>,
790 #[serde(default)]
791 pub write: Vec<String>,
792 #[serde(default)]
793 pub deny: Vec<String>,
794}
795
796#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
797pub struct ProcessesEntitlement {
798 pub spawn: SpawnEntitlement,
799}
800
801#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
802pub struct SpawnEntitlement {
803 pub mode: SpawnMode,
804 #[serde(default)]
805 pub allowed: Vec<String>,
806 #[serde(default)]
821 pub allowed_dirs: Vec<String>,
822}
823
824#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
825#[serde(rename_all = "lowercase")]
826pub enum SpawnMode {
827 Allowlist,
828 Any,
829 None,
830 Strict,
836}
837
838#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
839pub struct SyscallsEntitlement {
840 #[serde(default = "default_syscalls_mode")]
841 pub mode: String,
842 #[serde(default)]
843 pub extra_deny: Vec<String>,
844}
845fn default_syscalls_mode() -> String {
846 "default".to_string()
847}
848
849#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
850pub struct LimitsEntitlement {
851 #[serde(default)]
852 pub cpu_seconds: Option<u64>,
853 #[serde(default = "default_memory_mb")]
854 pub memory_mb: u64,
855 #[serde(default = "default_fds")]
856 pub file_descriptors: u32,
857 #[serde(default = "default_procs")]
858 pub processes: u32,
859}
860fn default_memory_mb() -> u64 {
861 512
862}
863fn default_fds() -> u32 {
864 1024
865}
866fn default_procs() -> u32 {
867 32
868}
869
870#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
871#[serde(rename_all = "lowercase")]
872pub enum ToolPolicy {
873 Allow,
874 #[default]
875 Ask,
876 Deny,
877}
878
879#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
880pub struct ToolRule {
881 pub pattern: String,
882 pub policy: ToolPolicy,
883 #[serde(default, skip_serializing_if = "Option::is_none")]
886 pub risk: Option<crate::hitl::RiskTier>,
887}
888
889pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
893 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
894}
895
896pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
901 for rule in rules {
902 if rule.pattern == tool_name {
903 return Some(rule.policy);
904 }
905 }
906 let mut best: Option<(&ToolRule, usize)> = None;
907 for rule in rules {
908 if let Some(prefix) = rule.pattern.strip_suffix('*')
909 && tool_name.starts_with(prefix)
910 {
911 let len = prefix.len();
912 if best.is_none_or(|(_, best_len)| len > best_len) {
913 best = Some((rule, len));
914 }
915 }
916 }
917 best.map(|(rule, _)| rule.policy)
918}
919
920#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
921pub struct NotificationsConfig {
922 #[serde(default)]
923 pub on_task_complete: Vec<NotificationTarget>,
924 #[serde(default)]
925 pub on_error: Vec<NotificationTarget>,
926 #[serde(default)]
927 pub on_shutdown: Vec<NotificationTarget>,
928}
929
930#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
931#[serde(tag = "target", rename_all = "lowercase")]
932pub enum NotificationTarget {
933 Agent {
934 name: String,
935 },
936 Commander,
937 Email {
938 address: String,
939 #[serde(default)]
940 smtp_config_file: Option<String>,
941 },
942 Slack {
943 #[serde(default)]
944 channel: Option<String>,
945 #[serde(default)]
946 webhook_url_env: Option<String>,
947 },
948 Webpush {
949 url: String,
950 },
951 Webhook {
952 url: String,
953 #[serde(default = "default_post")]
954 method: String,
955 #[serde(default)]
956 auth: Option<String>,
957 },
958}
959fn default_post() -> String {
960 "POST".to_string()
961}
962
963#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
964pub struct RetryConfig {
965 pub llm: RetryPolicy,
966 pub tool: RetryPolicy,
967}
968
969#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
970pub struct RetryPolicy {
971 pub max_retries: u32,
972 pub backoff: BackoffStrategy,
973 pub initial_delay_ms: u64,
974 #[serde(default)]
975 pub max_delay_ms: Option<u64>,
976 #[serde(default)]
977 pub retry_on: Vec<String>,
978}
979
980#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
981#[serde(rename_all = "lowercase")]
982pub enum BackoffStrategy {
983 Linear,
984 Exponential,
985 Fixed,
986}
987
988#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
989pub struct LifecycleConfig {
990 pub restart: RestartPolicy,
991 #[serde(default = "default_max_restarts")]
992 pub max_restarts: u32,
993 #[serde(default = "default_window")]
994 pub restart_window_secs: u64,
995 #[serde(default = "default_stop_timeout")]
996 pub stop_timeout_secs: u64,
997 #[serde(default = "default_mcp_required")]
998 pub mcp_required: bool,
999 #[serde(default)]
1000 pub execution: ExecutionMode,
1001 #[serde(default)]
1002 pub schedule: Vec<ScheduleEntry>,
1003 #[serde(default)]
1004 pub idle_triggers: Vec<IdleTrigger>,
1005}
1006fn default_max_restarts() -> u32 {
1007 3
1008}
1009fn default_window() -> u64 {
1010 600
1011}
1012fn default_stop_timeout() -> u64 {
1013 15
1014}
1015fn default_mcp_required() -> bool {
1016 true
1017}
1018
1019#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1020#[serde(rename_all = "snake_case")]
1021pub enum RestartPolicy {
1022 Never,
1023 OnFailure,
1024 Always,
1025}
1026
1027#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1028#[serde(rename_all = "snake_case")]
1029pub enum ExecutionMode {
1030 #[default]
1031 Daemon,
1032 OnDemand,
1033}
1034
1035pub const SCHEDULE_PROPOSAL_DIR: &str = "schedule-proposals";
1050
1051pub const SCHEDULE_CHANNEL_FILE: &str = "schedule-channel";
1055
1056#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1058pub struct ScheduleProposal {
1059 pub cron: String,
1060 pub message: String,
1061 #[serde(default, skip_serializing_if = "Option::is_none")]
1065 pub asked_for: Option<String>,
1066 #[serde(default, skip_serializing_if = "Option::is_none")]
1070 pub not_after: Option<String>,
1071 pub proposed_at: String,
1072}
1073
1074#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1075pub struct ScheduleEntry {
1076 pub cron: String,
1077 pub message: String,
1078 #[serde(default, skip_serializing_if = "Option::is_none")]
1079 pub sends_to: Option<String>,
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1092 pub not_after: Option<String>,
1093}
1094
1095#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1096pub struct IdleTrigger {
1097 pub after_secs: u64,
1099 pub message: String,
1101 #[serde(default, skip_serializing_if = "Option::is_none")]
1103 pub sends_to: Option<String>,
1104 #[serde(default = "default_idle_cooldown")]
1107 pub cooldown_secs: u64,
1108 #[serde(default = "default_true")]
1111 pub respect_quiet_hours: bool,
1112}
1113
1114fn default_idle_cooldown() -> u64 {
1115 600
1116}
1117pub fn name_enabled(denylist: &[String], name: &str) -> bool {
1119 !denylist.iter().any(|n| n == name)
1120}
1121
1122pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
1125 if enabled {
1126 list.retain(|n| n != name);
1127 } else if !list.iter().any(|n| n == name) {
1128 list.push(name.to_string());
1129 }
1130}
1131
1132fn default_true() -> bool {
1133 true
1134}
1135
1136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1137pub struct FileTransferConfig {
1138 #[serde(default = "default_accept_max")]
1139 pub accept_incoming_file_max_bytes: u64,
1140 #[serde(default = "default_accept_total")]
1141 pub accept_incoming_total_per_hour: u64,
1142 #[serde(default = "default_approval_threshold")]
1143 pub require_approval_above_bytes: u64,
1144 #[serde(default = "default_reject_paths")]
1145 pub reject_paths: Vec<String>,
1146 #[serde(default = "default_allowed_mime")]
1147 pub allowed_mime_types: Vec<String>,
1148}
1149
1150impl Default for FileTransferConfig {
1151 fn default() -> Self {
1152 Self {
1153 accept_incoming_file_max_bytes: default_accept_max(),
1154 accept_incoming_total_per_hour: default_accept_total(),
1155 require_approval_above_bytes: default_approval_threshold(),
1156 reject_paths: default_reject_paths(),
1157 allowed_mime_types: default_allowed_mime(),
1158 }
1159 }
1160}
1161
1162fn default_accept_max() -> u64 {
1163 10_485_760
1164}
1165fn default_accept_total() -> u64 {
1166 104_857_600
1167}
1168fn default_approval_threshold() -> u64 {
1169 10_485_760
1170}
1171fn default_reject_paths() -> Vec<String> {
1172 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
1173}
1174fn default_allowed_mime() -> Vec<String> {
1175 vec!["*".into()]
1176}
1177
1178#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1179#[serde(rename_all = "snake_case")]
1180pub enum DeploymentType {
1181 #[default]
1182 Laptop,
1183 Vm,
1184 Docker,
1185 K8s,
1186 Lambda,
1187}
1188
1189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1190pub struct DeploymentConfig {
1191 #[serde(rename = "type", default)]
1192 pub deployment_type: DeploymentType,
1193 #[serde(default, skip_serializing_if = "Option::is_none")]
1194 pub region: Option<String>,
1195 #[serde(default = "default_env")]
1196 pub environment: Option<String>,
1197}
1198
1199impl Default for DeploymentConfig {
1200 fn default() -> Self {
1201 Self {
1202 deployment_type: DeploymentType::default(),
1203 region: None,
1204 environment: default_env(),
1205 }
1206 }
1207}
1208
1209fn default_env() -> Option<String> {
1210 Some("dev".into())
1211}
1212
1213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1219pub struct DroppedGrant {
1220 pub path: String,
1221 pub verb: String,
1223 pub reason: String,
1224}
1225
1226pub fn filesystem_grants_digest(fs: &FilesystemEntitlement) -> String {
1233 use sha2::{Digest, Sha256};
1234 let mut h = Sha256::new();
1235 for (label, list) in [("r", &fs.read), ("w", &fs.write), ("d", &fs.deny)] {
1236 let mut sorted = list.clone();
1237 sorted.sort();
1238 for p in sorted {
1239 h.update(label.as_bytes());
1240 h.update(b"\0");
1241 h.update(p.as_bytes());
1242 h.update(b"\0");
1243 }
1244 }
1245 format!("sha256:{:x}", h.finalize())
1246}
1247
1248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1256pub struct SandboxRecord {
1257 pub enforcing: bool,
1261 pub mode: String,
1263 pub granted_digest: String,
1268 #[serde(default)]
1270 pub dropped: Vec<DroppedGrant>,
1271}
1272
1273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1274pub struct LockFile {
1275 pub schema: u32,
1276 pub uuid: String,
1277 pub name: String,
1278 pub pid: u32,
1279 pub ppid: u32,
1280 pub started_at: String,
1281 pub binary_version: String,
1282 pub transports: LockTransports,
1283 pub card_digest: String,
1284 pub capabilities: Vec<String>,
1285 #[serde(default)]
1288 pub build_sha: String,
1289 #[serde(default)]
1292 pub proto_version: u32,
1293 #[serde(default, skip_serializing_if = "Option::is_none")]
1296 pub sandbox: Option<SandboxRecord>,
1297}
1298
1299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1300pub struct LockTransports {
1301 pub stdio: bool,
1302 #[serde(default)]
1303 pub unix_socket: Option<String>,
1304 #[serde(default)]
1305 pub tcp: Option<String>,
1306 #[serde(default)]
1311 pub webhook: Option<String>,
1312}
1313
1314#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1321#[serde(rename_all = "snake_case")]
1322pub enum VoiceId {
1323 #[default]
1325 AfHeart,
1326 AfBella,
1327 AfNicole,
1328 AmAdam,
1329 AmMichael,
1330}
1331
1332impl VoiceId {
1333 pub fn style_index(&self) -> usize {
1335 match self {
1336 VoiceId::AfHeart => 0,
1337 VoiceId::AfBella => 1,
1338 VoiceId::AfNicole => 2,
1339 VoiceId::AmAdam => 3,
1340 VoiceId::AmMichael => 4,
1341 }
1342 }
1343
1344 pub fn as_str(&self) -> &'static str {
1346 match self {
1347 VoiceId::AfHeart => "af_heart",
1348 VoiceId::AfBella => "af_bella",
1349 VoiceId::AfNicole => "af_nicole",
1350 VoiceId::AmAdam => "am_adam",
1351 VoiceId::AmMichael => "am_michael",
1352 }
1353 }
1354}
1355
1356impl std::str::FromStr for VoiceId {
1357 type Err = anyhow::Error;
1358
1359 fn from_str(s: &str) -> anyhow::Result<Self> {
1360 match s {
1361 "af_heart" => Ok(VoiceId::AfHeart),
1362 "af_bella" => Ok(VoiceId::AfBella),
1363 "af_nicole" => Ok(VoiceId::AfNicole),
1364 "am_adam" => Ok(VoiceId::AmAdam),
1365 "am_michael" => Ok(VoiceId::AmMichael),
1366 other => anyhow::bail!(
1367 "unknown voice ID '{other}' \
1368 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1369 ),
1370 }
1371 }
1372}
1373
1374#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1377pub struct VoiceConfig {
1378 #[serde(default)]
1380 pub enabled: bool,
1381 #[serde(default)]
1383 pub voice_id: VoiceId,
1384 #[serde(default, skip_serializing_if = "Option::is_none")]
1387 pub input_device: Option<String>,
1388}
1389
1390#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1395pub struct HitlConfig {
1396 #[serde(default = "default_hitl_timeout_secs")]
1397 pub timeout_secs: u32,
1398 #[serde(default)]
1402 pub max_iterations: Option<u32>,
1403 #[serde(default)]
1408 pub max_tokens: Option<u64>,
1409}
1410
1411fn default_hitl_timeout_secs() -> u32 {
1412 300
1413}
1414
1415impl Default for HitlConfig {
1416 fn default() -> Self {
1417 Self {
1418 timeout_secs: default_hitl_timeout_secs(),
1419 max_iterations: None,
1420 max_tokens: None,
1421 }
1422 }
1423}
1424
1425#[cfg(test)]
1426mod hitl_tests {
1427 use super::*;
1428
1429 #[test]
1430 fn hitl_config_default_max_iterations_is_none() {
1431 let cfg = HitlConfig::default();
1432 assert!(cfg.max_iterations.is_none());
1433 }
1434
1435 #[test]
1436 fn hitl_config_max_iterations_explicit() {
1437 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1438 assert_eq!(cfg.max_iterations, Some(5));
1439 }
1440
1441 #[test]
1442 fn hitl_config_default_max_tokens_is_none() {
1443 let cfg = HitlConfig::default();
1444 assert!(cfg.max_tokens.is_none());
1445 }
1446
1447 #[test]
1448 fn hitl_config_max_tokens_explicit() {
1449 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1450 assert_eq!(cfg.max_tokens, Some(250_000));
1451 }
1452}
1453
1454#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1460pub struct CompanionConfig {
1461 #[serde(default)]
1462 pub enabled: bool,
1463 #[serde(default = "default_locale")]
1464 pub locale: String,
1465 #[serde(default)]
1466 pub relationship: Relationship,
1467 #[serde(default)]
1468 pub voice_overrides: VoiceOverrides,
1469 #[serde(default)]
1470 pub onboarding: OnboardingState,
1471 #[serde(default)]
1472 pub rhythm: RhythmConfig,
1473 #[serde(default)]
1474 pub proactive: ProactiveConfig,
1475}
1476
1477pub fn default_locale() -> String {
1486 sys_locale::get_locale()
1487 .filter(|l| !l.is_empty())
1488 .or_else(|| std::env::var("LANG").ok().and_then(|v| normalize_lang(&v)))
1489 .unwrap_or_else(|| "en-US".into())
1490}
1491
1492fn normalize_lang(v: &str) -> Option<String> {
1494 v.split('.')
1495 .next()
1496 .map(|s| s.replace('_', "-"))
1497 .filter(|s| !s.is_empty())
1498}
1499
1500#[cfg(test)]
1501mod locale_tests {
1502 use super::normalize_lang;
1503
1504 #[test]
1505 fn lang_with_encoding_and_region_normalizes() {
1506 assert_eq!(normalize_lang("zh_TW.UTF-8").as_deref(), Some("zh-TW"));
1507 }
1508
1509 #[test]
1510 fn lang_without_encoding_normalizes() {
1511 assert_eq!(normalize_lang("en_US").as_deref(), Some("en-US"));
1512 }
1513
1514 #[test]
1515 fn lang_with_script_keeps_script() {
1516 assert_eq!(
1517 normalize_lang("zh_Hant_TW.UTF-8").as_deref(),
1518 Some("zh-Hant-TW")
1519 );
1520 }
1521
1522 #[test]
1523 fn empty_lang_yields_none() {
1524 assert_eq!(normalize_lang(""), None);
1525 }
1526}
1527
1528#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1529pub struct VoiceOverrides {
1530 #[serde(default, skip_serializing_if = "Option::is_none")]
1531 pub name_for_user: Option<String>,
1532 #[serde(default, skip_serializing_if = "Option::is_none")]
1533 pub formality: Option<Formality>,
1534 #[serde(default, skip_serializing_if = "Option::is_none")]
1535 pub extra_instructions: Option<String>,
1536}
1537
1538#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1539pub struct FirstMemory {
1540 pub text: String,
1541 pub established_at: chrono::DateTime<chrono::Utc>,
1542}
1543
1544#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1545pub struct OnboardingState {
1546 #[serde(default, skip_serializing_if = "Option::is_none")]
1547 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1548 #[serde(default)]
1549 pub version: u32,
1550 #[serde(default, skip_serializing_if = "Option::is_none")]
1551 pub agent_display_name: Option<String>,
1552 #[serde(default, skip_serializing_if = "Option::is_none")]
1553 pub first_memory: Option<FirstMemory>,
1554}
1555
1556#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1559pub struct RhythmConfig {
1560 #[serde(default)]
1561 pub enabled: bool,
1562}
1563
1564#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1565pub struct ProactiveConfig {
1566 #[serde(default)]
1567 pub enabled: bool,
1568 #[serde(default, skip_serializing_if = "Option::is_none")]
1570 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1571 #[serde(default, skip_serializing_if = "Option::is_none")]
1572 pub quiet_hours: Option<QuietHours>,
1573 #[serde(default, skip_serializing_if = "Option::is_none")]
1574 pub active_hours: Option<ActiveHours>,
1575 #[serde(default = "default_daily_cap")]
1576 pub daily_cap: u8,
1577 #[serde(default = "default_channels")]
1578 pub channels: Vec<String>,
1579 #[serde(default, skip_serializing_if = "Option::is_none")]
1580 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1581}
1582
1583impl Default for ProactiveConfig {
1584 fn default() -> Self {
1585 Self {
1586 enabled: false,
1587 learning_until: None,
1588 quiet_hours: None,
1589 active_hours: None,
1590 daily_cap: default_daily_cap(),
1591 channels: default_channels(),
1592 paused_until: None,
1593 }
1594 }
1595}
1596
1597fn default_daily_cap() -> u8 {
1598 3
1599}
1600fn default_channels() -> Vec<String> {
1601 vec!["stdout".into()]
1602}
1603
1604#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1605pub struct QuietHours {
1606 pub start: String,
1607 pub end: String,
1608}
1609
1610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1611pub struct ActiveHours {
1612 pub start: String,
1613 pub end: String,
1614}
1615
1616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1621pub struct AgentAppearance {
1622 #[serde(default = "default_style_preset")]
1624 pub style_preset: String,
1625 #[serde(default)]
1626 pub behavior_preset: BehaviorPreset,
1627 #[serde(default, skip_serializing_if = "Option::is_none")]
1629 pub source_image_path: Option<std::path::PathBuf>,
1630 #[serde(default = "default_expressions_dir")]
1632 pub expressions_dir: std::path::PathBuf,
1633 #[serde(default, skip_serializing_if = "Option::is_none")]
1634 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1635 #[serde(default)]
1636 pub render_status: RenderStatus,
1637}
1638
1639fn default_style_preset() -> String {
1640 "default-blob".into()
1641}
1642
1643fn default_expressions_dir() -> std::path::PathBuf {
1644 std::path::PathBuf::from("expressions")
1645}
1646
1647impl Default for AgentAppearance {
1648 fn default() -> Self {
1649 Self {
1650 style_preset: default_style_preset(),
1651 behavior_preset: BehaviorPreset::Normal,
1652 source_image_path: None,
1653 expressions_dir: default_expressions_dir(),
1654 last_rendered_at: None,
1655 render_status: RenderStatus::Pending,
1656 }
1657 }
1658}
1659
1660#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1661#[serde(rename_all = "snake_case")]
1662pub enum BehaviorPreset {
1663 Quiet,
1664 #[default]
1665 Normal,
1666 Lively,
1667}
1668
1669#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1670#[serde(tag = "status", rename_all = "snake_case")]
1671pub enum RenderStatus {
1672 #[default]
1673 Pending,
1674 Rendering {
1675 done: u8,
1676 total: u8,
1677 },
1678 Ready,
1679 Failed {
1680 reason: String,
1681 },
1682}
1683
1684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1690#[serde(rename_all = "kebab-case")]
1691pub enum SnapshotPolicy {
1692 #[default]
1693 PullOnStart,
1694 PullPeriodic,
1695 Manual,
1696}
1697
1698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1700pub struct PatternFilter {
1701 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1702 pub applies_in: Vec<String>,
1703 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1704 pub tier: Vec<String>,
1705 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1706 pub maturity: Vec<String>,
1707 #[serde(default)]
1708 pub importance_min: f64,
1709 #[serde(default = "default_max_snapshot_count")]
1710 pub max_count: usize,
1711 #[serde(default)]
1712 pub snapshot_policy: SnapshotPolicy,
1713}
1714
1715fn default_max_snapshot_count() -> usize {
1716 200
1717}
1718
1719impl Default for PatternFilter {
1720 fn default() -> Self {
1721 Self {
1722 applies_in: vec![],
1723 tier: vec![],
1724 maturity: vec![],
1725 importance_min: 0.0,
1726 max_count: 200,
1727 snapshot_policy: SnapshotPolicy::default(),
1728 }
1729 }
1730}
1731
1732#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1734pub struct SnapshotRef {
1735 pub knowledge_commit: String,
1736 pub taken_at: String,
1737 pub filter: PatternFilter,
1738}
1739
1740#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1742pub struct FederationConfig {
1743 #[serde(default)]
1744 pub filter: PatternFilter,
1745 #[serde(default, skip_serializing_if = "Option::is_none")]
1746 pub snapshot_ref: Option<SnapshotRef>,
1747 #[serde(default)]
1748 pub evidence_flush_interval_minutes: u32,
1749}
1750
1751impl AgentProfile {
1752 #[doc(hidden)]
1758 pub fn default_for_tests() -> Self {
1759 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1760 .expect("minimal profile fixture")
1761 }
1762
1763 pub fn smart_override(&self) -> Option<&crate::config::SmartOverride> {
1772 self.smart
1773 .as_ref()
1774 .or_else(|| self.routing.as_ref().and_then(|r| r.smart.as_ref()))
1775 }
1776
1777 pub fn effective_smart(
1780 &self,
1781 cfg: &crate::config::ModelSwitchConfig,
1782 ) -> crate::config::SmartConfig {
1783 cfg.smart.merged(self.smart_override())
1784 }
1785
1786 pub fn effective_routing(
1788 &self,
1789 cfg: &crate::config::ModelSwitchConfig,
1790 ) -> crate::config::RoutingConfig {
1791 cfg.routing.merged(self.routing.as_ref())
1792 }
1793
1794 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1802 let path = mur_home.join("agents").join(name).join("profile.yaml");
1803 let yaml = std::fs::read_to_string(&path)
1804 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1805 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1806 }
1807
1808 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1810 self.addons.iter().find(|g| {
1811 g.skills.iter().any(|n| n == name)
1812 || g.mcp.iter().any(|n| n == name)
1813 || g.commands.iter().any(|n| n == name)
1814 })
1815 }
1816
1817 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1820 name_enabled(&self.disabled_skills, skill_name)
1821 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1822 }
1823
1824 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1826 name_enabled(&self.disabled_mcp, server_id)
1827 && self.group_of(server_id).is_none_or(|g| g.enabled)
1828 }
1829
1830 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1832 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1833 }
1834
1835 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1837 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1838 }
1839
1840 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1843 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1844 Some(g) => {
1845 g.enabled = enabled;
1846 true
1847 }
1848 None => false,
1849 }
1850 }
1851
1852 pub fn disable_all_addons(&mut self) {
1858 for g in &mut self.addons {
1859 g.enabled = false;
1860 }
1861 }
1862
1863 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1865 self.mcp_servers
1866 .iter()
1867 .filter(|m| self.mcp_enabled(&m.name))
1868 .cloned()
1869 .collect()
1870 }
1871}
1872
1873#[cfg(test)]
1874mod tests {
1875 #[test]
1879 fn grants_digest_ignores_order_but_not_content() {
1880 let a = FilesystemEntitlement {
1881 read: vec!["/a".into(), "/b".into()],
1882 write: vec!["/w".into()],
1883 deny: vec![],
1884 };
1885 let reordered = FilesystemEntitlement {
1886 read: vec!["/b".into(), "/a".into()],
1887 ..a.clone()
1888 };
1889 let changed = FilesystemEntitlement {
1890 write: vec!["/w".into(), "/x".into()],
1891 ..a.clone()
1892 };
1893 assert_eq!(
1894 filesystem_grants_digest(&a),
1895 filesystem_grants_digest(&reordered)
1896 );
1897 assert_ne!(
1898 filesystem_grants_digest(&a),
1899 filesystem_grants_digest(&changed)
1900 );
1901 }
1902
1903 #[test]
1905 fn grants_digest_separates_the_verbs() {
1906 let r = FilesystemEntitlement {
1907 read: vec!["/p".into()],
1908 write: vec![],
1909 deny: vec![],
1910 };
1911 let w = FilesystemEntitlement {
1912 read: vec![],
1913 write: vec!["/p".into()],
1914 deny: vec![],
1915 };
1916 assert_ne!(filesystem_grants_digest(&r), filesystem_grants_digest(&w));
1917 }
1918
1919 #[test]
1922 fn a_lock_without_the_sandbox_block_still_deserialises() {
1923 let old = r#"{"schema":1,"uuid":"u","name":"n","pid":1,"ppid":0,
1924 "started_at":"t","binary_version":"v",
1925 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
1926 let lf: LockFile = serde_json::from_str(old).expect("old lock must load");
1927 assert!(lf.sandbox.is_none());
1928 }
1929
1930 #[test]
1931 fn the_sandbox_block_round_trips() {
1932 let rec = SandboxRecord {
1933 enforcing: false,
1934 mode: "advisory-only".into(),
1935 granted_digest: "sha256:x".into(),
1936 dropped: vec![DroppedGrant {
1937 path: "/gone".into(),
1938 verb: "write".into(),
1939 reason: "path does not exist on disk".into(),
1940 }],
1941 };
1942 let back: SandboxRecord =
1943 serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
1944 assert_eq!(back, rec);
1945 }
1946
1947 use super::*;
1948
1949 #[test]
1950 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1951 let net = McpServerNetwork {
1952 mode: McpNetMode::BroadAudited,
1953 allow_hosts: vec![],
1954 deny_hosts: vec!["evil.example".into()],
1955 authorization: Some(EgressAuthorization {
1956 authorized_by: "david".into(),
1957 authorized_at_ms: 1_750_000_000_000,
1958 }),
1959 };
1960 let y = serde_yaml::to_string(&net).unwrap();
1961 assert!(y.contains("broad_audited"));
1962 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1963 assert_eq!(back, net);
1964 let legacy: McpServerNetwork =
1966 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1967 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1968 assert!(legacy.authorization.is_none());
1969 }
1970
1971 #[test]
1972 fn mcp_entry_network_is_optional_and_round_trips() {
1973 let bare = "name: x\ncommand: npx\n";
1975 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1976 assert!(e.network.is_none());
1977
1978 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1980 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1981 let net = e2.network.expect("network present");
1982 assert_eq!(net.mode, McpNetMode::Restricted);
1983 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1984
1985 let out = serde_yaml_ng::to_string(&e).unwrap();
1987 assert!(!out.contains("network"));
1988 }
1989
1990 #[test]
1991 fn profile_round_trip_yaml() {
1992 let yaml = r#"
1993schema: 1
1994id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1995name: agent_a
1996display_name: "Price Hunter"
1997version: "0.1.0"
1998persona:
1999 category: research
2000 description: "Finds prices"
2001 traits: { tone: concise, risk: cautious, verbosity: low }
2002sys_prompt_file: "sys_prompt.md"
2003model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
2004mcp_servers: []
2005skills: []
2006transport:
2007 stdio: true
2008 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
2009communication: { accepts_from: ["*"], sends_to: [] }
2010capabilities: ["a2a.message.send", "a2a.tasks"]
2011entitlements:
2012 network:
2013 inbound: { ports: [] }
2014 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
2015 filesystem: { read: [], write: [], deny: [] }
2016 processes: { spawn: { mode: allowlist, allowed: [] } }
2017 syscalls: { mode: default }
2018 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
2019notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
2020retry:
2021 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
2022 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
2023lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
2024created_at: "2026-04-22T10:00:00+08:00"
2025updated_at: "2026-04-22T10:00:00+08:00"
2026"#;
2027 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
2028 assert_eq!(profile.name, "agent_a");
2029 assert_eq!(profile.persona.category, PersonaCategory::Research);
2030 assert_eq!(
2031 profile.entitlements.network.outbound.mode,
2032 NetworkOutboundMode::Restricted
2033 );
2034 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
2035 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
2036 assert_eq!(profile.id, round_tripped.id);
2037 }
2038
2039 #[test]
2040 fn requires_capabilities_defaults_empty_and_round_trips() {
2041 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2042 let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
2043 assert!(p.requires_capabilities.is_empty());
2044 let with = format!("{base}\nrequires_capabilities:\n - media\n");
2045 let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
2046 assert_eq!(p2.requires_capabilities, vec!["media"]);
2047 }
2048}
2049
2050#[cfg(test)]
2051mod model_ref_tests {
2052 use super::*;
2053
2054 #[test]
2055 fn legacy_profile_without_model_ref_still_parses() {
2056 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2057 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2058 assert!(
2059 p.model_ref.is_none(),
2060 "legacy profile must not have model_ref"
2061 );
2062 }
2063
2064 #[test]
2065 fn round_trip_with_model_ref_preserves_field() {
2066 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2067 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2068 p.model_ref = Some("anthropic_opus_4_7".into());
2069 let s = serde_yaml_ng::to_string(&p).unwrap();
2070 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
2071 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2072 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
2073 }
2074
2075 #[test]
2076 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
2077 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2079 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2080 assert!(
2081 p.fallback_chain.is_empty(),
2082 "legacy profile must have empty fallback_chain"
2083 );
2084 assert!(
2085 p.routing.is_none(),
2086 "legacy profile must have no routing override"
2087 );
2088
2089 let mut p = p.clone();
2091 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
2092 p.routing = Some(crate::config::RoutingOverride {
2093 enabled: Some(true),
2094 ..Default::default()
2095 });
2096 let s = serde_yaml_ng::to_string(&p).unwrap();
2097 assert!(
2098 s.contains("fallback_chain:"),
2099 "yaml must contain fallback_chain"
2100 );
2101 assert!(s.contains("routing:"), "yaml must contain routing");
2102 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2103 assert_eq!(
2104 p2.fallback_chain,
2105 vec!["claude_opus", "claude_sonnet"],
2106 "fallback_chain must round-trip"
2107 );
2108 assert_eq!(
2109 p2.routing.as_ref().unwrap().enabled,
2110 Some(true),
2111 "routing.enabled must round-trip"
2112 );
2113 }
2114
2115 #[test]
2116 fn effective_smart_prefers_the_promoted_field_then_the_legacy_nesting() {
2117 use crate::config::{ModelSwitchConfig, SmartConfig, SmartOverride};
2118 let cfg = ModelSwitchConfig {
2119 smart: SmartConfig {
2120 enabled: false,
2121 cheap: Some("g".into()),
2122 max_escalations: 2,
2123 },
2124 ..Default::default()
2125 };
2126 let p = AgentProfile::default_for_tests();
2128 assert_eq!(p.effective_smart(&cfg), cfg.smart);
2129
2130 let mut legacy = AgentProfile::default_for_tests();
2132 legacy.routing = Some(crate::config::RoutingOverride {
2133 smart: Some(SmartOverride {
2134 enabled: Some(true),
2135 ..Default::default()
2136 }),
2137 ..Default::default()
2138 });
2139 assert!(
2140 legacy.effective_smart(&cfg).enabled,
2141 "legacy nesting is read"
2142 );
2143 assert_eq!(
2144 legacy.effective_smart(&cfg).cheap.as_deref(),
2145 Some("g"),
2146 "unset fields still inherit"
2147 );
2148
2149 let mut both = legacy.clone();
2151 both.smart = Some(SmartOverride {
2152 enabled: Some(false),
2153 ..Default::default()
2154 });
2155 assert!(!both.effective_smart(&cfg).enabled);
2156 }
2157}
2158
2159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2167#[serde(rename_all = "snake_case")]
2168pub enum ProactiveTier {
2169 Off,
2170 WarmOnly,
2171 WarmAndBehavior,
2172 All,
2173}
2174
2175impl ProactiveTier {
2176 pub fn from_config(c: &CompanionConfig) -> Self {
2177 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
2178 (false, _, _) => Self::Off,
2179 (true, false, false) => Self::WarmOnly,
2180 (true, true, false) => Self::WarmAndBehavior,
2181 (true, _, true) => Self::All,
2182 }
2183 }
2184
2185 pub fn apply(&self, c: &mut CompanionConfig) {
2186 match self {
2187 Self::Off => {
2188 c.enabled = false;
2189 c.rhythm.enabled = false;
2190 c.proactive.enabled = false;
2191 }
2192 Self::WarmOnly => {
2193 c.enabled = true;
2194 c.rhythm.enabled = false;
2195 c.proactive.enabled = false;
2196 }
2197 Self::WarmAndBehavior => {
2198 c.enabled = true;
2199 c.rhythm.enabled = true;
2200 c.proactive.enabled = false;
2201 }
2202 Self::All => {
2203 c.enabled = true;
2204 c.rhythm.enabled = true;
2205 c.proactive.enabled = true;
2206 }
2207 }
2208 }
2209}
2210
2211#[cfg(test)]
2212mod mcp_pin_tests {
2213 use super::*;
2214
2215 #[test]
2219 fn pre_m9_entry_roundtrips_without_pin_fields() {
2220 let yaml = r#"
2221name: weather
2222command: /opt/mcp/weather
2223args: ["--port", "0"]
2224"#;
2225 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2226 assert_eq!(entry.name, "weather");
2227 assert_eq!(entry.binary_sha256, None);
2228 assert_eq!(entry.description_hash, None);
2229 assert_eq!(entry.publisher, None);
2230 assert_eq!(entry.installed_at, None);
2231
2232 let out = serde_yaml_ng::to_string(&entry).unwrap();
2235 assert!(!out.contains("binary_sha256"), "got {out}");
2236 assert!(!out.contains("description_hash"), "got {out}");
2237 assert!(!out.contains("publisher"), "got {out}");
2238 assert!(!out.contains("installed_at"), "got {out}");
2239 }
2240
2241 #[test]
2243 fn full_m9_entry_roundtrips_all_fields() {
2244 let yaml = r#"
2245name: weather
2246command: /opt/mcp/weather
2247args: []
2248binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
2249description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
2250publisher:
2251 name: "@anthropic-mcp/weather"
2252 homepage: "https://github.com/anthropic-mcp/weather"
2253 registry_id: "@anthropic-mcp/weather@1.2.3"
2254installed_at: "2026-05-06T08:00:00Z"
2255"#;
2256 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2257 assert!(
2258 entry
2259 .binary_sha256
2260 .as_deref()
2261 .unwrap()
2262 .starts_with("3f4abca8")
2263 );
2264 assert!(
2265 entry
2266 .description_hash
2267 .as_deref()
2268 .unwrap()
2269 .starts_with("9a01b2c3")
2270 );
2271 let pub_info = entry.publisher.clone().unwrap();
2272 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
2273 assert_eq!(
2274 pub_info.homepage.as_deref(),
2275 Some("https://github.com/anthropic-mcp/weather"),
2276 );
2277 assert_eq!(
2278 pub_info.registry_id.as_deref(),
2279 Some("@anthropic-mcp/weather@1.2.3"),
2280 );
2281 let installed = entry.installed_at.unwrap();
2282 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
2283 }
2284
2285 #[test]
2289 fn partial_pin_only_binary_sha_roundtrips() {
2290 let yaml = r#"
2291name: weather
2292command: /opt/mcp/weather
2293args: []
2294binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
2295"#;
2296 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2297 assert_eq!(
2298 entry.binary_sha256.as_deref(),
2299 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
2300 );
2301 assert_eq!(entry.description_hash, None);
2302 assert_eq!(entry.publisher, None);
2303 }
2304
2305 #[test]
2308 fn publisher_minimal_just_name() {
2309 let yaml = r#"
2310name: weather
2311command: /opt/mcp/weather
2312args: []
2313publisher:
2314 name: "alice"
2315"#;
2316 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2317 let p = entry.publisher.as_ref().unwrap();
2318 assert_eq!(p.name, "alice");
2319 assert_eq!(p.homepage, None);
2320 assert_eq!(p.registry_id, None);
2321
2322 let out = serde_yaml_ng::to_string(&entry).unwrap();
2324 assert!(!out.contains("homepage:"), "got {out}");
2325 assert!(!out.contains("registry_id:"), "got {out}");
2326 }
2327}
2328
2329#[cfg(test)]
2330mod voice_tests {
2331 use super::*;
2332 use std::str::FromStr;
2333
2334 #[test]
2335 fn voice_config_round_trips() {
2336 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2338 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
2339
2340 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
2341 assert!(profile.voice.enabled);
2342 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
2343
2344 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
2346 assert!(!legacy.voice.enabled);
2347 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
2348 }
2349
2350 #[test]
2351 fn voice_id_from_str_roundtrips() {
2352 let cases = [
2353 ("af_heart", VoiceId::AfHeart),
2354 ("af_bella", VoiceId::AfBella),
2355 ("af_nicole", VoiceId::AfNicole),
2356 ("am_adam", VoiceId::AmAdam),
2357 ("am_michael", VoiceId::AmMichael),
2358 ];
2359 for (s, expected) in cases {
2360 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
2361 assert_eq!(expected.as_str(), s);
2362 }
2363 }
2364
2365 #[test]
2366 fn voice_id_from_str_rejects_unknown() {
2367 assert!(VoiceId::from_str("bogus").is_err());
2368 }
2369}
2370
2371#[cfg(test)]
2372mod idle_trigger_tests {
2373 use super::*;
2374
2375 #[test]
2376 fn idle_trigger_yaml_round_trip() {
2377 let yaml = r#"
2378restart: on_failure
2379idle_triggers:
2380 - after_secs: 3600
2381 message: "still there?"
2382 sends_to: other_agent
2383 cooldown_secs: 1800
2384 respect_quiet_hours: true
2385"#;
2386 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2387 assert_eq!(cfg.idle_triggers.len(), 1);
2388 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
2389 assert_eq!(cfg.idle_triggers[0].message, "still there?");
2390 assert_eq!(
2391 cfg.idle_triggers[0].sends_to.as_deref(),
2392 Some("other_agent")
2393 );
2394 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
2395 assert!(cfg.idle_triggers[0].respect_quiet_hours);
2396 }
2397
2398 #[test]
2399 fn idle_trigger_defaults_when_omitted() {
2400 let yaml = "restart: on_failure\n";
2401 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2402 assert!(cfg.idle_triggers.is_empty());
2403 }
2404}
2405
2406#[cfg(test)]
2407mod appearance_tests {
2408 use super::*;
2409
2410 #[test]
2411 fn appearance_default_style_preset_is_default_blob() {
2412 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
2413 }
2414
2415 #[test]
2416 fn appearance_default_behavior_is_normal() {
2417 assert_eq!(
2418 AgentAppearance::default().behavior_preset,
2419 BehaviorPreset::Normal
2420 );
2421 }
2422
2423 #[test]
2424 fn appearance_default_render_status_is_pending() {
2425 assert_eq!(
2426 AgentAppearance::default().render_status,
2427 RenderStatus::Pending
2428 );
2429 }
2430
2431 #[test]
2432 fn render_status_serde_round_trip() {
2433 let cases = [
2434 RenderStatus::Pending,
2435 RenderStatus::Rendering { done: 3, total: 12 },
2436 RenderStatus::Ready,
2437 RenderStatus::Failed {
2438 reason: "out of quota".into(),
2439 },
2440 ];
2441 for status in cases {
2442 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
2443 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
2444 assert_eq!(status, back);
2445 }
2446 }
2447
2448 #[test]
2449 fn agent_profile_with_appearance_round_trips() {
2450 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2451 let yaml = format!(
2452 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
2453 );
2454 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2455 assert_eq!(profile.appearance.style_preset, "chiikawa");
2456 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2457
2458 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2459 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2460 assert_eq!(profile.appearance, back.appearance);
2461 }
2462
2463 #[test]
2464 fn legacy_profile_without_appearance_uses_default() {
2465 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2466 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2467 assert_eq!(profile.appearance.style_preset, "default-blob");
2468 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2469 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2470 }
2471
2472 #[test]
2473 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2474 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2475 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2476 assert!(p.file_actions.is_empty());
2477 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2478 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2479 }
2480}
2481
2482#[cfg(test)]
2483mod federation_tests {
2484 use super::*;
2485
2486 #[test]
2487 fn test_pattern_filter_default() {
2488 let f = PatternFilter::default();
2489 assert_eq!(f.max_count, 200);
2490 assert_eq!(f.importance_min, 0.0);
2491 assert!(f.tier.is_empty());
2492 }
2493
2494 #[test]
2495 fn test_federation_config_roundtrip() {
2496 let cfg = FederationConfig {
2497 filter: PatternFilter {
2498 tier: vec!["core".into()],
2499 max_count: 50,
2500 ..Default::default()
2501 },
2502 snapshot_ref: Some(SnapshotRef {
2503 knowledge_commit: "abc123def456".into(),
2504 taken_at: "2026-05-19T00:00:00Z".into(),
2505 filter: PatternFilter::default(),
2506 }),
2507 evidence_flush_interval_minutes: 15,
2508 };
2509 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2510 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2511 assert_eq!(cfg, back);
2512 }
2513
2514 #[test]
2515 fn test_agent_profile_federation_defaults() {
2516 let cfg = FederationConfig::default();
2520 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2521 assert!(cfg.snapshot_ref.is_none());
2522 }
2523}
2524
2525#[cfg(test)]
2526mod skill_card_tests {
2527 use super::*;
2528
2529 #[test]
2530 fn installed_skills_default_to_empty_when_absent() {
2531 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2532 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2533 assert!(p.installed_skills.is_empty());
2534 }
2535
2536 #[test]
2537 fn installed_skills_roundtrip_preserves_entries() {
2538 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2539 let yaml = format!(
2540 "{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"
2541 );
2542 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2543 assert_eq!(p.installed_skills.len(), 1);
2544 assert_eq!(p.installed_skills[0].name, "s1");
2545 assert_eq!(p.installed_skills[0].abstract_text, "does things");
2546 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2547
2548 let out = serde_yaml_ng::to_string(&p).unwrap();
2549 assert!(out.contains("abstract: does things"));
2550 assert!(out.contains("pattern: /find"));
2551
2552 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2553 assert_eq!(p.installed_skills, back.installed_skills);
2554 }
2555
2556 #[test]
2557 fn installed_skills_minimal_entry_serializes_compactly() {
2558 let entry = SkillCardEntry {
2560 name: "minimal".into(),
2561 ..Default::default()
2562 };
2563 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2564 assert!(yaml.contains("name: minimal"));
2565 assert!(
2566 !yaml.contains("version:"),
2567 "empty version must be skipped: {yaml}"
2568 );
2569 assert!(
2570 !yaml.contains("publisher:"),
2571 "empty publisher must be skipped: {yaml}"
2572 );
2573 assert!(
2574 !yaml.contains("abstract:"),
2575 "empty abstract must be skipped: {yaml}"
2576 );
2577 }
2578}
2579
2580#[cfg(test)]
2581mod tool_policy_tests {
2582 use super::*;
2583
2584 fn rules() -> Vec<ToolRule> {
2585 vec![
2586 ToolRule {
2587 pattern: "mcp__github__merge_pr".into(),
2588 policy: ToolPolicy::Ask,
2589 risk: None,
2590 },
2591 ToolRule {
2592 pattern: "mcp__github__*".into(),
2593 policy: ToolPolicy::Allow,
2594 risk: None,
2595 },
2596 ToolRule {
2597 pattern: "mcp__*".into(),
2598 policy: ToolPolicy::Deny,
2599 risk: None,
2600 },
2601 ToolRule {
2602 pattern: "bash".into(),
2603 policy: ToolPolicy::Allow,
2604 risk: None,
2605 },
2606 ]
2607 }
2608
2609 #[test]
2610 fn exact_beats_glob() {
2611 assert_eq!(
2612 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2613 ToolPolicy::Ask
2614 );
2615 }
2616
2617 #[test]
2618 fn longer_glob_wins() {
2619 assert_eq!(
2620 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2621 ToolPolicy::Allow
2622 );
2623 }
2624
2625 #[test]
2626 fn shorter_glob_fallback() {
2627 assert_eq!(
2628 resolve_tool_policy(&rules(), "mcp__slack__send"),
2629 ToolPolicy::Deny
2630 );
2631 }
2632
2633 #[test]
2634 fn exact_bash() {
2635 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2636 }
2637
2638 #[test]
2639 fn unknown_tool_defaults_ask() {
2640 assert_eq!(
2641 resolve_tool_policy(&rules(), "unknown_tool"),
2642 ToolPolicy::Ask
2643 );
2644 }
2645
2646 #[test]
2647 fn empty_rules_defaults_ask() {
2648 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2649 }
2650
2651 fn minimal_entitlements_yaml() -> &'static str {
2652 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2653 }
2654
2655 #[test]
2656 fn entitlements_tools_defaults_empty() {
2657 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2658 assert!(e.tools.is_empty());
2659 }
2660
2661 #[test]
2662 fn entitlements_tools_roundtrip() {
2663 let base = minimal_entitlements_yaml();
2664 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2665 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2666 assert_eq!(e.tools.len(), 1);
2667 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2668 let y = serde_yaml_ng::to_string(&e).unwrap();
2669 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2670 assert_eq!(back.tools.len(), 1);
2671 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2672 }
2673 #[test]
2674 fn denylist_membership_and_mutation() {
2675 let mut list: Vec<String> = vec![];
2676 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2677
2678 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2680 assert_eq!(list, ["a"]);
2681
2682 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2684
2685 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2687 assert!(list.is_empty());
2688
2689 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2691 }
2692
2693 #[test]
2694 fn addon_group_rule_truth_table() {
2695 let mut p = AgentProfile::default_for_tests();
2696 p.addons.push(AddonRef {
2697 id: "grp".into(),
2698 source: "claude-local:grp@1.0.0".into(),
2699 enabled: false,
2700 skills: vec!["g_skill".into()],
2701 mcp: vec!["g_mcp".into()],
2702 commands: vec!["g_cmd".into()],
2703 content_hash: None,
2704 fetch_ref: None,
2705 fetch_plugin: None,
2706 });
2707
2708 assert!(p.skill_enabled("standalone"));
2710 assert!(p.mcp_enabled("standalone_mcp"));
2711
2712 assert!(!p.skill_enabled("g_skill"));
2714 assert!(!p.mcp_enabled("g_mcp"));
2715
2716 assert!(p.set_addon_enabled("grp", true));
2718 assert!(p.skill_enabled("g_skill"));
2719 assert!(p.mcp_enabled("g_mcp"));
2720
2721 p.set_skill_enabled("g_skill", false);
2723 assert!(!p.skill_enabled("g_skill"));
2724
2725 assert!(!p.set_addon_enabled("nope", true));
2727
2728 p.disable_all_addons();
2730 assert!(p.addons.iter().all(|g| !g.enabled));
2731 assert!(!p.skill_enabled("g_skill"));
2732 assert!(!p.skill_enabled("g_cmd"));
2733 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2739 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);
2745 assert!(p.skill_enabled("g_skill"));
2746 }
2747
2748 #[test]
2749 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2750 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2752 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2753 assert_eq!(r.content_hash, None);
2754 assert_eq!(r.fetch_ref, None);
2755
2756 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2758 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2759 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2760 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2761 let back = serde_yaml_ng::to_string(&r2).unwrap();
2762 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2763 assert_eq!(r2, r3);
2764 }
2765}
2766
2767#[cfg(test)]
2768mod lockfile_compat_tests {
2769 use super::*;
2770
2771 #[test]
2772 fn lockfile_new_fields_default_for_old_locks() {
2773 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2776 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2777 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2778 let lock: LockFile = serde_json::from_str(old).unwrap();
2779 assert_eq!(lock.build_sha, "");
2780 assert_eq!(lock.proto_version, 0);
2781 }
2782}
2783
2784#[cfg(test)]
2785mod remote_mcp_tests {
2786 use super::*;
2787
2788 #[test]
2789 fn mcp_entry_roundtrips_remote_bearer() {
2790 let e = McpServerEntry {
2791 name: "gh".into(),
2792 command: String::new(),
2793 url: Some("https://api.example.com/mcp".into()),
2794 auth: Some(McpAuth::Bearer {
2795 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2796 }),
2797 ..Default::default()
2798 };
2799 let y = serde_yaml_ng::to_string(&e).unwrap();
2800 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2801 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2802 assert!(matches!(
2803 back.auth,
2804 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2805 ));
2806 let legacy: McpServerEntry =
2808 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2809 assert!(legacy.url.is_none());
2810 assert!(legacy.auth.is_none());
2811 }
2812}
2813
2814#[cfg(test)]
2815mod requires_programs_tests {
2816 #[test]
2817 fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2818 let with = r#"
2819name: research-gateway
2820command: mur-research-gateway
2821requires_programs:
2822 - name: lightpanda
2823 detect: { file: "~/.mur/aura/lightpanda" }
2824 reason: "render tier"
2825 registry: lightpanda
2826"#;
2827 let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2828 assert_eq!(e.requires_programs.len(), 1);
2829 assert_eq!(e.requires_programs[0].name, "lightpanda");
2830
2831 let without = "name: x\ncommand: y\n";
2833 let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2834 assert!(e2.requires_programs.is_empty());
2835 }
2836}