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)]
155 pub voice: VoiceConfig,
156 #[serde(default)]
158 pub hooks: crate::HooksConfig,
159 #[serde(default)]
162 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
163 pub created_at: String,
164 pub updated_at: String,
165 #[serde(default)]
167 pub appearance: AgentAppearance,
168 #[serde(default)]
170 pub federation: FederationConfig,
171
172 #[serde(default)]
176 pub file_actions: Vec<crate::action::FileAction>,
177
178 #[serde(default)]
180 pub action_pipeline: crate::action::ActionPipelineConfig,
181
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub requires_programs: Vec<ProgramDep>,
186
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub requires_capabilities: Vec<String>,
191}
192
193fn default_algorithm() -> String {
194 "ed25519".into()
195}
196
197pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub struct IdentityConfig {
202 #[serde(default)]
205 pub pubkey: String,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub owner: Option<String>,
209
210 #[serde(default = "default_algorithm")]
213 pub algorithm: String,
214 #[serde(default)]
216 pub key_version: u32,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub created_at_key: Option<String>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub previous_pubkey: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub previous_key_version: Option<u32>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub grace_expires_at: Option<String>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub rotated_at: Option<String>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub emergency_rekey_at: Option<String>,
236}
237
238impl Default for IdentityConfig {
239 fn default() -> Self {
240 Self {
241 pubkey: String::new(),
242 owner: None,
243 algorithm: default_algorithm(),
244 key_version: 0,
245 created_at_key: None,
246 previous_pubkey: None,
247 previous_key_version: None,
248 grace_expires_at: None,
249 rotated_at: None,
250 emergency_rekey_at: None,
251 }
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
256pub struct Persona {
257 pub category: PersonaCategory,
258 pub description: String,
259 pub traits: PersonaTraits,
260}
261
262#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
263#[serde(rename_all = "lowercase")]
264pub enum PersonaCategory {
265 Research,
266 Automation,
267 Monitor,
268 Notify,
269 Commerce,
270 Custom,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
274pub struct PersonaTraits {
275 pub tone: String,
276 pub risk: String,
277 pub verbosity: String,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
281pub struct ModelConfig {
282 pub provider: String,
283 pub name: String,
284 #[serde(default)]
285 pub params: BTreeMap<String, serde_yaml_ng::Value>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
289pub struct McpServerEntry {
290 pub name: String,
291 pub command: String,
292 #[serde(default)]
293 pub args: Vec<String>,
294
295 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub binary_sha256: Option<String>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub description_hash: Option<String>,
310
311 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub publisher: Option<McpPublisherInfo>,
316
317 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
322
323 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub timeout_secs: Option<u32>,
328
329 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub network: Option<McpServerNetwork>,
335
336 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub url: Option<String>,
340
341 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub auth: Option<McpAuth>,
345
346 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub requires_programs: Vec<ProgramDep>,
350
351 #[serde(default, skip_serializing_if = "Vec::is_empty")]
366 pub state_paths: Vec<String>,
367
368 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub package: Option<McpPackagePin>,
377}
378
379#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
392pub struct McpPackagePin {
393 pub runner: String,
395 pub name: String,
397 pub version: String,
399 pub install_dir: String,
401 pub lockfile_sha256: String,
403
404 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub signatures_missing: Option<u32>,
417
418 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub provenance: Option<String>,
429}
430
431impl McpPackagePin {
432 pub fn lockfile_name(&self) -> &'static str {
439 match self.runner.as_str() {
440 "pypi" => "requirements.lock",
441 _ => "package-lock.json",
442 }
443 }
444
445 pub fn lockfile_path(&self) -> std::path::PathBuf {
451 std::path::Path::new(&self.install_dir).join(self.lockfile_name())
452 }
453}
454
455#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
457#[serde(rename_all = "snake_case", tag = "kind")]
458pub enum McpAuth {
459 Bearer { token: crate::secret::SecretRef },
461 Oauth(OauthAuth),
463}
464
465#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
467pub struct OauthAuth {
468 pub token_endpoint: String,
470 pub client_id: String,
472 pub access_token: crate::secret::SecretRef,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub refresh_token: Option<crate::secret::SecretRef>,
477 #[serde(default)]
479 pub expires_at: u64,
480}
481
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
484#[serde(rename_all = "snake_case")]
485pub enum McpNetMode {
486 #[default]
500 Inherit,
501 Restricted,
503 BroadAudited,
509 Off,
511}
512
513pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
522
523#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
525pub struct McpServerNetwork {
526 #[serde(default)]
527 pub mode: McpNetMode,
528 #[serde(default)]
529 pub allow_hosts: Vec<String>,
530 #[serde(default)]
533 pub deny_hosts: Vec<String>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub authorization: Option<EgressAuthorization>,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
548pub struct AddonRef {
549 pub id: String,
551 pub source: String,
553 #[serde(default)]
554 pub enabled: bool,
555 #[serde(default, skip_serializing_if = "Vec::is_empty")]
556 pub skills: Vec<String>,
557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
558 pub mcp: Vec<String>,
559 #[serde(default, skip_serializing_if = "Vec::is_empty")]
560 pub commands: Vec<String>,
561 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub content_hash: Option<String>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub fetch_ref: Option<String>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub fetch_plugin: Option<String>,
576}
577
578#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
584pub struct McpPublisherInfo {
585 pub name: String,
588
589 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub homepage: Option<String>,
594
595 #[serde(default, skip_serializing_if = "Option::is_none")]
598 pub registry_id: Option<String>,
599}
600
601#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
602pub struct TransportConfig {
603 pub stdio: bool,
604 pub socket: SocketTransportConfig,
605 #[serde(default)]
606 pub tcp: TcpTransportConfig,
607 #[serde(default)]
611 pub webhook: WebhookTransportConfig,
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
615pub struct TcpTransportConfig {
616 #[serde(default)]
617 pub enabled: bool,
618 #[serde(default)]
619 pub bind: String,
620 #[serde(default)]
621 pub noise: NoiseConfig,
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
638pub struct WebhookTransportConfig {
639 #[serde(default)]
640 pub enabled: bool,
641 #[serde(default = "default_webhook_bind")]
642 pub bind: String,
643 #[serde(default = "default_webhook_port")]
644 pub port: u16,
645 #[serde(default)]
649 pub hmac_secret_ref: String,
650}
651
652fn default_webhook_bind() -> String {
653 "127.0.0.1".to_string()
654}
655
656fn default_webhook_port() -> u16 {
657 6789
658}
659
660impl Default for WebhookTransportConfig {
661 fn default() -> Self {
662 Self {
663 enabled: false,
664 bind: default_webhook_bind(),
665 port: default_webhook_port(),
666 hmac_secret_ref: String::new(),
667 }
668 }
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
672pub struct NoiseConfig {
673 pub pattern: String,
674}
675
676impl Default for NoiseConfig {
677 fn default() -> Self {
678 Self {
679 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
680 }
681 }
682}
683
684#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
685pub struct SocketTransportConfig {
686 pub enabled: bool,
687 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
689 pub auth: Option<AuthConfig>,
690}
691
692#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
693pub struct AuthConfig {
694 pub scheme: String,
695 pub token_file: String,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
699pub struct CommunicationConfig {
700 #[serde(default = "default_accepts_all")]
701 pub accepts_from: Vec<String>,
702 #[serde(default)]
703 pub sends_to: Vec<String>,
704}
705fn default_accepts_all() -> Vec<String> {
706 vec!["*".to_string()]
707}
708
709#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
710pub struct Entitlements {
711 pub network: NetworkEntitlement,
712 pub filesystem: FilesystemEntitlement,
713 pub processes: ProcessesEntitlement,
714 #[serde(default)]
715 pub syscalls: SyscallsEntitlement,
716 #[serde(default)]
717 pub limits: LimitsEntitlement,
718 #[serde(default)]
721 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
722 #[serde(default, skip_serializing_if = "Vec::is_empty")]
724 pub tools: Vec<ToolRule>,
725 #[serde(default = "default_true")]
730 pub fail_closed_on_sandbox_error: bool,
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
734pub struct NetworkEntitlement {
735 pub inbound: InboundNetwork,
736 pub outbound: OutboundNetwork,
737}
738
739#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
740pub struct InboundNetwork {
741 #[serde(default)]
742 pub ports: Vec<u16>,
743}
744
745#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
746pub struct OutboundNetwork {
747 pub mode: NetworkOutboundMode,
748 #[serde(default)]
749 pub allow_hosts: Vec<String>,
750 #[serde(default = "default_protocols")]
751 pub protocols: Vec<String>,
752 #[serde(default)]
753 pub resolve_dns: ResolveDnsConfig,
754}
755fn default_protocols() -> Vec<String> {
756 vec!["tcp".to_string()]
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
763pub struct EgressAuthorization {
764 pub authorized_by: String,
765 pub authorized_at_ms: u64,
766}
767
768#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
769#[serde(rename_all = "lowercase")]
770pub enum NetworkOutboundMode {
771 Unrestricted,
772 Restricted,
773 ProxyOnly,
777 Off,
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
781pub struct ResolveDnsConfig {
782 #[serde(default = "default_dns_mode")]
783 pub mode: String,
784 #[serde(default)]
785 pub servers: Vec<String>,
786}
787impl Default for ResolveDnsConfig {
788 fn default() -> Self {
789 Self {
790 mode: default_dns_mode(),
791 servers: vec![],
792 }
793 }
794}
795fn default_dns_mode() -> String {
796 "system".to_string()
797}
798
799pub const AUTHORING_DIRS: [&str; 4] = ["skills", "workflows", "fleets", "artifacts"];
810
811#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
812pub struct FilesystemEntitlement {
813 #[serde(default)]
814 pub read: Vec<String>,
815 #[serde(default)]
816 pub write: Vec<String>,
817 #[serde(default)]
818 pub deny: Vec<String>,
819}
820
821#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
822pub struct ProcessesEntitlement {
823 pub spawn: SpawnEntitlement,
824}
825
826#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
827pub struct SpawnEntitlement {
828 pub mode: SpawnMode,
829 #[serde(default)]
830 pub allowed: Vec<String>,
831 #[serde(default)]
846 pub allowed_dirs: Vec<String>,
847}
848
849#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
850#[serde(rename_all = "lowercase")]
851pub enum SpawnMode {
852 Allowlist,
853 Any,
854 None,
855 Strict,
861}
862
863#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
864pub struct SyscallsEntitlement {
865 #[serde(default = "default_syscalls_mode")]
866 pub mode: String,
867 #[serde(default)]
868 pub extra_deny: Vec<String>,
869}
870fn default_syscalls_mode() -> String {
871 "default".to_string()
872}
873
874#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
875pub struct LimitsEntitlement {
876 #[serde(default)]
877 pub cpu_seconds: Option<u64>,
878 #[serde(default = "default_memory_mb")]
879 pub memory_mb: u64,
880 #[serde(default = "default_fds")]
881 pub file_descriptors: u32,
882 #[serde(default = "default_procs")]
883 pub processes: u32,
884}
885fn default_memory_mb() -> u64 {
886 512
887}
888fn default_fds() -> u32 {
889 1024
890}
891fn default_procs() -> u32 {
892 32
893}
894
895#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
896#[serde(rename_all = "lowercase")]
897pub enum ToolPolicy {
898 Allow,
899 #[default]
900 Ask,
901 Deny,
902}
903
904#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
905pub struct ToolRule {
906 pub pattern: String,
907 pub policy: ToolPolicy,
908 #[serde(default, skip_serializing_if = "Option::is_none")]
911 pub risk: Option<crate::hitl::RiskTier>,
912}
913
914pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
918 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
919}
920
921pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
926 for rule in rules {
927 if rule.pattern == tool_name {
928 return Some(rule.policy);
929 }
930 }
931 let mut best: Option<(&ToolRule, usize)> = None;
932 for rule in rules {
933 if let Some(prefix) = rule.pattern.strip_suffix('*')
934 && tool_name.starts_with(prefix)
935 {
936 let len = prefix.len();
937 if best.is_none_or(|(_, best_len)| len > best_len) {
938 best = Some((rule, len));
939 }
940 }
941 }
942 best.map(|(rule, _)| rule.policy)
943}
944
945#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
946pub struct NotificationsConfig {
947 #[serde(default)]
948 pub on_task_complete: Vec<NotificationTarget>,
949 #[serde(default)]
950 pub on_error: Vec<NotificationTarget>,
951 #[serde(default)]
952 pub on_shutdown: Vec<NotificationTarget>,
953}
954
955#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
956#[serde(tag = "target", rename_all = "lowercase")]
957pub enum NotificationTarget {
958 Agent {
959 name: String,
960 },
961 Commander,
962 Email {
963 address: String,
964 #[serde(default)]
965 smtp_config_file: Option<String>,
966 },
967 Slack {
968 #[serde(default)]
969 channel: Option<String>,
970 #[serde(default)]
971 webhook_url_env: Option<String>,
972 },
973 Webpush {
974 url: String,
975 },
976 Webhook {
977 url: String,
978 #[serde(default = "default_post")]
979 method: String,
980 #[serde(default)]
981 auth: Option<String>,
982 },
983}
984fn default_post() -> String {
985 "POST".to_string()
986}
987
988#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
989pub struct RetryConfig {
990 pub llm: RetryPolicy,
991 pub tool: RetryPolicy,
992}
993
994#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
995pub struct RetryPolicy {
996 pub max_retries: u32,
997 pub backoff: BackoffStrategy,
998 pub initial_delay_ms: u64,
999 #[serde(default)]
1000 pub max_delay_ms: Option<u64>,
1001 #[serde(default)]
1002 pub retry_on: Vec<String>,
1003}
1004
1005#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1006#[serde(rename_all = "lowercase")]
1007pub enum BackoffStrategy {
1008 Linear,
1009 Exponential,
1010 Fixed,
1011}
1012
1013#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1014pub struct LifecycleConfig {
1015 pub restart: RestartPolicy,
1016 #[serde(default = "default_max_restarts")]
1017 pub max_restarts: u32,
1018 #[serde(default = "default_window")]
1019 pub restart_window_secs: u64,
1020 #[serde(default = "default_stop_timeout")]
1021 pub stop_timeout_secs: u64,
1022 #[serde(default = "default_mcp_required")]
1023 pub mcp_required: bool,
1024 #[serde(default)]
1025 pub execution: ExecutionMode,
1026 #[serde(default)]
1027 pub schedule: Vec<ScheduleEntry>,
1028 #[serde(default)]
1029 pub idle_triggers: Vec<IdleTrigger>,
1030}
1031fn default_max_restarts() -> u32 {
1032 3
1033}
1034fn default_window() -> u64 {
1035 600
1036}
1037fn default_stop_timeout() -> u64 {
1038 15
1039}
1040fn default_mcp_required() -> bool {
1041 true
1042}
1043
1044#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1045#[serde(rename_all = "snake_case")]
1046pub enum RestartPolicy {
1047 Never,
1048 OnFailure,
1049 Always,
1050}
1051
1052#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1053#[serde(rename_all = "snake_case")]
1054pub enum ExecutionMode {
1055 #[default]
1056 Daemon,
1057 OnDemand,
1058}
1059
1060pub const SCHEDULE_PROPOSAL_DIR: &str = "schedule-proposals";
1075
1076pub const SCHEDULE_CHANNEL_FILE: &str = "schedule-channel";
1080
1081pub const HITL_CHANNEL_FILE: &str = "hitl-channel";
1086
1087#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1089pub struct ScheduleProposal {
1090 pub cron: String,
1091 pub message: String,
1092 #[serde(default, skip_serializing_if = "Option::is_none")]
1096 pub asked_for: Option<String>,
1097 #[serde(default, skip_serializing_if = "Option::is_none")]
1101 pub not_after: Option<String>,
1102 pub proposed_at: String,
1103}
1104
1105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1106pub struct ScheduleEntry {
1107 pub cron: String,
1108 pub message: String,
1109 #[serde(default, skip_serializing_if = "Option::is_none")]
1110 pub sends_to: Option<String>,
1111 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub not_after: Option<String>,
1124}
1125
1126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1127pub struct IdleTrigger {
1128 pub after_secs: u64,
1130 pub message: String,
1132 #[serde(default, skip_serializing_if = "Option::is_none")]
1134 pub sends_to: Option<String>,
1135 #[serde(default = "default_idle_cooldown")]
1138 pub cooldown_secs: u64,
1139 #[serde(default = "default_true")]
1142 pub respect_quiet_hours: bool,
1143}
1144
1145fn default_idle_cooldown() -> u64 {
1146 600
1147}
1148pub fn name_enabled(denylist: &[String], name: &str) -> bool {
1150 !denylist.iter().any(|n| n == name)
1151}
1152
1153pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
1156 if enabled {
1157 list.retain(|n| n != name);
1158 } else if !list.iter().any(|n| n == name) {
1159 list.push(name.to_string());
1160 }
1161}
1162
1163fn default_true() -> bool {
1164 true
1165}
1166
1167#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1168pub struct FileTransferConfig {
1169 #[serde(default = "default_accept_max")]
1170 pub accept_incoming_file_max_bytes: u64,
1171 #[serde(default = "default_accept_total")]
1172 pub accept_incoming_total_per_hour: u64,
1173 #[serde(default = "default_approval_threshold")]
1174 pub require_approval_above_bytes: u64,
1175 #[serde(default = "default_reject_paths")]
1176 pub reject_paths: Vec<String>,
1177 #[serde(default = "default_allowed_mime")]
1178 pub allowed_mime_types: Vec<String>,
1179}
1180
1181impl Default for FileTransferConfig {
1182 fn default() -> Self {
1183 Self {
1184 accept_incoming_file_max_bytes: default_accept_max(),
1185 accept_incoming_total_per_hour: default_accept_total(),
1186 require_approval_above_bytes: default_approval_threshold(),
1187 reject_paths: default_reject_paths(),
1188 allowed_mime_types: default_allowed_mime(),
1189 }
1190 }
1191}
1192
1193fn default_accept_max() -> u64 {
1194 10_485_760
1195}
1196fn default_accept_total() -> u64 {
1197 104_857_600
1198}
1199fn default_approval_threshold() -> u64 {
1200 10_485_760
1201}
1202fn default_reject_paths() -> Vec<String> {
1203 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
1204}
1205fn default_allowed_mime() -> Vec<String> {
1206 vec!["*".into()]
1207}
1208
1209#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1210#[serde(rename_all = "snake_case")]
1211pub enum DeploymentType {
1212 #[default]
1213 Laptop,
1214 Vm,
1215 Docker,
1216 K8s,
1217 Lambda,
1218}
1219
1220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1221pub struct DeploymentConfig {
1222 #[serde(rename = "type", default)]
1223 pub deployment_type: DeploymentType,
1224 #[serde(default, skip_serializing_if = "Option::is_none")]
1225 pub region: Option<String>,
1226 #[serde(default = "default_env")]
1227 pub environment: Option<String>,
1228}
1229
1230impl Default for DeploymentConfig {
1231 fn default() -> Self {
1232 Self {
1233 deployment_type: DeploymentType::default(),
1234 region: None,
1235 environment: default_env(),
1236 }
1237 }
1238}
1239
1240fn default_env() -> Option<String> {
1241 Some("dev".into())
1242}
1243
1244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1250pub struct DroppedGrant {
1251 pub path: String,
1252 pub verb: String,
1254 pub reason: String,
1255}
1256
1257pub fn filesystem_grants_digest(fs: &FilesystemEntitlement) -> String {
1264 use sha2::{Digest, Sha256};
1265 let mut h = Sha256::new();
1266 for (label, list) in [("r", &fs.read), ("w", &fs.write), ("d", &fs.deny)] {
1267 let mut sorted = list.clone();
1268 sorted.sort();
1269 for p in sorted {
1270 h.update(label.as_bytes());
1271 h.update(b"\0");
1272 h.update(p.as_bytes());
1273 h.update(b"\0");
1274 }
1275 }
1276 format!("sha256:{:x}", h.finalize())
1277}
1278
1279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1287pub struct SandboxRecord {
1288 pub enforcing: bool,
1292 pub mode: String,
1294 pub granted_digest: String,
1299 #[serde(default)]
1301 pub dropped: Vec<DroppedGrant>,
1302}
1303
1304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1305pub struct LockFile {
1306 pub schema: u32,
1307 pub uuid: String,
1308 pub name: String,
1309 pub pid: u32,
1310 pub ppid: u32,
1311 pub started_at: String,
1312 pub binary_version: String,
1313 pub transports: LockTransports,
1314 pub card_digest: String,
1315 pub capabilities: Vec<String>,
1316 #[serde(default)]
1319 pub build_sha: String,
1320 #[serde(default)]
1323 pub proto_version: u32,
1324 #[serde(default, skip_serializing_if = "Option::is_none")]
1327 pub sandbox: Option<SandboxRecord>,
1328}
1329
1330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1331pub struct LockTransports {
1332 pub stdio: bool,
1333 #[serde(default)]
1334 pub unix_socket: Option<String>,
1335 #[serde(default)]
1336 pub tcp: Option<String>,
1337 #[serde(default)]
1342 pub webhook: Option<String>,
1343}
1344
1345#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1352#[serde(rename_all = "snake_case")]
1353pub enum VoiceId {
1354 #[default]
1356 AfHeart,
1357 AfBella,
1358 AfNicole,
1359 AmAdam,
1360 AmMichael,
1361}
1362
1363impl VoiceId {
1364 pub fn style_index(&self) -> usize {
1366 match self {
1367 VoiceId::AfHeart => 0,
1368 VoiceId::AfBella => 1,
1369 VoiceId::AfNicole => 2,
1370 VoiceId::AmAdam => 3,
1371 VoiceId::AmMichael => 4,
1372 }
1373 }
1374
1375 pub fn as_str(&self) -> &'static str {
1377 match self {
1378 VoiceId::AfHeart => "af_heart",
1379 VoiceId::AfBella => "af_bella",
1380 VoiceId::AfNicole => "af_nicole",
1381 VoiceId::AmAdam => "am_adam",
1382 VoiceId::AmMichael => "am_michael",
1383 }
1384 }
1385}
1386
1387impl std::str::FromStr for VoiceId {
1388 type Err = anyhow::Error;
1389
1390 fn from_str(s: &str) -> anyhow::Result<Self> {
1391 match s {
1392 "af_heart" => Ok(VoiceId::AfHeart),
1393 "af_bella" => Ok(VoiceId::AfBella),
1394 "af_nicole" => Ok(VoiceId::AfNicole),
1395 "am_adam" => Ok(VoiceId::AmAdam),
1396 "am_michael" => Ok(VoiceId::AmMichael),
1397 other => anyhow::bail!(
1398 "unknown voice ID '{other}' \
1399 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1400 ),
1401 }
1402 }
1403}
1404
1405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1408pub struct VoiceConfig {
1409 #[serde(default)]
1411 pub enabled: bool,
1412 #[serde(default)]
1414 pub voice_id: VoiceId,
1415 #[serde(default, skip_serializing_if = "Option::is_none")]
1418 pub input_device: Option<String>,
1419}
1420
1421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1426pub struct HitlConfig {
1427 #[serde(default = "default_hitl_timeout_secs")]
1428 pub timeout_secs: u32,
1429 #[serde(default)]
1433 pub max_iterations: Option<u32>,
1434 #[serde(default)]
1439 pub max_tokens: Option<u64>,
1440}
1441
1442fn default_hitl_timeout_secs() -> u32 {
1443 300
1444}
1445
1446impl Default for HitlConfig {
1447 fn default() -> Self {
1448 Self {
1449 timeout_secs: default_hitl_timeout_secs(),
1450 max_iterations: None,
1451 max_tokens: None,
1452 }
1453 }
1454}
1455
1456#[cfg(test)]
1457mod hitl_tests {
1458 use super::*;
1459
1460 #[test]
1461 fn hitl_config_default_max_iterations_is_none() {
1462 let cfg = HitlConfig::default();
1463 assert!(cfg.max_iterations.is_none());
1464 }
1465
1466 #[test]
1467 fn hitl_config_max_iterations_explicit() {
1468 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1469 assert_eq!(cfg.max_iterations, Some(5));
1470 }
1471
1472 #[test]
1473 fn hitl_config_default_max_tokens_is_none() {
1474 let cfg = HitlConfig::default();
1475 assert!(cfg.max_tokens.is_none());
1476 }
1477
1478 #[test]
1479 fn hitl_config_max_tokens_explicit() {
1480 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1481 assert_eq!(cfg.max_tokens, Some(250_000));
1482 }
1483}
1484
1485#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1491pub struct CompanionConfig {
1492 #[serde(default)]
1493 pub enabled: bool,
1494 #[serde(default = "default_locale")]
1495 pub locale: String,
1496 #[serde(default)]
1497 pub relationship: Relationship,
1498 #[serde(default)]
1499 pub voice_overrides: VoiceOverrides,
1500 #[serde(default)]
1501 pub onboarding: OnboardingState,
1502 #[serde(default)]
1503 pub rhythm: RhythmConfig,
1504 #[serde(default)]
1505 pub proactive: ProactiveConfig,
1506}
1507
1508pub fn default_locale() -> String {
1517 sys_locale::get_locale()
1518 .filter(|l| !l.is_empty())
1519 .or_else(|| std::env::var("LANG").ok().and_then(|v| normalize_lang(&v)))
1520 .unwrap_or_else(|| "en-US".into())
1521}
1522
1523fn normalize_lang(v: &str) -> Option<String> {
1525 v.split('.')
1526 .next()
1527 .map(|s| s.replace('_', "-"))
1528 .filter(|s| !s.is_empty())
1529}
1530
1531#[cfg(test)]
1532mod locale_tests {
1533 use super::normalize_lang;
1534
1535 #[test]
1536 fn lang_with_encoding_and_region_normalizes() {
1537 assert_eq!(normalize_lang("zh_TW.UTF-8").as_deref(), Some("zh-TW"));
1538 }
1539
1540 #[test]
1541 fn lang_without_encoding_normalizes() {
1542 assert_eq!(normalize_lang("en_US").as_deref(), Some("en-US"));
1543 }
1544
1545 #[test]
1546 fn lang_with_script_keeps_script() {
1547 assert_eq!(
1548 normalize_lang("zh_Hant_TW.UTF-8").as_deref(),
1549 Some("zh-Hant-TW")
1550 );
1551 }
1552
1553 #[test]
1554 fn empty_lang_yields_none() {
1555 assert_eq!(normalize_lang(""), None);
1556 }
1557}
1558
1559#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1560pub struct VoiceOverrides {
1561 #[serde(default, skip_serializing_if = "Option::is_none")]
1562 pub name_for_user: Option<String>,
1563 #[serde(default, skip_serializing_if = "Option::is_none")]
1564 pub formality: Option<Formality>,
1565 #[serde(default, skip_serializing_if = "Option::is_none")]
1566 pub extra_instructions: Option<String>,
1567}
1568
1569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1570pub struct FirstMemory {
1571 pub text: String,
1572 pub established_at: chrono::DateTime<chrono::Utc>,
1573}
1574
1575#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1576pub struct OnboardingState {
1577 #[serde(default, skip_serializing_if = "Option::is_none")]
1578 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1579 #[serde(default)]
1580 pub version: u32,
1581 #[serde(default, skip_serializing_if = "Option::is_none")]
1582 pub agent_display_name: Option<String>,
1583 #[serde(default, skip_serializing_if = "Option::is_none")]
1584 pub first_memory: Option<FirstMemory>,
1585}
1586
1587#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1590pub struct RhythmConfig {
1591 #[serde(default)]
1592 pub enabled: bool,
1593}
1594
1595#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1596pub struct ProactiveConfig {
1597 #[serde(default)]
1598 pub enabled: bool,
1599 #[serde(default, skip_serializing_if = "Option::is_none")]
1601 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1602 #[serde(default, skip_serializing_if = "Option::is_none")]
1603 pub quiet_hours: Option<QuietHours>,
1604 #[serde(default, skip_serializing_if = "Option::is_none")]
1605 pub active_hours: Option<ActiveHours>,
1606 #[serde(default = "default_daily_cap")]
1607 pub daily_cap: u8,
1608 #[serde(default = "default_channels")]
1609 pub channels: Vec<String>,
1610 #[serde(default, skip_serializing_if = "Option::is_none")]
1611 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1612}
1613
1614impl Default for ProactiveConfig {
1615 fn default() -> Self {
1616 Self {
1617 enabled: false,
1618 learning_until: None,
1619 quiet_hours: None,
1620 active_hours: None,
1621 daily_cap: default_daily_cap(),
1622 channels: default_channels(),
1623 paused_until: None,
1624 }
1625 }
1626}
1627
1628fn default_daily_cap() -> u8 {
1629 3
1630}
1631fn default_channels() -> Vec<String> {
1632 vec!["stdout".into()]
1633}
1634
1635#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1636pub struct QuietHours {
1637 pub start: String,
1638 pub end: String,
1639}
1640
1641#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1642pub struct ActiveHours {
1643 pub start: String,
1644 pub end: String,
1645}
1646
1647#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1652pub struct AgentAppearance {
1653 #[serde(default = "default_style_preset")]
1655 pub style_preset: String,
1656 #[serde(default)]
1657 pub behavior_preset: BehaviorPreset,
1658 #[serde(default, skip_serializing_if = "Option::is_none")]
1660 pub source_image_path: Option<std::path::PathBuf>,
1661 #[serde(default = "default_expressions_dir")]
1663 pub expressions_dir: std::path::PathBuf,
1664 #[serde(default, skip_serializing_if = "Option::is_none")]
1665 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1666 #[serde(default)]
1667 pub render_status: RenderStatus,
1668}
1669
1670fn default_style_preset() -> String {
1671 "default-blob".into()
1672}
1673
1674fn default_expressions_dir() -> std::path::PathBuf {
1675 std::path::PathBuf::from("expressions")
1676}
1677
1678impl Default for AgentAppearance {
1679 fn default() -> Self {
1680 Self {
1681 style_preset: default_style_preset(),
1682 behavior_preset: BehaviorPreset::Normal,
1683 source_image_path: None,
1684 expressions_dir: default_expressions_dir(),
1685 last_rendered_at: None,
1686 render_status: RenderStatus::Pending,
1687 }
1688 }
1689}
1690
1691#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1692#[serde(rename_all = "snake_case")]
1693pub enum BehaviorPreset {
1694 Quiet,
1695 #[default]
1696 Normal,
1697 Lively,
1698}
1699
1700#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1701#[serde(tag = "status", rename_all = "snake_case")]
1702pub enum RenderStatus {
1703 #[default]
1704 Pending,
1705 Rendering {
1706 done: u8,
1707 total: u8,
1708 },
1709 Ready,
1710 Failed {
1711 reason: String,
1712 },
1713}
1714
1715#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1721#[serde(rename_all = "kebab-case")]
1722pub enum SnapshotPolicy {
1723 #[default]
1724 PullOnStart,
1725 PullPeriodic,
1726 Manual,
1727}
1728
1729#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1731pub struct PatternFilter {
1732 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1733 pub applies_in: Vec<String>,
1734 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1735 pub tier: Vec<String>,
1736 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1737 pub maturity: Vec<String>,
1738 #[serde(default)]
1739 pub importance_min: f64,
1740 #[serde(default = "default_max_snapshot_count")]
1741 pub max_count: usize,
1742 #[serde(default)]
1743 pub snapshot_policy: SnapshotPolicy,
1744}
1745
1746fn default_max_snapshot_count() -> usize {
1747 200
1748}
1749
1750impl Default for PatternFilter {
1751 fn default() -> Self {
1752 Self {
1753 applies_in: vec![],
1754 tier: vec![],
1755 maturity: vec![],
1756 importance_min: 0.0,
1757 max_count: 200,
1758 snapshot_policy: SnapshotPolicy::default(),
1759 }
1760 }
1761}
1762
1763#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1765pub struct SnapshotRef {
1766 pub knowledge_commit: String,
1767 pub taken_at: String,
1768 pub filter: PatternFilter,
1769}
1770
1771#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1773pub struct FederationConfig {
1774 #[serde(default)]
1775 pub filter: PatternFilter,
1776 #[serde(default, skip_serializing_if = "Option::is_none")]
1777 pub snapshot_ref: Option<SnapshotRef>,
1778 #[serde(default)]
1779 pub evidence_flush_interval_minutes: u32,
1780}
1781
1782impl AgentProfile {
1783 #[doc(hidden)]
1789 pub fn default_for_tests() -> Self {
1790 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1791 .expect("minimal profile fixture")
1792 }
1793
1794 pub fn smart_override(&self) -> Option<&crate::config::SmartOverride> {
1803 self.smart
1804 .as_ref()
1805 .or_else(|| self.routing.as_ref().and_then(|r| r.smart.as_ref()))
1806 }
1807
1808 pub fn effective_smart(
1811 &self,
1812 cfg: &crate::config::ModelSwitchConfig,
1813 ) -> crate::config::SmartConfig {
1814 cfg.smart.merged(self.smart_override())
1815 }
1816
1817 pub fn effective_routing(
1819 &self,
1820 cfg: &crate::config::ModelSwitchConfig,
1821 ) -> crate::config::RoutingConfig {
1822 cfg.routing.merged(self.routing.as_ref())
1823 }
1824
1825 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1833 let path = mur_home.join("agents").join(name).join("profile.yaml");
1834 let yaml = std::fs::read_to_string(&path)
1835 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1836 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1837 }
1838
1839 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1841 self.addons.iter().find(|g| {
1842 g.skills.iter().any(|n| n == name)
1843 || g.mcp.iter().any(|n| n == name)
1844 || g.commands.iter().any(|n| n == name)
1845 })
1846 }
1847
1848 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1851 name_enabled(&self.disabled_skills, skill_name)
1852 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1853 }
1854
1855 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1857 name_enabled(&self.disabled_mcp, server_id)
1858 && self.group_of(server_id).is_none_or(|g| g.enabled)
1859 }
1860
1861 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1863 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1864 }
1865
1866 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1868 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1869 }
1870
1871 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1874 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1875 Some(g) => {
1876 g.enabled = enabled;
1877 true
1878 }
1879 None => false,
1880 }
1881 }
1882
1883 pub fn disable_all_addons(&mut self) {
1889 for g in &mut self.addons {
1890 g.enabled = false;
1891 }
1892 }
1893
1894 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1896 self.mcp_servers
1897 .iter()
1898 .filter(|m| self.mcp_enabled(&m.name))
1899 .cloned()
1900 .collect()
1901 }
1902}
1903
1904#[cfg(test)]
1905mod tests {
1906 #[test]
1910 fn grants_digest_ignores_order_but_not_content() {
1911 let a = FilesystemEntitlement {
1912 read: vec!["/a".into(), "/b".into()],
1913 write: vec!["/w".into()],
1914 deny: vec![],
1915 };
1916 let reordered = FilesystemEntitlement {
1917 read: vec!["/b".into(), "/a".into()],
1918 ..a.clone()
1919 };
1920 let changed = FilesystemEntitlement {
1921 write: vec!["/w".into(), "/x".into()],
1922 ..a.clone()
1923 };
1924 assert_eq!(
1925 filesystem_grants_digest(&a),
1926 filesystem_grants_digest(&reordered)
1927 );
1928 assert_ne!(
1929 filesystem_grants_digest(&a),
1930 filesystem_grants_digest(&changed)
1931 );
1932 }
1933
1934 #[test]
1936 fn grants_digest_separates_the_verbs() {
1937 let r = FilesystemEntitlement {
1938 read: vec!["/p".into()],
1939 write: vec![],
1940 deny: vec![],
1941 };
1942 let w = FilesystemEntitlement {
1943 read: vec![],
1944 write: vec!["/p".into()],
1945 deny: vec![],
1946 };
1947 assert_ne!(filesystem_grants_digest(&r), filesystem_grants_digest(&w));
1948 }
1949
1950 #[test]
1953 fn a_lock_without_the_sandbox_block_still_deserialises() {
1954 let old = r#"{"schema":1,"uuid":"u","name":"n","pid":1,"ppid":0,
1955 "started_at":"t","binary_version":"v",
1956 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
1957 let lf: LockFile = serde_json::from_str(old).expect("old lock must load");
1958 assert!(lf.sandbox.is_none());
1959 }
1960
1961 #[test]
1962 fn the_sandbox_block_round_trips() {
1963 let rec = SandboxRecord {
1964 enforcing: false,
1965 mode: "advisory-only".into(),
1966 granted_digest: "sha256:x".into(),
1967 dropped: vec![DroppedGrant {
1968 path: "/gone".into(),
1969 verb: "write".into(),
1970 reason: "path does not exist on disk".into(),
1971 }],
1972 };
1973 let back: SandboxRecord =
1974 serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
1975 assert_eq!(back, rec);
1976 }
1977
1978 use super::*;
1979
1980 #[test]
1981 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1982 let net = McpServerNetwork {
1983 mode: McpNetMode::BroadAudited,
1984 allow_hosts: vec![],
1985 deny_hosts: vec!["evil.example".into()],
1986 authorization: Some(EgressAuthorization {
1987 authorized_by: "david".into(),
1988 authorized_at_ms: 1_750_000_000_000,
1989 }),
1990 };
1991 let y = serde_yaml::to_string(&net).unwrap();
1992 assert!(y.contains("broad_audited"));
1993 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1994 assert_eq!(back, net);
1995 let legacy: McpServerNetwork =
1997 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1998 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1999 assert!(legacy.authorization.is_none());
2000 }
2001
2002 #[test]
2003 fn mcp_entry_network_is_optional_and_round_trips() {
2004 let bare = "name: x\ncommand: npx\n";
2006 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
2007 assert!(e.network.is_none());
2008
2009 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
2011 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
2012 let net = e2.network.expect("network present");
2013 assert_eq!(net.mode, McpNetMode::Restricted);
2014 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
2015
2016 let out = serde_yaml_ng::to_string(&e).unwrap();
2018 assert!(!out.contains("network"));
2019 }
2020
2021 #[test]
2022 fn profile_round_trip_yaml() {
2023 let yaml = r#"
2024schema: 1
2025id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
2026name: agent_a
2027display_name: "Price Hunter"
2028version: "0.1.0"
2029persona:
2030 category: research
2031 description: "Finds prices"
2032 traits: { tone: concise, risk: cautious, verbosity: low }
2033sys_prompt_file: "sys_prompt.md"
2034model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
2035mcp_servers: []
2036skills: []
2037transport:
2038 stdio: true
2039 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
2040communication: { accepts_from: ["*"], sends_to: [] }
2041capabilities: ["a2a.message.send", "a2a.tasks"]
2042entitlements:
2043 network:
2044 inbound: { ports: [] }
2045 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
2046 filesystem: { read: [], write: [], deny: [] }
2047 processes: { spawn: { mode: allowlist, allowed: [] } }
2048 syscalls: { mode: default }
2049 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
2050notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
2051retry:
2052 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
2053 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
2054lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
2055created_at: "2026-04-22T10:00:00+08:00"
2056updated_at: "2026-04-22T10:00:00+08:00"
2057"#;
2058 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
2059 assert_eq!(profile.name, "agent_a");
2060 assert_eq!(profile.persona.category, PersonaCategory::Research);
2061 assert_eq!(
2062 profile.entitlements.network.outbound.mode,
2063 NetworkOutboundMode::Restricted
2064 );
2065 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
2066 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
2067 assert_eq!(profile.id, round_tripped.id);
2068 }
2069
2070 #[test]
2071 fn requires_capabilities_defaults_empty_and_round_trips() {
2072 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2073 let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
2074 assert!(p.requires_capabilities.is_empty());
2075 let with = format!("{base}\nrequires_capabilities:\n - media\n");
2076 let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
2077 assert_eq!(p2.requires_capabilities, vec!["media"]);
2078 }
2079}
2080
2081#[cfg(test)]
2082mod model_ref_tests {
2083 use super::*;
2084
2085 #[test]
2086 fn legacy_profile_without_model_ref_still_parses() {
2087 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2088 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2089 assert!(
2090 p.model_ref.is_none(),
2091 "legacy profile must not have model_ref"
2092 );
2093 }
2094
2095 #[test]
2096 fn round_trip_with_model_ref_preserves_field() {
2097 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2098 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2099 p.model_ref = Some("anthropic_opus_4_7".into());
2100 let s = serde_yaml_ng::to_string(&p).unwrap();
2101 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
2102 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2103 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
2104 }
2105
2106 #[test]
2107 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
2108 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2110 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2111 assert!(
2112 p.fallback_chain.is_empty(),
2113 "legacy profile must have empty fallback_chain"
2114 );
2115 assert!(
2116 p.routing.is_none(),
2117 "legacy profile must have no routing override"
2118 );
2119
2120 let mut p = p.clone();
2122 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
2123 p.routing = Some(crate::config::RoutingOverride {
2124 enabled: Some(true),
2125 ..Default::default()
2126 });
2127 let s = serde_yaml_ng::to_string(&p).unwrap();
2128 assert!(
2129 s.contains("fallback_chain:"),
2130 "yaml must contain fallback_chain"
2131 );
2132 assert!(s.contains("routing:"), "yaml must contain routing");
2133 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2134 assert_eq!(
2135 p2.fallback_chain,
2136 vec!["claude_opus", "claude_sonnet"],
2137 "fallback_chain must round-trip"
2138 );
2139 assert_eq!(
2140 p2.routing.as_ref().unwrap().enabled,
2141 Some(true),
2142 "routing.enabled must round-trip"
2143 );
2144 }
2145
2146 #[test]
2147 fn effective_smart_prefers_the_promoted_field_then_the_legacy_nesting() {
2148 use crate::config::{ModelSwitchConfig, SmartConfig, SmartOverride};
2149 let cfg = ModelSwitchConfig {
2150 smart: SmartConfig {
2151 enabled: false,
2152 cheap: Some("g".into()),
2153 max_escalations: 2,
2154 },
2155 ..Default::default()
2156 };
2157 let p = AgentProfile::default_for_tests();
2159 assert_eq!(p.effective_smart(&cfg), cfg.smart);
2160
2161 let mut legacy = AgentProfile::default_for_tests();
2163 legacy.routing = Some(crate::config::RoutingOverride {
2164 smart: Some(SmartOverride {
2165 enabled: Some(true),
2166 ..Default::default()
2167 }),
2168 ..Default::default()
2169 });
2170 assert!(
2171 legacy.effective_smart(&cfg).enabled,
2172 "legacy nesting is read"
2173 );
2174 assert_eq!(
2175 legacy.effective_smart(&cfg).cheap.as_deref(),
2176 Some("g"),
2177 "unset fields still inherit"
2178 );
2179
2180 let mut both = legacy.clone();
2182 both.smart = Some(SmartOverride {
2183 enabled: Some(false),
2184 ..Default::default()
2185 });
2186 assert!(!both.effective_smart(&cfg).enabled);
2187 }
2188}
2189
2190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2198#[serde(rename_all = "snake_case")]
2199pub enum ProactiveTier {
2200 Off,
2201 WarmOnly,
2202 WarmAndBehavior,
2203 All,
2204}
2205
2206impl ProactiveTier {
2207 pub fn from_config(c: &CompanionConfig) -> Self {
2208 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
2209 (false, _, _) => Self::Off,
2210 (true, false, false) => Self::WarmOnly,
2211 (true, true, false) => Self::WarmAndBehavior,
2212 (true, _, true) => Self::All,
2213 }
2214 }
2215
2216 pub fn apply(&self, c: &mut CompanionConfig) {
2217 match self {
2218 Self::Off => {
2219 c.enabled = false;
2220 c.rhythm.enabled = false;
2221 c.proactive.enabled = false;
2222 }
2223 Self::WarmOnly => {
2224 c.enabled = true;
2225 c.rhythm.enabled = false;
2226 c.proactive.enabled = false;
2227 }
2228 Self::WarmAndBehavior => {
2229 c.enabled = true;
2230 c.rhythm.enabled = true;
2231 c.proactive.enabled = false;
2232 }
2233 Self::All => {
2234 c.enabled = true;
2235 c.rhythm.enabled = true;
2236 c.proactive.enabled = true;
2237 }
2238 }
2239 }
2240}
2241
2242#[cfg(test)]
2243mod mcp_pin_tests {
2244 use super::*;
2245
2246 #[test]
2250 fn pre_m9_entry_roundtrips_without_pin_fields() {
2251 let yaml = r#"
2252name: weather
2253command: /opt/mcp/weather
2254args: ["--port", "0"]
2255"#;
2256 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2257 assert_eq!(entry.name, "weather");
2258 assert_eq!(entry.binary_sha256, None);
2259 assert_eq!(entry.description_hash, None);
2260 assert_eq!(entry.publisher, None);
2261 assert_eq!(entry.installed_at, None);
2262
2263 let out = serde_yaml_ng::to_string(&entry).unwrap();
2266 assert!(!out.contains("binary_sha256"), "got {out}");
2267 assert!(!out.contains("description_hash"), "got {out}");
2268 assert!(!out.contains("publisher"), "got {out}");
2269 assert!(!out.contains("installed_at"), "got {out}");
2270 }
2271
2272 #[test]
2274 fn full_m9_entry_roundtrips_all_fields() {
2275 let yaml = r#"
2276name: weather
2277command: /opt/mcp/weather
2278args: []
2279binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
2280description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
2281publisher:
2282 name: "@anthropic-mcp/weather"
2283 homepage: "https://github.com/anthropic-mcp/weather"
2284 registry_id: "@anthropic-mcp/weather@1.2.3"
2285installed_at: "2026-05-06T08:00:00Z"
2286"#;
2287 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2288 assert!(
2289 entry
2290 .binary_sha256
2291 .as_deref()
2292 .unwrap()
2293 .starts_with("3f4abca8")
2294 );
2295 assert!(
2296 entry
2297 .description_hash
2298 .as_deref()
2299 .unwrap()
2300 .starts_with("9a01b2c3")
2301 );
2302 let pub_info = entry.publisher.clone().unwrap();
2303 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
2304 assert_eq!(
2305 pub_info.homepage.as_deref(),
2306 Some("https://github.com/anthropic-mcp/weather"),
2307 );
2308 assert_eq!(
2309 pub_info.registry_id.as_deref(),
2310 Some("@anthropic-mcp/weather@1.2.3"),
2311 );
2312 let installed = entry.installed_at.unwrap();
2313 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
2314 }
2315
2316 #[test]
2320 fn partial_pin_only_binary_sha_roundtrips() {
2321 let yaml = r#"
2322name: weather
2323command: /opt/mcp/weather
2324args: []
2325binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
2326"#;
2327 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2328 assert_eq!(
2329 entry.binary_sha256.as_deref(),
2330 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
2331 );
2332 assert_eq!(entry.description_hash, None);
2333 assert_eq!(entry.publisher, None);
2334 }
2335
2336 #[test]
2339 fn publisher_minimal_just_name() {
2340 let yaml = r#"
2341name: weather
2342command: /opt/mcp/weather
2343args: []
2344publisher:
2345 name: "alice"
2346"#;
2347 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2348 let p = entry.publisher.as_ref().unwrap();
2349 assert_eq!(p.name, "alice");
2350 assert_eq!(p.homepage, None);
2351 assert_eq!(p.registry_id, None);
2352
2353 let out = serde_yaml_ng::to_string(&entry).unwrap();
2355 assert!(!out.contains("homepage:"), "got {out}");
2356 assert!(!out.contains("registry_id:"), "got {out}");
2357 }
2358}
2359
2360#[cfg(test)]
2361mod voice_tests {
2362 use super::*;
2363 use std::str::FromStr;
2364
2365 #[test]
2366 fn voice_config_round_trips() {
2367 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2369 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
2370
2371 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
2372 assert!(profile.voice.enabled);
2373 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
2374
2375 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
2377 assert!(!legacy.voice.enabled);
2378 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
2379 }
2380
2381 #[test]
2382 fn voice_id_from_str_roundtrips() {
2383 let cases = [
2384 ("af_heart", VoiceId::AfHeart),
2385 ("af_bella", VoiceId::AfBella),
2386 ("af_nicole", VoiceId::AfNicole),
2387 ("am_adam", VoiceId::AmAdam),
2388 ("am_michael", VoiceId::AmMichael),
2389 ];
2390 for (s, expected) in cases {
2391 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
2392 assert_eq!(expected.as_str(), s);
2393 }
2394 }
2395
2396 #[test]
2397 fn voice_id_from_str_rejects_unknown() {
2398 assert!(VoiceId::from_str("bogus").is_err());
2399 }
2400}
2401
2402#[cfg(test)]
2403mod idle_trigger_tests {
2404 use super::*;
2405
2406 #[test]
2407 fn idle_trigger_yaml_round_trip() {
2408 let yaml = r#"
2409restart: on_failure
2410idle_triggers:
2411 - after_secs: 3600
2412 message: "still there?"
2413 sends_to: other_agent
2414 cooldown_secs: 1800
2415 respect_quiet_hours: true
2416"#;
2417 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2418 assert_eq!(cfg.idle_triggers.len(), 1);
2419 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
2420 assert_eq!(cfg.idle_triggers[0].message, "still there?");
2421 assert_eq!(
2422 cfg.idle_triggers[0].sends_to.as_deref(),
2423 Some("other_agent")
2424 );
2425 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
2426 assert!(cfg.idle_triggers[0].respect_quiet_hours);
2427 }
2428
2429 #[test]
2430 fn idle_trigger_defaults_when_omitted() {
2431 let yaml = "restart: on_failure\n";
2432 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2433 assert!(cfg.idle_triggers.is_empty());
2434 }
2435}
2436
2437#[cfg(test)]
2438mod appearance_tests {
2439 use super::*;
2440
2441 #[test]
2442 fn appearance_default_style_preset_is_default_blob() {
2443 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
2444 }
2445
2446 #[test]
2447 fn appearance_default_behavior_is_normal() {
2448 assert_eq!(
2449 AgentAppearance::default().behavior_preset,
2450 BehaviorPreset::Normal
2451 );
2452 }
2453
2454 #[test]
2455 fn appearance_default_render_status_is_pending() {
2456 assert_eq!(
2457 AgentAppearance::default().render_status,
2458 RenderStatus::Pending
2459 );
2460 }
2461
2462 #[test]
2463 fn render_status_serde_round_trip() {
2464 let cases = [
2465 RenderStatus::Pending,
2466 RenderStatus::Rendering { done: 3, total: 12 },
2467 RenderStatus::Ready,
2468 RenderStatus::Failed {
2469 reason: "out of quota".into(),
2470 },
2471 ];
2472 for status in cases {
2473 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
2474 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
2475 assert_eq!(status, back);
2476 }
2477 }
2478
2479 #[test]
2480 fn agent_profile_with_appearance_round_trips() {
2481 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2482 let yaml = format!(
2483 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
2484 );
2485 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2486 assert_eq!(profile.appearance.style_preset, "chiikawa");
2487 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2488
2489 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2490 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2491 assert_eq!(profile.appearance, back.appearance);
2492 }
2493
2494 #[test]
2495 fn legacy_profile_without_appearance_uses_default() {
2496 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2497 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2498 assert_eq!(profile.appearance.style_preset, "default-blob");
2499 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2500 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2501 }
2502
2503 #[test]
2504 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2505 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2506 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2507 assert!(p.file_actions.is_empty());
2508 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2509 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2510 }
2511}
2512
2513#[cfg(test)]
2514mod federation_tests {
2515 use super::*;
2516
2517 #[test]
2518 fn test_pattern_filter_default() {
2519 let f = PatternFilter::default();
2520 assert_eq!(f.max_count, 200);
2521 assert_eq!(f.importance_min, 0.0);
2522 assert!(f.tier.is_empty());
2523 }
2524
2525 #[test]
2526 fn test_federation_config_roundtrip() {
2527 let cfg = FederationConfig {
2528 filter: PatternFilter {
2529 tier: vec!["core".into()],
2530 max_count: 50,
2531 ..Default::default()
2532 },
2533 snapshot_ref: Some(SnapshotRef {
2534 knowledge_commit: "abc123def456".into(),
2535 taken_at: "2026-05-19T00:00:00Z".into(),
2536 filter: PatternFilter::default(),
2537 }),
2538 evidence_flush_interval_minutes: 15,
2539 };
2540 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2541 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2542 assert_eq!(cfg, back);
2543 }
2544
2545 #[test]
2546 fn test_agent_profile_federation_defaults() {
2547 let cfg = FederationConfig::default();
2551 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2552 assert!(cfg.snapshot_ref.is_none());
2553 }
2554}
2555
2556#[cfg(test)]
2557mod skill_card_tests {
2558 use super::*;
2559
2560 #[test]
2561 fn installed_skills_default_to_empty_when_absent() {
2562 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2563 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2564 assert!(p.installed_skills.is_empty());
2565 }
2566
2567 #[test]
2568 fn installed_skills_roundtrip_preserves_entries() {
2569 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2570 let yaml = format!(
2571 "{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"
2572 );
2573 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2574 assert_eq!(p.installed_skills.len(), 1);
2575 assert_eq!(p.installed_skills[0].name, "s1");
2576 assert_eq!(p.installed_skills[0].abstract_text, "does things");
2577 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2578
2579 let out = serde_yaml_ng::to_string(&p).unwrap();
2580 assert!(out.contains("abstract: does things"));
2581 assert!(out.contains("pattern: /find"));
2582
2583 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2584 assert_eq!(p.installed_skills, back.installed_skills);
2585 }
2586
2587 #[test]
2588 fn installed_skills_minimal_entry_serializes_compactly() {
2589 let entry = SkillCardEntry {
2591 name: "minimal".into(),
2592 ..Default::default()
2593 };
2594 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2595 assert!(yaml.contains("name: minimal"));
2596 assert!(
2597 !yaml.contains("version:"),
2598 "empty version must be skipped: {yaml}"
2599 );
2600 assert!(
2601 !yaml.contains("publisher:"),
2602 "empty publisher must be skipped: {yaml}"
2603 );
2604 assert!(
2605 !yaml.contains("abstract:"),
2606 "empty abstract must be skipped: {yaml}"
2607 );
2608 }
2609}
2610
2611#[cfg(test)]
2612mod tool_policy_tests {
2613 use super::*;
2614
2615 fn rules() -> Vec<ToolRule> {
2616 vec![
2617 ToolRule {
2618 pattern: "mcp__github__merge_pr".into(),
2619 policy: ToolPolicy::Ask,
2620 risk: None,
2621 },
2622 ToolRule {
2623 pattern: "mcp__github__*".into(),
2624 policy: ToolPolicy::Allow,
2625 risk: None,
2626 },
2627 ToolRule {
2628 pattern: "mcp__*".into(),
2629 policy: ToolPolicy::Deny,
2630 risk: None,
2631 },
2632 ToolRule {
2633 pattern: "bash".into(),
2634 policy: ToolPolicy::Allow,
2635 risk: None,
2636 },
2637 ]
2638 }
2639
2640 #[test]
2641 fn exact_beats_glob() {
2642 assert_eq!(
2643 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2644 ToolPolicy::Ask
2645 );
2646 }
2647
2648 #[test]
2649 fn longer_glob_wins() {
2650 assert_eq!(
2651 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2652 ToolPolicy::Allow
2653 );
2654 }
2655
2656 #[test]
2657 fn shorter_glob_fallback() {
2658 assert_eq!(
2659 resolve_tool_policy(&rules(), "mcp__slack__send"),
2660 ToolPolicy::Deny
2661 );
2662 }
2663
2664 #[test]
2665 fn exact_bash() {
2666 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2667 }
2668
2669 #[test]
2670 fn unknown_tool_defaults_ask() {
2671 assert_eq!(
2672 resolve_tool_policy(&rules(), "unknown_tool"),
2673 ToolPolicy::Ask
2674 );
2675 }
2676
2677 #[test]
2678 fn empty_rules_defaults_ask() {
2679 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2680 }
2681
2682 fn minimal_entitlements_yaml() -> &'static str {
2683 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2684 }
2685
2686 #[test]
2687 fn entitlements_tools_defaults_empty() {
2688 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2689 assert!(e.tools.is_empty());
2690 }
2691
2692 #[test]
2693 fn entitlements_tools_roundtrip() {
2694 let base = minimal_entitlements_yaml();
2695 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2696 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2697 assert_eq!(e.tools.len(), 1);
2698 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2699 let y = serde_yaml_ng::to_string(&e).unwrap();
2700 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2701 assert_eq!(back.tools.len(), 1);
2702 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2703 }
2704 #[test]
2705 fn denylist_membership_and_mutation() {
2706 let mut list: Vec<String> = vec![];
2707 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2708
2709 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2711 assert_eq!(list, ["a"]);
2712
2713 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2715
2716 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2718 assert!(list.is_empty());
2719
2720 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2722 }
2723
2724 #[test]
2725 fn addon_group_rule_truth_table() {
2726 let mut p = crate::agent::AgentProfile::default_for_tests();
2727 p.addons.push(AddonRef {
2728 id: "grp".into(),
2729 source: "claude-local:grp@1.0.0".into(),
2730 enabled: false,
2731 skills: vec!["g_skill".into()],
2732 mcp: vec!["g_mcp".into()],
2733 commands: vec!["g_cmd".into()],
2734 content_hash: None,
2735 fetch_ref: None,
2736 fetch_plugin: None,
2737 });
2738
2739 assert!(p.skill_enabled("standalone"));
2741 assert!(p.mcp_enabled("standalone_mcp"));
2742
2743 assert!(!p.skill_enabled("g_skill"));
2745 assert!(!p.mcp_enabled("g_mcp"));
2746
2747 assert!(p.set_addon_enabled("grp", true));
2749 assert!(p.skill_enabled("g_skill"));
2750 assert!(p.mcp_enabled("g_mcp"));
2751
2752 p.set_skill_enabled("g_skill", false);
2754 assert!(!p.skill_enabled("g_skill"));
2755
2756 assert!(!p.set_addon_enabled("nope", true));
2758
2759 p.disable_all_addons();
2761 assert!(p.addons.iter().all(|g| !g.enabled));
2762 assert!(!p.skill_enabled("g_skill"));
2763 assert!(!p.skill_enabled("g_cmd"));
2764 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2770 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);
2776 assert!(p.skill_enabled("g_skill"));
2777 }
2778
2779 #[test]
2780 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2781 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2783 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2784 assert_eq!(r.content_hash, None);
2785 assert_eq!(r.fetch_ref, None);
2786
2787 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2789 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2790 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2791 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2792 let back = serde_yaml_ng::to_string(&r2).unwrap();
2793 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2794 assert_eq!(r2, r3);
2795 }
2796}
2797
2798#[cfg(test)]
2799mod lockfile_compat_tests {
2800 use super::*;
2801
2802 #[test]
2803 fn lockfile_new_fields_default_for_old_locks() {
2804 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2807 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2808 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2809 let lock: LockFile = serde_json::from_str(old).unwrap();
2810 assert_eq!(lock.build_sha, "");
2811 assert_eq!(lock.proto_version, 0);
2812 }
2813}
2814
2815#[cfg(test)]
2816mod remote_mcp_tests {
2817 use super::*;
2818
2819 #[test]
2820 fn mcp_entry_roundtrips_remote_bearer() {
2821 let e = McpServerEntry {
2822 name: "gh".into(),
2823 command: String::new(),
2824 url: Some("https://api.example.com/mcp".into()),
2825 auth: Some(McpAuth::Bearer {
2826 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2827 }),
2828 ..Default::default()
2829 };
2830 let y = serde_yaml_ng::to_string(&e).unwrap();
2831 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2832 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2833 assert!(matches!(
2834 back.auth,
2835 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2836 ));
2837 let legacy: McpServerEntry =
2839 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2840 assert!(legacy.url.is_none());
2841 assert!(legacy.auth.is_none());
2842 }
2843}
2844
2845#[cfg(test)]
2846mod requires_programs_tests {
2847 #[test]
2848 fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2849 let with = r#"
2850name: research-gateway
2851command: mur-research-gateway
2852requires_programs:
2853 - name: lightpanda
2854 detect: { file: "~/.mur/aura/lightpanda" }
2855 reason: "render tier"
2856 registry: lightpanda
2857"#;
2858 let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2859 assert_eq!(e.requires_programs.len(), 1);
2860 assert_eq!(e.requires_programs[0].name, "lightpanda");
2861
2862 let without = "name: x\ncommand: y\n";
2864 let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2865 assert!(e2.requires_programs.is_empty());
2866 }
2867}
2868
2869#[cfg(test)]
2870mod secrets_field_tests {
2871 #[test]
2875 fn secrets_names_round_trip_and_are_absent_when_empty() {
2876 let mut p = crate::agent::AgentProfile::default_for_tests();
2877 let yaml = serde_yaml::to_string(&p).unwrap();
2878 assert!(
2879 !yaml.contains("secrets:"),
2880 "empty list must not be written: {yaml}"
2881 );
2882 p.secrets = vec!["GITEA_TOKEN".into()];
2883 let yaml = serde_yaml::to_string(&p).unwrap();
2884 let back: crate::agent::AgentProfile = serde_yaml::from_str(&yaml).unwrap();
2885 assert_eq!(back.secrets, vec!["GITEA_TOKEN".to_string()]);
2886 }
2887}