1use crate::companion::{Formality, Relationship};
5use crate::deps::ProgramDep;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
15pub struct SkillCardEntry {
16 pub name: String,
17 #[serde(default, skip_serializing_if = "String::is_empty")]
18 pub version: String,
19 #[serde(default, skip_serializing_if = "String::is_empty")]
20 pub publisher: String,
21 #[serde(default, skip_serializing_if = "String::is_empty")]
22 pub description: String,
23 #[serde(default, skip_serializing_if = "String::is_empty")]
24 pub category: String,
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub tags: Vec<String>,
27 #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 pub triggers: Vec<SkillCardTrigger>,
29 #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
32 pub abstract_text: String,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub transfer_chain: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
40pub struct SkillCardTrigger {
41 #[serde(rename = "type")]
42 pub kind: String,
43 #[serde(default, skip_serializing_if = "String::is_empty")]
44 pub pattern: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct AgentProfile {
49 pub schema: u32,
50 pub id: String, pub name: String,
52 pub display_name: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub role: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub effort: Option<crate::llm::Effort>,
73 pub version: String,
74 pub persona: Persona,
75 pub sys_prompt_file: String,
76 pub model: ModelConfig,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub model_ref: Option<String>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
84 pub fallback_chain: Vec<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub routing: Option<crate::config::RoutingConfig>,
89 #[serde(default)]
90 pub mcp_servers: Vec<McpServerEntry>,
91 #[serde(default)]
92 pub skills: Vec<String>,
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub installed_skills: Vec<SkillCardEntry>,
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub disabled_skills: Vec<String>,
104
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub disabled_mcp: Vec<String>,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub addons: Vec<AddonRef>,
115 pub transport: TransportConfig,
116 pub communication: CommunicationConfig,
117 #[serde(default)]
118 pub capabilities: Vec<String>,
119 pub entitlements: Entitlements,
120 #[serde(default)]
121 pub notifications: NotificationsConfig,
122 pub retry: RetryConfig,
123 pub lifecycle: LifecycleConfig,
124 #[serde(default)]
127 pub identity: IdentityConfig,
128 #[serde(default)]
129 pub file_transfer: FileTransferConfig,
130 #[serde(default)]
131 pub deployment: DeploymentConfig,
132 #[serde(default)]
135 pub companion: CompanionConfig,
136 #[serde(default)]
138 pub hitl: HitlConfig,
139 #[serde(default)]
141 pub voice: VoiceConfig,
142 #[serde(default)]
144 pub hooks: crate::HooksConfig,
145 #[serde(default)]
148 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
149 pub created_at: String,
150 pub updated_at: String,
151 #[serde(default)]
153 pub appearance: AgentAppearance,
154 #[serde(default)]
156 pub federation: FederationConfig,
157
158 #[serde(default)]
162 pub file_actions: Vec<crate::action::FileAction>,
163
164 #[serde(default)]
166 pub action_pipeline: crate::action::ActionPipelineConfig,
167
168 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub requires_programs: Vec<ProgramDep>,
172
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub requires_capabilities: Vec<String>,
177}
178
179fn default_algorithm() -> String {
180 "ed25519".into()
181}
182
183pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
187pub struct IdentityConfig {
188 #[serde(default)]
191 pub pubkey: String,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub owner: Option<String>,
195
196 #[serde(default = "default_algorithm")]
199 pub algorithm: String,
200 #[serde(default)]
202 pub key_version: u32,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub created_at_key: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub previous_pubkey: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub previous_key_version: Option<u32>,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub grace_expires_at: Option<String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub rotated_at: Option<String>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub emergency_rekey_at: Option<String>,
222}
223
224impl Default for IdentityConfig {
225 fn default() -> Self {
226 Self {
227 pubkey: String::new(),
228 owner: None,
229 algorithm: default_algorithm(),
230 key_version: 0,
231 created_at_key: None,
232 previous_pubkey: None,
233 previous_key_version: None,
234 grace_expires_at: None,
235 rotated_at: None,
236 emergency_rekey_at: None,
237 }
238 }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
242pub struct Persona {
243 pub category: PersonaCategory,
244 pub description: String,
245 pub traits: PersonaTraits,
246}
247
248#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
249#[serde(rename_all = "lowercase")]
250pub enum PersonaCategory {
251 Research,
252 Automation,
253 Monitor,
254 Notify,
255 Commerce,
256 Custom,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
260pub struct PersonaTraits {
261 pub tone: String,
262 pub risk: String,
263 pub verbosity: String,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267pub struct ModelConfig {
268 pub provider: String,
269 pub name: String,
270 #[serde(default)]
271 pub params: BTreeMap<String, serde_yaml_ng::Value>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
275pub struct McpServerEntry {
276 pub name: String,
277 pub command: String,
278 #[serde(default)]
279 pub args: Vec<String>,
280
281 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub binary_sha256: Option<String>,
288
289 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub description_hash: Option<String>,
296
297 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub publisher: Option<McpPublisherInfo>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
308
309 #[serde(default, skip_serializing_if = "Option::is_none")]
313 pub timeout_secs: Option<u32>,
314
315 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub network: Option<McpServerNetwork>,
321
322 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub url: Option<String>,
326
327 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub auth: Option<McpAuth>,
331
332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
335 pub requires_programs: Vec<ProgramDep>,
336
337 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub package: Option<McpPackagePin>,
346}
347
348#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
361pub struct McpPackagePin {
362 pub runner: String,
364 pub name: String,
366 pub version: String,
368 pub install_dir: String,
370 pub lockfile_sha256: String,
372
373 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub signatures_missing: Option<u32>,
386
387 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub provenance: Option<String>,
398}
399
400impl McpPackagePin {
401 pub fn lockfile_name(&self) -> &'static str {
408 match self.runner.as_str() {
409 "pypi" => "requirements.lock",
410 _ => "package-lock.json",
411 }
412 }
413
414 pub fn lockfile_path(&self) -> std::path::PathBuf {
420 std::path::Path::new(&self.install_dir).join(self.lockfile_name())
421 }
422}
423
424#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
426#[serde(rename_all = "snake_case", tag = "kind")]
427pub enum McpAuth {
428 Bearer { token: crate::secret::SecretRef },
430 Oauth(OauthAuth),
432}
433
434#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
436pub struct OauthAuth {
437 pub token_endpoint: String,
439 pub client_id: String,
441 pub access_token: crate::secret::SecretRef,
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub refresh_token: Option<crate::secret::SecretRef>,
446 #[serde(default)]
448 pub expires_at: u64,
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
453#[serde(rename_all = "snake_case")]
454pub enum McpNetMode {
455 #[default]
469 Inherit,
470 Restricted,
472 BroadAudited,
478 Off,
480}
481
482pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
491
492#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
494pub struct McpServerNetwork {
495 #[serde(default)]
496 pub mode: McpNetMode,
497 #[serde(default)]
498 pub allow_hosts: Vec<String>,
499 #[serde(default)]
502 pub deny_hosts: Vec<String>,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub authorization: Option<EgressAuthorization>,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
517pub struct AddonRef {
518 pub id: String,
520 pub source: String,
522 #[serde(default)]
523 pub enabled: bool,
524 #[serde(default, skip_serializing_if = "Vec::is_empty")]
525 pub skills: Vec<String>,
526 #[serde(default, skip_serializing_if = "Vec::is_empty")]
527 pub mcp: Vec<String>,
528 #[serde(default, skip_serializing_if = "Vec::is_empty")]
529 pub commands: Vec<String>,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub content_hash: Option<String>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub fetch_ref: Option<String>,
539 #[serde(default, skip_serializing_if = "Option::is_none")]
544 pub fetch_plugin: Option<String>,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
553pub struct McpPublisherInfo {
554 pub name: String,
557
558 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub homepage: Option<String>,
563
564 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub registry_id: Option<String>,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
571pub struct TransportConfig {
572 pub stdio: bool,
573 pub socket: SocketTransportConfig,
574 #[serde(default)]
575 pub tcp: TcpTransportConfig,
576 #[serde(default)]
580 pub webhook: WebhookTransportConfig,
581}
582
583#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
584pub struct TcpTransportConfig {
585 #[serde(default)]
586 pub enabled: bool,
587 #[serde(default)]
588 pub bind: String,
589 #[serde(default)]
590 pub noise: NoiseConfig,
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
607pub struct WebhookTransportConfig {
608 #[serde(default)]
609 pub enabled: bool,
610 #[serde(default = "default_webhook_bind")]
611 pub bind: String,
612 #[serde(default = "default_webhook_port")]
613 pub port: u16,
614 #[serde(default)]
618 pub hmac_secret_ref: String,
619}
620
621fn default_webhook_bind() -> String {
622 "127.0.0.1".to_string()
623}
624
625fn default_webhook_port() -> u16 {
626 6789
627}
628
629impl Default for WebhookTransportConfig {
630 fn default() -> Self {
631 Self {
632 enabled: false,
633 bind: default_webhook_bind(),
634 port: default_webhook_port(),
635 hmac_secret_ref: String::new(),
636 }
637 }
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
641pub struct NoiseConfig {
642 pub pattern: String,
643}
644
645impl Default for NoiseConfig {
646 fn default() -> Self {
647 Self {
648 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
649 }
650 }
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
654pub struct SocketTransportConfig {
655 pub enabled: bool,
656 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub auth: Option<AuthConfig>,
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
662pub struct AuthConfig {
663 pub scheme: String,
664 pub token_file: String,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
668pub struct CommunicationConfig {
669 #[serde(default = "default_accepts_all")]
670 pub accepts_from: Vec<String>,
671 #[serde(default)]
672 pub sends_to: Vec<String>,
673}
674fn default_accepts_all() -> Vec<String> {
675 vec!["*".to_string()]
676}
677
678#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
679pub struct Entitlements {
680 pub network: NetworkEntitlement,
681 pub filesystem: FilesystemEntitlement,
682 pub processes: ProcessesEntitlement,
683 #[serde(default)]
684 pub syscalls: SyscallsEntitlement,
685 #[serde(default)]
686 pub limits: LimitsEntitlement,
687 #[serde(default)]
690 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
691 #[serde(default, skip_serializing_if = "Vec::is_empty")]
693 pub tools: Vec<ToolRule>,
694 #[serde(default = "default_true")]
699 pub fail_closed_on_sandbox_error: bool,
700}
701
702#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
703pub struct NetworkEntitlement {
704 pub inbound: InboundNetwork,
705 pub outbound: OutboundNetwork,
706}
707
708#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
709pub struct InboundNetwork {
710 #[serde(default)]
711 pub ports: Vec<u16>,
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
715pub struct OutboundNetwork {
716 pub mode: NetworkOutboundMode,
717 #[serde(default)]
718 pub allow_hosts: Vec<String>,
719 #[serde(default = "default_protocols")]
720 pub protocols: Vec<String>,
721 #[serde(default)]
722 pub resolve_dns: ResolveDnsConfig,
723}
724fn default_protocols() -> Vec<String> {
725 vec!["tcp".to_string()]
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
732pub struct EgressAuthorization {
733 pub authorized_by: String,
734 pub authorized_at_ms: u64,
735}
736
737#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
738#[serde(rename_all = "lowercase")]
739pub enum NetworkOutboundMode {
740 Unrestricted,
741 Restricted,
742 ProxyOnly,
746 Off,
747}
748
749#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
750pub struct ResolveDnsConfig {
751 #[serde(default = "default_dns_mode")]
752 pub mode: String,
753 #[serde(default)]
754 pub servers: Vec<String>,
755}
756impl Default for ResolveDnsConfig {
757 fn default() -> Self {
758 Self {
759 mode: default_dns_mode(),
760 servers: vec![],
761 }
762 }
763}
764fn default_dns_mode() -> String {
765 "system".to_string()
766}
767
768pub const AUTHORING_DIRS: [&str; 4] = ["skills", "workflows", "fleets", "artifacts"];
779
780#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
781pub struct FilesystemEntitlement {
782 #[serde(default)]
783 pub read: Vec<String>,
784 #[serde(default)]
785 pub write: Vec<String>,
786 #[serde(default)]
787 pub deny: Vec<String>,
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
791pub struct ProcessesEntitlement {
792 pub spawn: SpawnEntitlement,
793}
794
795#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
796pub struct SpawnEntitlement {
797 pub mode: SpawnMode,
798 #[serde(default)]
799 pub allowed: Vec<String>,
800 #[serde(default)]
815 pub allowed_dirs: Vec<String>,
816}
817
818#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
819#[serde(rename_all = "lowercase")]
820pub enum SpawnMode {
821 Allowlist,
822 Any,
823 None,
824 Strict,
830}
831
832#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
833pub struct SyscallsEntitlement {
834 #[serde(default = "default_syscalls_mode")]
835 pub mode: String,
836 #[serde(default)]
837 pub extra_deny: Vec<String>,
838}
839fn default_syscalls_mode() -> String {
840 "default".to_string()
841}
842
843#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
844pub struct LimitsEntitlement {
845 #[serde(default)]
846 pub cpu_seconds: Option<u64>,
847 #[serde(default = "default_memory_mb")]
848 pub memory_mb: u64,
849 #[serde(default = "default_fds")]
850 pub file_descriptors: u32,
851 #[serde(default = "default_procs")]
852 pub processes: u32,
853}
854fn default_memory_mb() -> u64 {
855 512
856}
857fn default_fds() -> u32 {
858 1024
859}
860fn default_procs() -> u32 {
861 32
862}
863
864#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
865#[serde(rename_all = "lowercase")]
866pub enum ToolPolicy {
867 Allow,
868 #[default]
869 Ask,
870 Deny,
871}
872
873#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
874pub struct ToolRule {
875 pub pattern: String,
876 pub policy: ToolPolicy,
877 #[serde(default, skip_serializing_if = "Option::is_none")]
880 pub risk: Option<crate::hitl::RiskTier>,
881}
882
883pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
887 resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
888}
889
890pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
895 for rule in rules {
896 if rule.pattern == tool_name {
897 return Some(rule.policy);
898 }
899 }
900 let mut best: Option<(&ToolRule, usize)> = None;
901 for rule in rules {
902 if let Some(prefix) = rule.pattern.strip_suffix('*')
903 && tool_name.starts_with(prefix)
904 {
905 let len = prefix.len();
906 if best.is_none_or(|(_, best_len)| len > best_len) {
907 best = Some((rule, len));
908 }
909 }
910 }
911 best.map(|(rule, _)| rule.policy)
912}
913
914#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
915pub struct NotificationsConfig {
916 #[serde(default)]
917 pub on_task_complete: Vec<NotificationTarget>,
918 #[serde(default)]
919 pub on_error: Vec<NotificationTarget>,
920 #[serde(default)]
921 pub on_shutdown: Vec<NotificationTarget>,
922}
923
924#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
925#[serde(tag = "target", rename_all = "lowercase")]
926pub enum NotificationTarget {
927 Agent {
928 name: String,
929 },
930 Commander,
931 Email {
932 address: String,
933 #[serde(default)]
934 smtp_config_file: Option<String>,
935 },
936 Slack {
937 #[serde(default)]
938 channel: Option<String>,
939 #[serde(default)]
940 webhook_url_env: Option<String>,
941 },
942 Webpush {
943 url: String,
944 },
945 Webhook {
946 url: String,
947 #[serde(default = "default_post")]
948 method: String,
949 #[serde(default)]
950 auth: Option<String>,
951 },
952}
953fn default_post() -> String {
954 "POST".to_string()
955}
956
957#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
958pub struct RetryConfig {
959 pub llm: RetryPolicy,
960 pub tool: RetryPolicy,
961}
962
963#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
964pub struct RetryPolicy {
965 pub max_retries: u32,
966 pub backoff: BackoffStrategy,
967 pub initial_delay_ms: u64,
968 #[serde(default)]
969 pub max_delay_ms: Option<u64>,
970 #[serde(default)]
971 pub retry_on: Vec<String>,
972}
973
974#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
975#[serde(rename_all = "lowercase")]
976pub enum BackoffStrategy {
977 Linear,
978 Exponential,
979 Fixed,
980}
981
982#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
983pub struct LifecycleConfig {
984 pub restart: RestartPolicy,
985 #[serde(default = "default_max_restarts")]
986 pub max_restarts: u32,
987 #[serde(default = "default_window")]
988 pub restart_window_secs: u64,
989 #[serde(default = "default_stop_timeout")]
990 pub stop_timeout_secs: u64,
991 #[serde(default = "default_mcp_required")]
992 pub mcp_required: bool,
993 #[serde(default)]
994 pub execution: ExecutionMode,
995 #[serde(default)]
996 pub schedule: Vec<ScheduleEntry>,
997 #[serde(default)]
998 pub idle_triggers: Vec<IdleTrigger>,
999}
1000fn default_max_restarts() -> u32 {
1001 3
1002}
1003fn default_window() -> u64 {
1004 600
1005}
1006fn default_stop_timeout() -> u64 {
1007 15
1008}
1009fn default_mcp_required() -> bool {
1010 true
1011}
1012
1013#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1014#[serde(rename_all = "snake_case")]
1015pub enum RestartPolicy {
1016 Never,
1017 OnFailure,
1018 Always,
1019}
1020
1021#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1022#[serde(rename_all = "snake_case")]
1023pub enum ExecutionMode {
1024 #[default]
1025 Daemon,
1026 OnDemand,
1027}
1028
1029pub const SCHEDULE_PROPOSAL_DIR: &str = "schedule-proposals";
1044
1045pub const SCHEDULE_CHANNEL_FILE: &str = "schedule-channel";
1049
1050#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1052pub struct ScheduleProposal {
1053 pub cron: String,
1054 pub message: String,
1055 #[serde(default, skip_serializing_if = "Option::is_none")]
1059 pub asked_for: Option<String>,
1060 #[serde(default, skip_serializing_if = "Option::is_none")]
1064 pub not_after: Option<String>,
1065 pub proposed_at: String,
1066}
1067
1068#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1069pub struct ScheduleEntry {
1070 pub cron: String,
1071 pub message: String,
1072 #[serde(default, skip_serializing_if = "Option::is_none")]
1073 pub sends_to: Option<String>,
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1086 pub not_after: Option<String>,
1087}
1088
1089#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1090pub struct IdleTrigger {
1091 pub after_secs: u64,
1093 pub message: String,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub sends_to: Option<String>,
1098 #[serde(default = "default_idle_cooldown")]
1101 pub cooldown_secs: u64,
1102 #[serde(default = "default_true")]
1105 pub respect_quiet_hours: bool,
1106}
1107
1108fn default_idle_cooldown() -> u64 {
1109 600
1110}
1111pub fn name_enabled(denylist: &[String], name: &str) -> bool {
1113 !denylist.iter().any(|n| n == name)
1114}
1115
1116pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
1119 if enabled {
1120 list.retain(|n| n != name);
1121 } else if !list.iter().any(|n| n == name) {
1122 list.push(name.to_string());
1123 }
1124}
1125
1126fn default_true() -> bool {
1127 true
1128}
1129
1130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1131pub struct FileTransferConfig {
1132 #[serde(default = "default_accept_max")]
1133 pub accept_incoming_file_max_bytes: u64,
1134 #[serde(default = "default_accept_total")]
1135 pub accept_incoming_total_per_hour: u64,
1136 #[serde(default = "default_approval_threshold")]
1137 pub require_approval_above_bytes: u64,
1138 #[serde(default = "default_reject_paths")]
1139 pub reject_paths: Vec<String>,
1140 #[serde(default = "default_allowed_mime")]
1141 pub allowed_mime_types: Vec<String>,
1142}
1143
1144impl Default for FileTransferConfig {
1145 fn default() -> Self {
1146 Self {
1147 accept_incoming_file_max_bytes: default_accept_max(),
1148 accept_incoming_total_per_hour: default_accept_total(),
1149 require_approval_above_bytes: default_approval_threshold(),
1150 reject_paths: default_reject_paths(),
1151 allowed_mime_types: default_allowed_mime(),
1152 }
1153 }
1154}
1155
1156fn default_accept_max() -> u64 {
1157 10_485_760
1158}
1159fn default_accept_total() -> u64 {
1160 104_857_600
1161}
1162fn default_approval_threshold() -> u64 {
1163 10_485_760
1164}
1165fn default_reject_paths() -> Vec<String> {
1166 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
1167}
1168fn default_allowed_mime() -> Vec<String> {
1169 vec!["*".into()]
1170}
1171
1172#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1173#[serde(rename_all = "snake_case")]
1174pub enum DeploymentType {
1175 #[default]
1176 Laptop,
1177 Vm,
1178 Docker,
1179 K8s,
1180 Lambda,
1181}
1182
1183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1184pub struct DeploymentConfig {
1185 #[serde(rename = "type", default)]
1186 pub deployment_type: DeploymentType,
1187 #[serde(default, skip_serializing_if = "Option::is_none")]
1188 pub region: Option<String>,
1189 #[serde(default = "default_env")]
1190 pub environment: Option<String>,
1191}
1192
1193impl Default for DeploymentConfig {
1194 fn default() -> Self {
1195 Self {
1196 deployment_type: DeploymentType::default(),
1197 region: None,
1198 environment: default_env(),
1199 }
1200 }
1201}
1202
1203fn default_env() -> Option<String> {
1204 Some("dev".into())
1205}
1206
1207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1213pub struct DroppedGrant {
1214 pub path: String,
1215 pub verb: String,
1217 pub reason: String,
1218}
1219
1220pub fn filesystem_grants_digest(fs: &FilesystemEntitlement) -> String {
1227 use sha2::{Digest, Sha256};
1228 let mut h = Sha256::new();
1229 for (label, list) in [("r", &fs.read), ("w", &fs.write), ("d", &fs.deny)] {
1230 let mut sorted = list.clone();
1231 sorted.sort();
1232 for p in sorted {
1233 h.update(label.as_bytes());
1234 h.update(b"\0");
1235 h.update(p.as_bytes());
1236 h.update(b"\0");
1237 }
1238 }
1239 format!("sha256:{:x}", h.finalize())
1240}
1241
1242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1250pub struct SandboxRecord {
1251 pub enforcing: bool,
1255 pub mode: String,
1257 pub granted_digest: String,
1262 #[serde(default)]
1264 pub dropped: Vec<DroppedGrant>,
1265}
1266
1267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1268pub struct LockFile {
1269 pub schema: u32,
1270 pub uuid: String,
1271 pub name: String,
1272 pub pid: u32,
1273 pub ppid: u32,
1274 pub started_at: String,
1275 pub binary_version: String,
1276 pub transports: LockTransports,
1277 pub card_digest: String,
1278 pub capabilities: Vec<String>,
1279 #[serde(default)]
1282 pub build_sha: String,
1283 #[serde(default)]
1286 pub proto_version: u32,
1287 #[serde(default, skip_serializing_if = "Option::is_none")]
1290 pub sandbox: Option<SandboxRecord>,
1291}
1292
1293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1294pub struct LockTransports {
1295 pub stdio: bool,
1296 #[serde(default)]
1297 pub unix_socket: Option<String>,
1298 #[serde(default)]
1299 pub tcp: Option<String>,
1300 #[serde(default)]
1305 pub webhook: Option<String>,
1306}
1307
1308#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1315#[serde(rename_all = "snake_case")]
1316pub enum VoiceId {
1317 #[default]
1319 AfHeart,
1320 AfBella,
1321 AfNicole,
1322 AmAdam,
1323 AmMichael,
1324}
1325
1326impl VoiceId {
1327 pub fn style_index(&self) -> usize {
1329 match self {
1330 VoiceId::AfHeart => 0,
1331 VoiceId::AfBella => 1,
1332 VoiceId::AfNicole => 2,
1333 VoiceId::AmAdam => 3,
1334 VoiceId::AmMichael => 4,
1335 }
1336 }
1337
1338 pub fn as_str(&self) -> &'static str {
1340 match self {
1341 VoiceId::AfHeart => "af_heart",
1342 VoiceId::AfBella => "af_bella",
1343 VoiceId::AfNicole => "af_nicole",
1344 VoiceId::AmAdam => "am_adam",
1345 VoiceId::AmMichael => "am_michael",
1346 }
1347 }
1348}
1349
1350impl std::str::FromStr for VoiceId {
1351 type Err = anyhow::Error;
1352
1353 fn from_str(s: &str) -> anyhow::Result<Self> {
1354 match s {
1355 "af_heart" => Ok(VoiceId::AfHeart),
1356 "af_bella" => Ok(VoiceId::AfBella),
1357 "af_nicole" => Ok(VoiceId::AfNicole),
1358 "am_adam" => Ok(VoiceId::AmAdam),
1359 "am_michael" => Ok(VoiceId::AmMichael),
1360 other => anyhow::bail!(
1361 "unknown voice ID '{other}' \
1362 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1363 ),
1364 }
1365 }
1366}
1367
1368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1371pub struct VoiceConfig {
1372 #[serde(default)]
1374 pub enabled: bool,
1375 #[serde(default)]
1377 pub voice_id: VoiceId,
1378 #[serde(default, skip_serializing_if = "Option::is_none")]
1381 pub input_device: Option<String>,
1382}
1383
1384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1389pub struct HitlConfig {
1390 #[serde(default = "default_hitl_timeout_secs")]
1391 pub timeout_secs: u32,
1392 #[serde(default)]
1396 pub max_iterations: Option<u32>,
1397 #[serde(default)]
1402 pub max_tokens: Option<u64>,
1403}
1404
1405fn default_hitl_timeout_secs() -> u32 {
1406 300
1407}
1408
1409impl Default for HitlConfig {
1410 fn default() -> Self {
1411 Self {
1412 timeout_secs: default_hitl_timeout_secs(),
1413 max_iterations: None,
1414 max_tokens: None,
1415 }
1416 }
1417}
1418
1419#[cfg(test)]
1420mod hitl_tests {
1421 use super::*;
1422
1423 #[test]
1424 fn hitl_config_default_max_iterations_is_none() {
1425 let cfg = HitlConfig::default();
1426 assert!(cfg.max_iterations.is_none());
1427 }
1428
1429 #[test]
1430 fn hitl_config_max_iterations_explicit() {
1431 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1432 assert_eq!(cfg.max_iterations, Some(5));
1433 }
1434
1435 #[test]
1436 fn hitl_config_default_max_tokens_is_none() {
1437 let cfg = HitlConfig::default();
1438 assert!(cfg.max_tokens.is_none());
1439 }
1440
1441 #[test]
1442 fn hitl_config_max_tokens_explicit() {
1443 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1444 assert_eq!(cfg.max_tokens, Some(250_000));
1445 }
1446}
1447
1448#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1454pub struct CompanionConfig {
1455 #[serde(default)]
1456 pub enabled: bool,
1457 #[serde(default = "default_locale")]
1458 pub locale: String,
1459 #[serde(default)]
1460 pub relationship: Relationship,
1461 #[serde(default)]
1462 pub voice_overrides: VoiceOverrides,
1463 #[serde(default)]
1464 pub onboarding: OnboardingState,
1465 #[serde(default)]
1466 pub rhythm: RhythmConfig,
1467 #[serde(default)]
1468 pub proactive: ProactiveConfig,
1469}
1470
1471pub fn default_locale() -> String {
1480 sys_locale::get_locale()
1481 .filter(|l| !l.is_empty())
1482 .or_else(|| std::env::var("LANG").ok().and_then(|v| normalize_lang(&v)))
1483 .unwrap_or_else(|| "en-US".into())
1484}
1485
1486fn normalize_lang(v: &str) -> Option<String> {
1488 v.split('.')
1489 .next()
1490 .map(|s| s.replace('_', "-"))
1491 .filter(|s| !s.is_empty())
1492}
1493
1494#[cfg(test)]
1495mod locale_tests {
1496 use super::normalize_lang;
1497
1498 #[test]
1499 fn lang_with_encoding_and_region_normalizes() {
1500 assert_eq!(normalize_lang("zh_TW.UTF-8").as_deref(), Some("zh-TW"));
1501 }
1502
1503 #[test]
1504 fn lang_without_encoding_normalizes() {
1505 assert_eq!(normalize_lang("en_US").as_deref(), Some("en-US"));
1506 }
1507
1508 #[test]
1509 fn lang_with_script_keeps_script() {
1510 assert_eq!(
1511 normalize_lang("zh_Hant_TW.UTF-8").as_deref(),
1512 Some("zh-Hant-TW")
1513 );
1514 }
1515
1516 #[test]
1517 fn empty_lang_yields_none() {
1518 assert_eq!(normalize_lang(""), None);
1519 }
1520}
1521
1522#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1523pub struct VoiceOverrides {
1524 #[serde(default, skip_serializing_if = "Option::is_none")]
1525 pub name_for_user: Option<String>,
1526 #[serde(default, skip_serializing_if = "Option::is_none")]
1527 pub formality: Option<Formality>,
1528 #[serde(default, skip_serializing_if = "Option::is_none")]
1529 pub extra_instructions: Option<String>,
1530}
1531
1532#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1533pub struct FirstMemory {
1534 pub text: String,
1535 pub established_at: chrono::DateTime<chrono::Utc>,
1536}
1537
1538#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1539pub struct OnboardingState {
1540 #[serde(default, skip_serializing_if = "Option::is_none")]
1541 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1542 #[serde(default)]
1543 pub version: u32,
1544 #[serde(default, skip_serializing_if = "Option::is_none")]
1545 pub agent_display_name: Option<String>,
1546 #[serde(default, skip_serializing_if = "Option::is_none")]
1547 pub first_memory: Option<FirstMemory>,
1548}
1549
1550#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1553pub struct RhythmConfig {
1554 #[serde(default)]
1555 pub enabled: bool,
1556}
1557
1558#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1559pub struct ProactiveConfig {
1560 #[serde(default)]
1561 pub enabled: bool,
1562 #[serde(default, skip_serializing_if = "Option::is_none")]
1564 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1565 #[serde(default, skip_serializing_if = "Option::is_none")]
1566 pub quiet_hours: Option<QuietHours>,
1567 #[serde(default, skip_serializing_if = "Option::is_none")]
1568 pub active_hours: Option<ActiveHours>,
1569 #[serde(default = "default_daily_cap")]
1570 pub daily_cap: u8,
1571 #[serde(default = "default_channels")]
1572 pub channels: Vec<String>,
1573 #[serde(default, skip_serializing_if = "Option::is_none")]
1574 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1575}
1576
1577impl Default for ProactiveConfig {
1578 fn default() -> Self {
1579 Self {
1580 enabled: false,
1581 learning_until: None,
1582 quiet_hours: None,
1583 active_hours: None,
1584 daily_cap: default_daily_cap(),
1585 channels: default_channels(),
1586 paused_until: None,
1587 }
1588 }
1589}
1590
1591fn default_daily_cap() -> u8 {
1592 3
1593}
1594fn default_channels() -> Vec<String> {
1595 vec!["stdout".into()]
1596}
1597
1598#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1599pub struct QuietHours {
1600 pub start: String,
1601 pub end: String,
1602}
1603
1604#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1605pub struct ActiveHours {
1606 pub start: String,
1607 pub end: String,
1608}
1609
1610#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1615pub struct AgentAppearance {
1616 #[serde(default = "default_style_preset")]
1618 pub style_preset: String,
1619 #[serde(default)]
1620 pub behavior_preset: BehaviorPreset,
1621 #[serde(default, skip_serializing_if = "Option::is_none")]
1623 pub source_image_path: Option<std::path::PathBuf>,
1624 #[serde(default = "default_expressions_dir")]
1626 pub expressions_dir: std::path::PathBuf,
1627 #[serde(default, skip_serializing_if = "Option::is_none")]
1628 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1629 #[serde(default)]
1630 pub render_status: RenderStatus,
1631}
1632
1633fn default_style_preset() -> String {
1634 "default-blob".into()
1635}
1636
1637fn default_expressions_dir() -> std::path::PathBuf {
1638 std::path::PathBuf::from("expressions")
1639}
1640
1641impl Default for AgentAppearance {
1642 fn default() -> Self {
1643 Self {
1644 style_preset: default_style_preset(),
1645 behavior_preset: BehaviorPreset::Normal,
1646 source_image_path: None,
1647 expressions_dir: default_expressions_dir(),
1648 last_rendered_at: None,
1649 render_status: RenderStatus::Pending,
1650 }
1651 }
1652}
1653
1654#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1655#[serde(rename_all = "snake_case")]
1656pub enum BehaviorPreset {
1657 Quiet,
1658 #[default]
1659 Normal,
1660 Lively,
1661}
1662
1663#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1664#[serde(tag = "status", rename_all = "snake_case")]
1665pub enum RenderStatus {
1666 #[default]
1667 Pending,
1668 Rendering {
1669 done: u8,
1670 total: u8,
1671 },
1672 Ready,
1673 Failed {
1674 reason: String,
1675 },
1676}
1677
1678#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1684#[serde(rename_all = "kebab-case")]
1685pub enum SnapshotPolicy {
1686 #[default]
1687 PullOnStart,
1688 PullPeriodic,
1689 Manual,
1690}
1691
1692#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1694pub struct PatternFilter {
1695 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1696 pub applies_in: Vec<String>,
1697 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1698 pub tier: Vec<String>,
1699 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1700 pub maturity: Vec<String>,
1701 #[serde(default)]
1702 pub importance_min: f64,
1703 #[serde(default = "default_max_snapshot_count")]
1704 pub max_count: usize,
1705 #[serde(default)]
1706 pub snapshot_policy: SnapshotPolicy,
1707}
1708
1709fn default_max_snapshot_count() -> usize {
1710 200
1711}
1712
1713impl Default for PatternFilter {
1714 fn default() -> Self {
1715 Self {
1716 applies_in: vec![],
1717 tier: vec![],
1718 maturity: vec![],
1719 importance_min: 0.0,
1720 max_count: 200,
1721 snapshot_policy: SnapshotPolicy::default(),
1722 }
1723 }
1724}
1725
1726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1728pub struct SnapshotRef {
1729 pub knowledge_commit: String,
1730 pub taken_at: String,
1731 pub filter: PatternFilter,
1732}
1733
1734#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1736pub struct FederationConfig {
1737 #[serde(default)]
1738 pub filter: PatternFilter,
1739 #[serde(default, skip_serializing_if = "Option::is_none")]
1740 pub snapshot_ref: Option<SnapshotRef>,
1741 #[serde(default)]
1742 pub evidence_flush_interval_minutes: u32,
1743}
1744
1745impl AgentProfile {
1746 #[doc(hidden)]
1752 pub fn default_for_tests() -> Self {
1753 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1754 .expect("minimal profile fixture")
1755 }
1756
1757 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1765 let path = mur_home.join("agents").join(name).join("profile.yaml");
1766 let yaml = std::fs::read_to_string(&path)
1767 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1768 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1769 }
1770
1771 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1773 self.addons.iter().find(|g| {
1774 g.skills.iter().any(|n| n == name)
1775 || g.mcp.iter().any(|n| n == name)
1776 || g.commands.iter().any(|n| n == name)
1777 })
1778 }
1779
1780 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1783 name_enabled(&self.disabled_skills, skill_name)
1784 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1785 }
1786
1787 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1789 name_enabled(&self.disabled_mcp, server_id)
1790 && self.group_of(server_id).is_none_or(|g| g.enabled)
1791 }
1792
1793 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1795 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1796 }
1797
1798 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1800 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1801 }
1802
1803 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1806 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1807 Some(g) => {
1808 g.enabled = enabled;
1809 true
1810 }
1811 None => false,
1812 }
1813 }
1814
1815 pub fn disable_all_addons(&mut self) {
1821 for g in &mut self.addons {
1822 g.enabled = false;
1823 }
1824 }
1825
1826 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1828 self.mcp_servers
1829 .iter()
1830 .filter(|m| self.mcp_enabled(&m.name))
1831 .cloned()
1832 .collect()
1833 }
1834}
1835
1836#[cfg(test)]
1837mod tests {
1838 #[test]
1842 fn grants_digest_ignores_order_but_not_content() {
1843 let a = FilesystemEntitlement {
1844 read: vec!["/a".into(), "/b".into()],
1845 write: vec!["/w".into()],
1846 deny: vec![],
1847 };
1848 let reordered = FilesystemEntitlement {
1849 read: vec!["/b".into(), "/a".into()],
1850 ..a.clone()
1851 };
1852 let changed = FilesystemEntitlement {
1853 write: vec!["/w".into(), "/x".into()],
1854 ..a.clone()
1855 };
1856 assert_eq!(
1857 filesystem_grants_digest(&a),
1858 filesystem_grants_digest(&reordered)
1859 );
1860 assert_ne!(
1861 filesystem_grants_digest(&a),
1862 filesystem_grants_digest(&changed)
1863 );
1864 }
1865
1866 #[test]
1868 fn grants_digest_separates_the_verbs() {
1869 let r = FilesystemEntitlement {
1870 read: vec!["/p".into()],
1871 write: vec![],
1872 deny: vec![],
1873 };
1874 let w = FilesystemEntitlement {
1875 read: vec![],
1876 write: vec!["/p".into()],
1877 deny: vec![],
1878 };
1879 assert_ne!(filesystem_grants_digest(&r), filesystem_grants_digest(&w));
1880 }
1881
1882 #[test]
1885 fn a_lock_without_the_sandbox_block_still_deserialises() {
1886 let old = r#"{"schema":1,"uuid":"u","name":"n","pid":1,"ppid":0,
1887 "started_at":"t","binary_version":"v",
1888 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
1889 let lf: LockFile = serde_json::from_str(old).expect("old lock must load");
1890 assert!(lf.sandbox.is_none());
1891 }
1892
1893 #[test]
1894 fn the_sandbox_block_round_trips() {
1895 let rec = SandboxRecord {
1896 enforcing: false,
1897 mode: "advisory-only".into(),
1898 granted_digest: "sha256:x".into(),
1899 dropped: vec![DroppedGrant {
1900 path: "/gone".into(),
1901 verb: "write".into(),
1902 reason: "path does not exist on disk".into(),
1903 }],
1904 };
1905 let back: SandboxRecord =
1906 serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
1907 assert_eq!(back, rec);
1908 }
1909
1910 use super::*;
1911
1912 #[test]
1913 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1914 let net = McpServerNetwork {
1915 mode: McpNetMode::BroadAudited,
1916 allow_hosts: vec![],
1917 deny_hosts: vec!["evil.example".into()],
1918 authorization: Some(EgressAuthorization {
1919 authorized_by: "david".into(),
1920 authorized_at_ms: 1_750_000_000_000,
1921 }),
1922 };
1923 let y = serde_yaml::to_string(&net).unwrap();
1924 assert!(y.contains("broad_audited"));
1925 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1926 assert_eq!(back, net);
1927 let legacy: McpServerNetwork =
1929 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1930 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1931 assert!(legacy.authorization.is_none());
1932 }
1933
1934 #[test]
1935 fn mcp_entry_network_is_optional_and_round_trips() {
1936 let bare = "name: x\ncommand: npx\n";
1938 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1939 assert!(e.network.is_none());
1940
1941 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1943 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1944 let net = e2.network.expect("network present");
1945 assert_eq!(net.mode, McpNetMode::Restricted);
1946 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1947
1948 let out = serde_yaml_ng::to_string(&e).unwrap();
1950 assert!(!out.contains("network"));
1951 }
1952
1953 #[test]
1954 fn profile_round_trip_yaml() {
1955 let yaml = r#"
1956schema: 1
1957id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1958name: agent_a
1959display_name: "Price Hunter"
1960version: "0.1.0"
1961persona:
1962 category: research
1963 description: "Finds prices"
1964 traits: { tone: concise, risk: cautious, verbosity: low }
1965sys_prompt_file: "sys_prompt.md"
1966model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1967mcp_servers: []
1968skills: []
1969transport:
1970 stdio: true
1971 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1972communication: { accepts_from: ["*"], sends_to: [] }
1973capabilities: ["a2a.message.send", "a2a.tasks"]
1974entitlements:
1975 network:
1976 inbound: { ports: [] }
1977 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1978 filesystem: { read: [], write: [], deny: [] }
1979 processes: { spawn: { mode: allowlist, allowed: [] } }
1980 syscalls: { mode: default }
1981 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1982notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1983retry:
1984 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1985 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1986lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1987created_at: "2026-04-22T10:00:00+08:00"
1988updated_at: "2026-04-22T10:00:00+08:00"
1989"#;
1990 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1991 assert_eq!(profile.name, "agent_a");
1992 assert_eq!(profile.persona.category, PersonaCategory::Research);
1993 assert_eq!(
1994 profile.entitlements.network.outbound.mode,
1995 NetworkOutboundMode::Restricted
1996 );
1997 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1998 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1999 assert_eq!(profile.id, round_tripped.id);
2000 }
2001
2002 #[test]
2003 fn requires_capabilities_defaults_empty_and_round_trips() {
2004 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2005 let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
2006 assert!(p.requires_capabilities.is_empty());
2007 let with = format!("{base}\nrequires_capabilities:\n - media\n");
2008 let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
2009 assert_eq!(p2.requires_capabilities, vec!["media"]);
2010 }
2011}
2012
2013#[cfg(test)]
2014mod model_ref_tests {
2015 use super::*;
2016
2017 #[test]
2018 fn legacy_profile_without_model_ref_still_parses() {
2019 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2020 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2021 assert!(
2022 p.model_ref.is_none(),
2023 "legacy profile must not have model_ref"
2024 );
2025 }
2026
2027 #[test]
2028 fn round_trip_with_model_ref_preserves_field() {
2029 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2030 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2031 p.model_ref = Some("anthropic_opus_4_7".into());
2032 let s = serde_yaml_ng::to_string(&p).unwrap();
2033 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
2034 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2035 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
2036 }
2037
2038 #[test]
2039 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
2040 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2042 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2043 assert!(
2044 p.fallback_chain.is_empty(),
2045 "legacy profile must have empty fallback_chain"
2046 );
2047 assert!(
2048 p.routing.is_none(),
2049 "legacy profile must have no routing override"
2050 );
2051
2052 let mut p = p.clone();
2054 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
2055 p.routing = Some(crate::config::RoutingConfig {
2056 enabled: true,
2057 ..Default::default()
2058 });
2059 let s = serde_yaml_ng::to_string(&p).unwrap();
2060 assert!(
2061 s.contains("fallback_chain:"),
2062 "yaml must contain fallback_chain"
2063 );
2064 assert!(s.contains("routing:"), "yaml must contain routing");
2065 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2066 assert_eq!(
2067 p2.fallback_chain,
2068 vec!["claude_opus", "claude_sonnet"],
2069 "fallback_chain must round-trip"
2070 );
2071 assert!(
2072 p2.routing.as_ref().unwrap().enabled,
2073 "routing.enabled must round-trip"
2074 );
2075 }
2076}
2077
2078#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2086#[serde(rename_all = "snake_case")]
2087pub enum ProactiveTier {
2088 Off,
2089 WarmOnly,
2090 WarmAndBehavior,
2091 All,
2092}
2093
2094impl ProactiveTier {
2095 pub fn from_config(c: &CompanionConfig) -> Self {
2096 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
2097 (false, _, _) => Self::Off,
2098 (true, false, false) => Self::WarmOnly,
2099 (true, true, false) => Self::WarmAndBehavior,
2100 (true, _, true) => Self::All,
2101 }
2102 }
2103
2104 pub fn apply(&self, c: &mut CompanionConfig) {
2105 match self {
2106 Self::Off => {
2107 c.enabled = false;
2108 c.rhythm.enabled = false;
2109 c.proactive.enabled = false;
2110 }
2111 Self::WarmOnly => {
2112 c.enabled = true;
2113 c.rhythm.enabled = false;
2114 c.proactive.enabled = false;
2115 }
2116 Self::WarmAndBehavior => {
2117 c.enabled = true;
2118 c.rhythm.enabled = true;
2119 c.proactive.enabled = false;
2120 }
2121 Self::All => {
2122 c.enabled = true;
2123 c.rhythm.enabled = true;
2124 c.proactive.enabled = true;
2125 }
2126 }
2127 }
2128}
2129
2130#[cfg(test)]
2131mod mcp_pin_tests {
2132 use super::*;
2133
2134 #[test]
2138 fn pre_m9_entry_roundtrips_without_pin_fields() {
2139 let yaml = r#"
2140name: weather
2141command: /opt/mcp/weather
2142args: ["--port", "0"]
2143"#;
2144 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2145 assert_eq!(entry.name, "weather");
2146 assert_eq!(entry.binary_sha256, None);
2147 assert_eq!(entry.description_hash, None);
2148 assert_eq!(entry.publisher, None);
2149 assert_eq!(entry.installed_at, None);
2150
2151 let out = serde_yaml_ng::to_string(&entry).unwrap();
2154 assert!(!out.contains("binary_sha256"), "got {out}");
2155 assert!(!out.contains("description_hash"), "got {out}");
2156 assert!(!out.contains("publisher"), "got {out}");
2157 assert!(!out.contains("installed_at"), "got {out}");
2158 }
2159
2160 #[test]
2162 fn full_m9_entry_roundtrips_all_fields() {
2163 let yaml = r#"
2164name: weather
2165command: /opt/mcp/weather
2166args: []
2167binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
2168description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
2169publisher:
2170 name: "@anthropic-mcp/weather"
2171 homepage: "https://github.com/anthropic-mcp/weather"
2172 registry_id: "@anthropic-mcp/weather@1.2.3"
2173installed_at: "2026-05-06T08:00:00Z"
2174"#;
2175 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2176 assert!(
2177 entry
2178 .binary_sha256
2179 .as_deref()
2180 .unwrap()
2181 .starts_with("3f4abca8")
2182 );
2183 assert!(
2184 entry
2185 .description_hash
2186 .as_deref()
2187 .unwrap()
2188 .starts_with("9a01b2c3")
2189 );
2190 let pub_info = entry.publisher.clone().unwrap();
2191 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
2192 assert_eq!(
2193 pub_info.homepage.as_deref(),
2194 Some("https://github.com/anthropic-mcp/weather"),
2195 );
2196 assert_eq!(
2197 pub_info.registry_id.as_deref(),
2198 Some("@anthropic-mcp/weather@1.2.3"),
2199 );
2200 let installed = entry.installed_at.unwrap();
2201 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
2202 }
2203
2204 #[test]
2208 fn partial_pin_only_binary_sha_roundtrips() {
2209 let yaml = r#"
2210name: weather
2211command: /opt/mcp/weather
2212args: []
2213binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
2214"#;
2215 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2216 assert_eq!(
2217 entry.binary_sha256.as_deref(),
2218 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
2219 );
2220 assert_eq!(entry.description_hash, None);
2221 assert_eq!(entry.publisher, None);
2222 }
2223
2224 #[test]
2227 fn publisher_minimal_just_name() {
2228 let yaml = r#"
2229name: weather
2230command: /opt/mcp/weather
2231args: []
2232publisher:
2233 name: "alice"
2234"#;
2235 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2236 let p = entry.publisher.as_ref().unwrap();
2237 assert_eq!(p.name, "alice");
2238 assert_eq!(p.homepage, None);
2239 assert_eq!(p.registry_id, None);
2240
2241 let out = serde_yaml_ng::to_string(&entry).unwrap();
2243 assert!(!out.contains("homepage:"), "got {out}");
2244 assert!(!out.contains("registry_id:"), "got {out}");
2245 }
2246}
2247
2248#[cfg(test)]
2249mod voice_tests {
2250 use super::*;
2251 use std::str::FromStr;
2252
2253 #[test]
2254 fn voice_config_round_trips() {
2255 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2257 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
2258
2259 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
2260 assert!(profile.voice.enabled);
2261 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
2262
2263 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
2265 assert!(!legacy.voice.enabled);
2266 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
2267 }
2268
2269 #[test]
2270 fn voice_id_from_str_roundtrips() {
2271 let cases = [
2272 ("af_heart", VoiceId::AfHeart),
2273 ("af_bella", VoiceId::AfBella),
2274 ("af_nicole", VoiceId::AfNicole),
2275 ("am_adam", VoiceId::AmAdam),
2276 ("am_michael", VoiceId::AmMichael),
2277 ];
2278 for (s, expected) in cases {
2279 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
2280 assert_eq!(expected.as_str(), s);
2281 }
2282 }
2283
2284 #[test]
2285 fn voice_id_from_str_rejects_unknown() {
2286 assert!(VoiceId::from_str("bogus").is_err());
2287 }
2288}
2289
2290#[cfg(test)]
2291mod idle_trigger_tests {
2292 use super::*;
2293
2294 #[test]
2295 fn idle_trigger_yaml_round_trip() {
2296 let yaml = r#"
2297restart: on_failure
2298idle_triggers:
2299 - after_secs: 3600
2300 message: "still there?"
2301 sends_to: other_agent
2302 cooldown_secs: 1800
2303 respect_quiet_hours: true
2304"#;
2305 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2306 assert_eq!(cfg.idle_triggers.len(), 1);
2307 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
2308 assert_eq!(cfg.idle_triggers[0].message, "still there?");
2309 assert_eq!(
2310 cfg.idle_triggers[0].sends_to.as_deref(),
2311 Some("other_agent")
2312 );
2313 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
2314 assert!(cfg.idle_triggers[0].respect_quiet_hours);
2315 }
2316
2317 #[test]
2318 fn idle_trigger_defaults_when_omitted() {
2319 let yaml = "restart: on_failure\n";
2320 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2321 assert!(cfg.idle_triggers.is_empty());
2322 }
2323}
2324
2325#[cfg(test)]
2326mod appearance_tests {
2327 use super::*;
2328
2329 #[test]
2330 fn appearance_default_style_preset_is_default_blob() {
2331 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
2332 }
2333
2334 #[test]
2335 fn appearance_default_behavior_is_normal() {
2336 assert_eq!(
2337 AgentAppearance::default().behavior_preset,
2338 BehaviorPreset::Normal
2339 );
2340 }
2341
2342 #[test]
2343 fn appearance_default_render_status_is_pending() {
2344 assert_eq!(
2345 AgentAppearance::default().render_status,
2346 RenderStatus::Pending
2347 );
2348 }
2349
2350 #[test]
2351 fn render_status_serde_round_trip() {
2352 let cases = [
2353 RenderStatus::Pending,
2354 RenderStatus::Rendering { done: 3, total: 12 },
2355 RenderStatus::Ready,
2356 RenderStatus::Failed {
2357 reason: "out of quota".into(),
2358 },
2359 ];
2360 for status in cases {
2361 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
2362 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
2363 assert_eq!(status, back);
2364 }
2365 }
2366
2367 #[test]
2368 fn agent_profile_with_appearance_round_trips() {
2369 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2370 let yaml = format!(
2371 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
2372 );
2373 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2374 assert_eq!(profile.appearance.style_preset, "chiikawa");
2375 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2376
2377 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2378 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2379 assert_eq!(profile.appearance, back.appearance);
2380 }
2381
2382 #[test]
2383 fn legacy_profile_without_appearance_uses_default() {
2384 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2385 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2386 assert_eq!(profile.appearance.style_preset, "default-blob");
2387 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2388 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2389 }
2390
2391 #[test]
2392 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2393 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2394 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2395 assert!(p.file_actions.is_empty());
2396 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2397 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2398 }
2399}
2400
2401#[cfg(test)]
2402mod federation_tests {
2403 use super::*;
2404
2405 #[test]
2406 fn test_pattern_filter_default() {
2407 let f = PatternFilter::default();
2408 assert_eq!(f.max_count, 200);
2409 assert_eq!(f.importance_min, 0.0);
2410 assert!(f.tier.is_empty());
2411 }
2412
2413 #[test]
2414 fn test_federation_config_roundtrip() {
2415 let cfg = FederationConfig {
2416 filter: PatternFilter {
2417 tier: vec!["core".into()],
2418 max_count: 50,
2419 ..Default::default()
2420 },
2421 snapshot_ref: Some(SnapshotRef {
2422 knowledge_commit: "abc123def456".into(),
2423 taken_at: "2026-05-19T00:00:00Z".into(),
2424 filter: PatternFilter::default(),
2425 }),
2426 evidence_flush_interval_minutes: 15,
2427 };
2428 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2429 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2430 assert_eq!(cfg, back);
2431 }
2432
2433 #[test]
2434 fn test_agent_profile_federation_defaults() {
2435 let cfg = FederationConfig::default();
2439 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2440 assert!(cfg.snapshot_ref.is_none());
2441 }
2442}
2443
2444#[cfg(test)]
2445mod skill_card_tests {
2446 use super::*;
2447
2448 #[test]
2449 fn installed_skills_default_to_empty_when_absent() {
2450 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2451 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2452 assert!(p.installed_skills.is_empty());
2453 }
2454
2455 #[test]
2456 fn installed_skills_roundtrip_preserves_entries() {
2457 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2458 let yaml = format!(
2459 "{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"
2460 );
2461 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2462 assert_eq!(p.installed_skills.len(), 1);
2463 assert_eq!(p.installed_skills[0].name, "s1");
2464 assert_eq!(p.installed_skills[0].abstract_text, "does things");
2465 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2466
2467 let out = serde_yaml_ng::to_string(&p).unwrap();
2468 assert!(out.contains("abstract: does things"));
2469 assert!(out.contains("pattern: /find"));
2470
2471 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2472 assert_eq!(p.installed_skills, back.installed_skills);
2473 }
2474
2475 #[test]
2476 fn installed_skills_minimal_entry_serializes_compactly() {
2477 let entry = SkillCardEntry {
2479 name: "minimal".into(),
2480 ..Default::default()
2481 };
2482 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2483 assert!(yaml.contains("name: minimal"));
2484 assert!(
2485 !yaml.contains("version:"),
2486 "empty version must be skipped: {yaml}"
2487 );
2488 assert!(
2489 !yaml.contains("publisher:"),
2490 "empty publisher must be skipped: {yaml}"
2491 );
2492 assert!(
2493 !yaml.contains("abstract:"),
2494 "empty abstract must be skipped: {yaml}"
2495 );
2496 }
2497}
2498
2499#[cfg(test)]
2500mod tool_policy_tests {
2501 use super::*;
2502
2503 fn rules() -> Vec<ToolRule> {
2504 vec![
2505 ToolRule {
2506 pattern: "mcp__github__merge_pr".into(),
2507 policy: ToolPolicy::Ask,
2508 risk: None,
2509 },
2510 ToolRule {
2511 pattern: "mcp__github__*".into(),
2512 policy: ToolPolicy::Allow,
2513 risk: None,
2514 },
2515 ToolRule {
2516 pattern: "mcp__*".into(),
2517 policy: ToolPolicy::Deny,
2518 risk: None,
2519 },
2520 ToolRule {
2521 pattern: "bash".into(),
2522 policy: ToolPolicy::Allow,
2523 risk: None,
2524 },
2525 ]
2526 }
2527
2528 #[test]
2529 fn exact_beats_glob() {
2530 assert_eq!(
2531 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2532 ToolPolicy::Ask
2533 );
2534 }
2535
2536 #[test]
2537 fn longer_glob_wins() {
2538 assert_eq!(
2539 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2540 ToolPolicy::Allow
2541 );
2542 }
2543
2544 #[test]
2545 fn shorter_glob_fallback() {
2546 assert_eq!(
2547 resolve_tool_policy(&rules(), "mcp__slack__send"),
2548 ToolPolicy::Deny
2549 );
2550 }
2551
2552 #[test]
2553 fn exact_bash() {
2554 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2555 }
2556
2557 #[test]
2558 fn unknown_tool_defaults_ask() {
2559 assert_eq!(
2560 resolve_tool_policy(&rules(), "unknown_tool"),
2561 ToolPolicy::Ask
2562 );
2563 }
2564
2565 #[test]
2566 fn empty_rules_defaults_ask() {
2567 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2568 }
2569
2570 fn minimal_entitlements_yaml() -> &'static str {
2571 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2572 }
2573
2574 #[test]
2575 fn entitlements_tools_defaults_empty() {
2576 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2577 assert!(e.tools.is_empty());
2578 }
2579
2580 #[test]
2581 fn entitlements_tools_roundtrip() {
2582 let base = minimal_entitlements_yaml();
2583 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2584 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2585 assert_eq!(e.tools.len(), 1);
2586 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2587 let y = serde_yaml_ng::to_string(&e).unwrap();
2588 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2589 assert_eq!(back.tools.len(), 1);
2590 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2591 }
2592 #[test]
2593 fn denylist_membership_and_mutation() {
2594 let mut list: Vec<String> = vec![];
2595 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2596
2597 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2599 assert_eq!(list, ["a"]);
2600
2601 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2603
2604 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2606 assert!(list.is_empty());
2607
2608 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2610 }
2611
2612 #[test]
2613 fn addon_group_rule_truth_table() {
2614 let mut p = AgentProfile::default_for_tests();
2615 p.addons.push(AddonRef {
2616 id: "grp".into(),
2617 source: "claude-local:grp@1.0.0".into(),
2618 enabled: false,
2619 skills: vec!["g_skill".into()],
2620 mcp: vec!["g_mcp".into()],
2621 commands: vec!["g_cmd".into()],
2622 content_hash: None,
2623 fetch_ref: None,
2624 fetch_plugin: None,
2625 });
2626
2627 assert!(p.skill_enabled("standalone"));
2629 assert!(p.mcp_enabled("standalone_mcp"));
2630
2631 assert!(!p.skill_enabled("g_skill"));
2633 assert!(!p.mcp_enabled("g_mcp"));
2634
2635 assert!(p.set_addon_enabled("grp", true));
2637 assert!(p.skill_enabled("g_skill"));
2638 assert!(p.mcp_enabled("g_mcp"));
2639
2640 p.set_skill_enabled("g_skill", false);
2642 assert!(!p.skill_enabled("g_skill"));
2643
2644 assert!(!p.set_addon_enabled("nope", true));
2646
2647 p.disable_all_addons();
2649 assert!(p.addons.iter().all(|g| !g.enabled));
2650 assert!(!p.skill_enabled("g_skill"));
2651 assert!(!p.skill_enabled("g_cmd"));
2652 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2658 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);
2664 assert!(p.skill_enabled("g_skill"));
2665 }
2666
2667 #[test]
2668 fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2669 let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2671 let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2672 assert_eq!(r.content_hash, None);
2673 assert_eq!(r.fetch_ref, None);
2674
2675 let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2677 let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2678 assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2679 assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2680 let back = serde_yaml_ng::to_string(&r2).unwrap();
2681 let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2682 assert_eq!(r2, r3);
2683 }
2684}
2685
2686#[cfg(test)]
2687mod lockfile_compat_tests {
2688 use super::*;
2689
2690 #[test]
2691 fn lockfile_new_fields_default_for_old_locks() {
2692 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2695 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2696 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2697 let lock: LockFile = serde_json::from_str(old).unwrap();
2698 assert_eq!(lock.build_sha, "");
2699 assert_eq!(lock.proto_version, 0);
2700 }
2701}
2702
2703#[cfg(test)]
2704mod remote_mcp_tests {
2705 use super::*;
2706
2707 #[test]
2708 fn mcp_entry_roundtrips_remote_bearer() {
2709 let e = McpServerEntry {
2710 name: "gh".into(),
2711 command: String::new(),
2712 url: Some("https://api.example.com/mcp".into()),
2713 auth: Some(McpAuth::Bearer {
2714 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2715 }),
2716 ..Default::default()
2717 };
2718 let y = serde_yaml_ng::to_string(&e).unwrap();
2719 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2720 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2721 assert!(matches!(
2722 back.auth,
2723 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2724 ));
2725 let legacy: McpServerEntry =
2727 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2728 assert!(legacy.url.is_none());
2729 assert!(legacy.auth.is_none());
2730 }
2731}
2732
2733#[cfg(test)]
2734mod requires_programs_tests {
2735 #[test]
2736 fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2737 let with = r#"
2738name: research-gateway
2739command: mur-research-gateway
2740requires_programs:
2741 - name: lightpanda
2742 detect: { file: "~/.mur/aura/lightpanda" }
2743 reason: "render tier"
2744 registry: lightpanda
2745"#;
2746 let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2747 assert_eq!(e.requires_programs.len(), 1);
2748 assert_eq!(e.requires_programs[0].name, "lightpanda");
2749
2750 let without = "name: x\ncommand: y\n";
2752 let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2753 assert!(e2.requires_programs.is_empty());
2754 }
2755}