1use crate::companion::{Formality, Relationship};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
14pub struct SkillCardEntry {
15 pub name: String,
16 #[serde(default, skip_serializing_if = "String::is_empty")]
17 pub version: String,
18 #[serde(default, skip_serializing_if = "String::is_empty")]
19 pub publisher: String,
20 #[serde(default, skip_serializing_if = "String::is_empty")]
21 pub description: String,
22 #[serde(default, skip_serializing_if = "String::is_empty")]
23 pub category: String,
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
25 pub tags: Vec<String>,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub triggers: Vec<SkillCardTrigger>,
28 #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
31 pub abstract_text: String,
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub transfer_chain: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
39pub struct SkillCardTrigger {
40 #[serde(rename = "type")]
41 pub kind: String,
42 #[serde(default, skip_serializing_if = "String::is_empty")]
43 pub pattern: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct AgentProfile {
48 pub schema: u32,
49 pub id: String, pub name: String,
51 pub display_name: String,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub role: Option<String>,
58 pub version: String,
59 pub persona: Persona,
60 pub sys_prompt_file: String,
61 pub model: ModelConfig,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub model_ref: Option<String>,
66 #[serde(default)]
67 pub mcp_servers: Vec<McpServerEntry>,
68 #[serde(default)]
69 pub skills: Vec<String>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub installed_skills: Vec<SkillCardEntry>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub disabled_skills: Vec<String>,
81
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub disabled_mcp: Vec<String>,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub addons: Vec<AddonRef>,
92 pub transport: TransportConfig,
93 pub communication: CommunicationConfig,
94 #[serde(default)]
95 pub capabilities: Vec<String>,
96 pub entitlements: Entitlements,
97 #[serde(default)]
98 pub notifications: NotificationsConfig,
99 pub retry: RetryConfig,
100 pub lifecycle: LifecycleConfig,
101 #[serde(default)]
104 pub identity: IdentityConfig,
105 #[serde(default)]
106 pub file_transfer: FileTransferConfig,
107 #[serde(default)]
108 pub deployment: DeploymentConfig,
109 #[serde(default)]
112 pub companion: CompanionConfig,
113 #[serde(default)]
115 pub hitl: HitlConfig,
116 #[serde(default)]
118 pub voice: VoiceConfig,
119 #[serde(default)]
121 pub hooks: crate::HooksConfig,
122 #[serde(default)]
125 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
126 pub created_at: String,
127 pub updated_at: String,
128 #[serde(default)]
130 pub appearance: AgentAppearance,
131 #[serde(default)]
133 pub federation: FederationConfig,
134
135 #[serde(default)]
139 pub file_actions: Vec<crate::action::FileAction>,
140
141 #[serde(default)]
143 pub action_pipeline: crate::action::ActionPipelineConfig,
144}
145
146fn default_algorithm() -> String {
147 "ed25519".into()
148}
149
150pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct IdentityConfig {
155 #[serde(default)]
158 pub pubkey: String,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub owner: Option<String>,
162
163 #[serde(default = "default_algorithm")]
166 pub algorithm: String,
167 #[serde(default)]
169 pub key_version: u32,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub created_at_key: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub previous_pubkey: Option<String>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub previous_key_version: Option<u32>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub grace_expires_at: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub rotated_at: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub emergency_rekey_at: Option<String>,
189}
190
191impl Default for IdentityConfig {
192 fn default() -> Self {
193 Self {
194 pubkey: String::new(),
195 owner: None,
196 algorithm: default_algorithm(),
197 key_version: 0,
198 created_at_key: None,
199 previous_pubkey: None,
200 previous_key_version: None,
201 grace_expires_at: None,
202 rotated_at: None,
203 emergency_rekey_at: None,
204 }
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
209pub struct Persona {
210 pub category: PersonaCategory,
211 pub description: String,
212 pub traits: PersonaTraits,
213}
214
215#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
216#[serde(rename_all = "lowercase")]
217pub enum PersonaCategory {
218 Research,
219 Automation,
220 Monitor,
221 Notify,
222 Commerce,
223 Custom,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
227pub struct PersonaTraits {
228 pub tone: String,
229 pub risk: String,
230 pub verbosity: String,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
234pub struct ModelConfig {
235 pub provider: String,
236 pub name: String,
237 #[serde(default)]
238 pub params: BTreeMap<String, serde_yaml_ng::Value>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
242pub struct McpServerEntry {
243 pub name: String,
244 pub command: String,
245 #[serde(default)]
246 pub args: Vec<String>,
247
248 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub binary_sha256: Option<String>,
255
256 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub description_hash: Option<String>,
263
264 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub publisher: Option<McpPublisherInfo>,
269
270 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub timeout_secs: Option<u32>,
281
282 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub network: Option<McpServerNetwork>,
288
289 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub url: Option<String>,
293
294 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub auth: Option<McpAuth>,
298}
299
300#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
302#[serde(rename_all = "snake_case", tag = "kind")]
303pub enum McpAuth {
304 Bearer { token: crate::secret::SecretRef },
306 Oauth(OauthAuth),
308}
309
310#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
312pub struct OauthAuth {
313 pub token_endpoint: String,
315 pub client_id: String,
317 pub access_token: crate::secret::SecretRef,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub refresh_token: Option<crate::secret::SecretRef>,
322 #[serde(default)]
324 pub expires_at: u64,
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
329#[serde(rename_all = "snake_case")]
330pub enum McpNetMode {
331 #[default]
333 Inherit,
334 Restricted,
336 BroadAudited,
342 Off,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
348pub struct McpServerNetwork {
349 #[serde(default)]
350 pub mode: McpNetMode,
351 #[serde(default)]
352 pub allow_hosts: Vec<String>,
353 #[serde(default)]
356 pub deny_hosts: Vec<String>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub authorization: Option<EgressAuthorization>,
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
371pub struct AddonRef {
372 pub id: String,
374 pub source: String,
376 #[serde(default)]
377 pub enabled: bool,
378 #[serde(default, skip_serializing_if = "Vec::is_empty")]
379 pub skills: Vec<String>,
380 #[serde(default, skip_serializing_if = "Vec::is_empty")]
381 pub mcp: Vec<String>,
382 #[serde(default, skip_serializing_if = "Vec::is_empty")]
383 pub commands: Vec<String>,
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
392pub struct McpPublisherInfo {
393 pub name: String,
396
397 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub homepage: Option<String>,
402
403 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub registry_id: Option<String>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
410pub struct TransportConfig {
411 pub stdio: bool,
412 pub socket: SocketTransportConfig,
413 #[serde(default)]
414 pub tcp: TcpTransportConfig,
415 #[serde(default)]
419 pub webhook: WebhookTransportConfig,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
423pub struct TcpTransportConfig {
424 #[serde(default)]
425 pub enabled: bool,
426 #[serde(default)]
427 pub bind: String,
428 #[serde(default)]
429 pub noise: NoiseConfig,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
446pub struct WebhookTransportConfig {
447 #[serde(default)]
448 pub enabled: bool,
449 #[serde(default = "default_webhook_bind")]
450 pub bind: String,
451 #[serde(default = "default_webhook_port")]
452 pub port: u16,
453 #[serde(default)]
457 pub hmac_secret_ref: String,
458}
459
460fn default_webhook_bind() -> String {
461 "127.0.0.1".to_string()
462}
463
464fn default_webhook_port() -> u16 {
465 6789
466}
467
468impl Default for WebhookTransportConfig {
469 fn default() -> Self {
470 Self {
471 enabled: false,
472 bind: default_webhook_bind(),
473 port: default_webhook_port(),
474 hmac_secret_ref: String::new(),
475 }
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
480pub struct NoiseConfig {
481 pub pattern: String,
482}
483
484impl Default for NoiseConfig {
485 fn default() -> Self {
486 Self {
487 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
488 }
489 }
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
493pub struct SocketTransportConfig {
494 pub enabled: bool,
495 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub auth: Option<AuthConfig>,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
501pub struct AuthConfig {
502 pub scheme: String,
503 pub token_file: String,
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
507pub struct CommunicationConfig {
508 #[serde(default = "default_accepts_all")]
509 pub accepts_from: Vec<String>,
510 #[serde(default)]
511 pub sends_to: Vec<String>,
512}
513fn default_accepts_all() -> Vec<String> {
514 vec!["*".to_string()]
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
518pub struct Entitlements {
519 pub network: NetworkEntitlement,
520 pub filesystem: FilesystemEntitlement,
521 pub processes: ProcessesEntitlement,
522 #[serde(default)]
523 pub syscalls: SyscallsEntitlement,
524 #[serde(default)]
525 pub limits: LimitsEntitlement,
526 #[serde(default)]
529 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
530 #[serde(default, skip_serializing_if = "Vec::is_empty")]
532 pub tools: Vec<ToolRule>,
533 #[serde(default = "default_true")]
538 pub fail_closed_on_sandbox_error: bool,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
542pub struct NetworkEntitlement {
543 pub inbound: InboundNetwork,
544 pub outbound: OutboundNetwork,
545}
546
547#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
548pub struct InboundNetwork {
549 #[serde(default)]
550 pub ports: Vec<u16>,
551}
552
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
554pub struct OutboundNetwork {
555 pub mode: NetworkOutboundMode,
556 #[serde(default)]
557 pub allow_hosts: Vec<String>,
558 #[serde(default = "default_protocols")]
559 pub protocols: Vec<String>,
560 #[serde(default)]
561 pub resolve_dns: ResolveDnsConfig,
562}
563fn default_protocols() -> Vec<String> {
564 vec!["tcp".to_string()]
565}
566
567#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
571pub struct EgressAuthorization {
572 pub authorized_by: String,
573 pub authorized_at_ms: u64,
574}
575
576#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
577#[serde(rename_all = "lowercase")]
578pub enum NetworkOutboundMode {
579 Unrestricted,
580 Restricted,
581 Off,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
585pub struct ResolveDnsConfig {
586 #[serde(default = "default_dns_mode")]
587 pub mode: String,
588 #[serde(default)]
589 pub servers: Vec<String>,
590}
591impl Default for ResolveDnsConfig {
592 fn default() -> Self {
593 Self {
594 mode: default_dns_mode(),
595 servers: vec![],
596 }
597 }
598}
599fn default_dns_mode() -> String {
600 "system".to_string()
601}
602
603#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
604pub struct FilesystemEntitlement {
605 #[serde(default)]
606 pub read: Vec<String>,
607 #[serde(default)]
608 pub write: Vec<String>,
609 #[serde(default)]
610 pub deny: Vec<String>,
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
614pub struct ProcessesEntitlement {
615 pub spawn: SpawnEntitlement,
616}
617
618#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
619pub struct SpawnEntitlement {
620 pub mode: SpawnMode,
621 #[serde(default)]
622 pub allowed: Vec<String>,
623}
624
625#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
626#[serde(rename_all = "lowercase")]
627pub enum SpawnMode {
628 Allowlist,
629 Any,
630 None,
631 Strict,
637}
638
639#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
640pub struct SyscallsEntitlement {
641 #[serde(default = "default_syscalls_mode")]
642 pub mode: String,
643 #[serde(default)]
644 pub extra_deny: Vec<String>,
645}
646fn default_syscalls_mode() -> String {
647 "default".to_string()
648}
649
650#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
651pub struct LimitsEntitlement {
652 #[serde(default)]
653 pub cpu_seconds: Option<u64>,
654 #[serde(default = "default_memory_mb")]
655 pub memory_mb: u64,
656 #[serde(default = "default_fds")]
657 pub file_descriptors: u32,
658 #[serde(default = "default_procs")]
659 pub processes: u32,
660}
661fn default_memory_mb() -> u64 {
662 512
663}
664fn default_fds() -> u32 {
665 1024
666}
667fn default_procs() -> u32 {
668 32
669}
670
671#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
672#[serde(rename_all = "lowercase")]
673pub enum ToolPolicy {
674 Allow,
675 #[default]
676 Ask,
677 Deny,
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
681pub struct ToolRule {
682 pub pattern: String,
683 pub policy: ToolPolicy,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub risk: Option<crate::hitl::RiskTier>,
688}
689
690pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
694 for rule in rules {
695 if rule.pattern == tool_name {
696 return rule.policy;
697 }
698 }
699 let mut best: Option<(&ToolRule, usize)> = None;
700 for rule in rules {
701 if let Some(prefix) = rule.pattern.strip_suffix('*')
702 && tool_name.starts_with(prefix)
703 {
704 let len = prefix.len();
705 if best.is_none_or(|(_, best_len)| len > best_len) {
706 best = Some((rule, len));
707 }
708 }
709 }
710 if let Some((rule, _)) = best {
711 return rule.policy;
712 }
713 ToolPolicy::default()
714}
715
716#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
717pub struct NotificationsConfig {
718 #[serde(default)]
719 pub on_task_complete: Vec<NotificationTarget>,
720 #[serde(default)]
721 pub on_error: Vec<NotificationTarget>,
722 #[serde(default)]
723 pub on_shutdown: Vec<NotificationTarget>,
724}
725
726#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
727#[serde(tag = "target", rename_all = "lowercase")]
728pub enum NotificationTarget {
729 Agent {
730 name: String,
731 },
732 Commander,
733 Email {
734 address: String,
735 #[serde(default)]
736 smtp_config_file: Option<String>,
737 },
738 Slack {
739 #[serde(default)]
740 channel: Option<String>,
741 #[serde(default)]
742 webhook_url_env: Option<String>,
743 },
744 Webpush {
745 url: String,
746 },
747 Webhook {
748 url: String,
749 #[serde(default = "default_post")]
750 method: String,
751 #[serde(default)]
752 auth: Option<String>,
753 },
754}
755fn default_post() -> String {
756 "POST".to_string()
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
760pub struct RetryConfig {
761 pub llm: RetryPolicy,
762 pub tool: RetryPolicy,
763}
764
765#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
766pub struct RetryPolicy {
767 pub max_retries: u32,
768 pub backoff: BackoffStrategy,
769 pub initial_delay_ms: u64,
770 #[serde(default)]
771 pub max_delay_ms: Option<u64>,
772 #[serde(default)]
773 pub retry_on: Vec<String>,
774}
775
776#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
777#[serde(rename_all = "lowercase")]
778pub enum BackoffStrategy {
779 Linear,
780 Exponential,
781 Fixed,
782}
783
784#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
785pub struct LifecycleConfig {
786 pub restart: RestartPolicy,
787 #[serde(default = "default_max_restarts")]
788 pub max_restarts: u32,
789 #[serde(default = "default_window")]
790 pub restart_window_secs: u64,
791 #[serde(default = "default_stop_timeout")]
792 pub stop_timeout_secs: u64,
793 #[serde(default = "default_mcp_required")]
794 pub mcp_required: bool,
795 #[serde(default)]
796 pub execution: ExecutionMode,
797 #[serde(default)]
798 pub schedule: Vec<ScheduleEntry>,
799 #[serde(default)]
800 pub idle_triggers: Vec<IdleTrigger>,
801}
802fn default_max_restarts() -> u32 {
803 3
804}
805fn default_window() -> u64 {
806 600
807}
808fn default_stop_timeout() -> u64 {
809 15
810}
811fn default_mcp_required() -> bool {
812 true
813}
814
815#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
816#[serde(rename_all = "snake_case")]
817pub enum RestartPolicy {
818 Never,
819 OnFailure,
820 Always,
821}
822
823#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
824#[serde(rename_all = "snake_case")]
825pub enum ExecutionMode {
826 #[default]
827 Daemon,
828 OnDemand,
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
832pub struct ScheduleEntry {
833 pub cron: String,
834 pub message: String,
835 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub sends_to: Option<String>,
837}
838
839#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
840pub struct IdleTrigger {
841 pub after_secs: u64,
843 pub message: String,
845 #[serde(default, skip_serializing_if = "Option::is_none")]
847 pub sends_to: Option<String>,
848 #[serde(default = "default_idle_cooldown")]
851 pub cooldown_secs: u64,
852 #[serde(default = "default_true")]
855 pub respect_quiet_hours: bool,
856}
857
858fn default_idle_cooldown() -> u64 {
859 600
860}
861pub fn name_enabled(denylist: &[String], name: &str) -> bool {
863 !denylist.iter().any(|n| n == name)
864}
865
866pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
869 if enabled {
870 list.retain(|n| n != name);
871 } else if !list.iter().any(|n| n == name) {
872 list.push(name.to_string());
873 }
874}
875
876fn default_true() -> bool {
877 true
878}
879
880#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
881pub struct FileTransferConfig {
882 #[serde(default = "default_accept_max")]
883 pub accept_incoming_file_max_bytes: u64,
884 #[serde(default = "default_accept_total")]
885 pub accept_incoming_total_per_hour: u64,
886 #[serde(default = "default_approval_threshold")]
887 pub require_approval_above_bytes: u64,
888 #[serde(default = "default_reject_paths")]
889 pub reject_paths: Vec<String>,
890 #[serde(default = "default_allowed_mime")]
891 pub allowed_mime_types: Vec<String>,
892}
893
894impl Default for FileTransferConfig {
895 fn default() -> Self {
896 Self {
897 accept_incoming_file_max_bytes: default_accept_max(),
898 accept_incoming_total_per_hour: default_accept_total(),
899 require_approval_above_bytes: default_approval_threshold(),
900 reject_paths: default_reject_paths(),
901 allowed_mime_types: default_allowed_mime(),
902 }
903 }
904}
905
906fn default_accept_max() -> u64 {
907 10_485_760
908}
909fn default_accept_total() -> u64 {
910 104_857_600
911}
912fn default_approval_threshold() -> u64 {
913 10_485_760
914}
915fn default_reject_paths() -> Vec<String> {
916 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
917}
918fn default_allowed_mime() -> Vec<String> {
919 vec!["*".into()]
920}
921
922#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
923#[serde(rename_all = "snake_case")]
924pub enum DeploymentType {
925 #[default]
926 Laptop,
927 Vm,
928 Docker,
929 K8s,
930 Lambda,
931}
932
933#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
934pub struct DeploymentConfig {
935 #[serde(rename = "type", default)]
936 pub deployment_type: DeploymentType,
937 #[serde(default, skip_serializing_if = "Option::is_none")]
938 pub region: Option<String>,
939 #[serde(default = "default_env")]
940 pub environment: Option<String>,
941}
942
943impl Default for DeploymentConfig {
944 fn default() -> Self {
945 Self {
946 deployment_type: DeploymentType::default(),
947 region: None,
948 environment: default_env(),
949 }
950 }
951}
952
953fn default_env() -> Option<String> {
954 Some("dev".into())
955}
956
957#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
958pub struct LockFile {
959 pub schema: u32,
960 pub uuid: String,
961 pub name: String,
962 pub pid: u32,
963 pub ppid: u32,
964 pub started_at: String,
965 pub binary_version: String,
966 pub transports: LockTransports,
967 pub card_digest: String,
968 pub capabilities: Vec<String>,
969 #[serde(default)]
972 pub build_sha: String,
973 #[serde(default)]
976 pub proto_version: u32,
977}
978
979#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
980pub struct LockTransports {
981 pub stdio: bool,
982 #[serde(default)]
983 pub unix_socket: Option<String>,
984 #[serde(default)]
985 pub tcp: Option<String>,
986 #[serde(default)]
991 pub webhook: Option<String>,
992}
993
994#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1001#[serde(rename_all = "snake_case")]
1002pub enum VoiceId {
1003 #[default]
1005 AfHeart,
1006 AfBella,
1007 AfNicole,
1008 AmAdam,
1009 AmMichael,
1010}
1011
1012impl VoiceId {
1013 pub fn style_index(&self) -> usize {
1015 match self {
1016 VoiceId::AfHeart => 0,
1017 VoiceId::AfBella => 1,
1018 VoiceId::AfNicole => 2,
1019 VoiceId::AmAdam => 3,
1020 VoiceId::AmMichael => 4,
1021 }
1022 }
1023
1024 pub fn as_str(&self) -> &'static str {
1026 match self {
1027 VoiceId::AfHeart => "af_heart",
1028 VoiceId::AfBella => "af_bella",
1029 VoiceId::AfNicole => "af_nicole",
1030 VoiceId::AmAdam => "am_adam",
1031 VoiceId::AmMichael => "am_michael",
1032 }
1033 }
1034}
1035
1036impl std::str::FromStr for VoiceId {
1037 type Err = anyhow::Error;
1038
1039 fn from_str(s: &str) -> anyhow::Result<Self> {
1040 match s {
1041 "af_heart" => Ok(VoiceId::AfHeart),
1042 "af_bella" => Ok(VoiceId::AfBella),
1043 "af_nicole" => Ok(VoiceId::AfNicole),
1044 "am_adam" => Ok(VoiceId::AmAdam),
1045 "am_michael" => Ok(VoiceId::AmMichael),
1046 other => anyhow::bail!(
1047 "unknown voice ID '{other}' \
1048 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1049 ),
1050 }
1051 }
1052}
1053
1054#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1057pub struct VoiceConfig {
1058 #[serde(default)]
1060 pub enabled: bool,
1061 #[serde(default)]
1063 pub voice_id: VoiceId,
1064 #[serde(default, skip_serializing_if = "Option::is_none")]
1067 pub input_device: Option<String>,
1068}
1069
1070#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1075pub struct HitlConfig {
1076 #[serde(default = "default_hitl_timeout_secs")]
1077 pub timeout_secs: u32,
1078 #[serde(default)]
1082 pub max_iterations: Option<u32>,
1083 #[serde(default)]
1088 pub max_tokens: Option<u64>,
1089}
1090
1091fn default_hitl_timeout_secs() -> u32 {
1092 300
1093}
1094
1095impl Default for HitlConfig {
1096 fn default() -> Self {
1097 Self {
1098 timeout_secs: default_hitl_timeout_secs(),
1099 max_iterations: None,
1100 max_tokens: None,
1101 }
1102 }
1103}
1104
1105#[cfg(test)]
1106mod hitl_tests {
1107 use super::*;
1108
1109 #[test]
1110 fn hitl_config_default_max_iterations_is_none() {
1111 let cfg = HitlConfig::default();
1112 assert!(cfg.max_iterations.is_none());
1113 }
1114
1115 #[test]
1116 fn hitl_config_max_iterations_explicit() {
1117 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1118 assert_eq!(cfg.max_iterations, Some(5));
1119 }
1120
1121 #[test]
1122 fn hitl_config_default_max_tokens_is_none() {
1123 let cfg = HitlConfig::default();
1124 assert!(cfg.max_tokens.is_none());
1125 }
1126
1127 #[test]
1128 fn hitl_config_max_tokens_explicit() {
1129 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1130 assert_eq!(cfg.max_tokens, Some(250_000));
1131 }
1132}
1133
1134#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1140pub struct CompanionConfig {
1141 #[serde(default)]
1142 pub enabled: bool,
1143 #[serde(default = "default_locale")]
1144 pub locale: String,
1145 #[serde(default)]
1146 pub relationship: Relationship,
1147 #[serde(default)]
1148 pub voice_overrides: VoiceOverrides,
1149 #[serde(default)]
1150 pub onboarding: OnboardingState,
1151 #[serde(default)]
1152 pub rhythm: RhythmConfig,
1153 #[serde(default)]
1154 pub proactive: ProactiveConfig,
1155}
1156
1157pub fn default_locale() -> String {
1160 std::env::var("LANG")
1161 .ok()
1162 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
1163 .unwrap_or_else(|| "en-US".into())
1164}
1165
1166#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1167pub struct VoiceOverrides {
1168 #[serde(default, skip_serializing_if = "Option::is_none")]
1169 pub name_for_user: Option<String>,
1170 #[serde(default, skip_serializing_if = "Option::is_none")]
1171 pub formality: Option<Formality>,
1172 #[serde(default, skip_serializing_if = "Option::is_none")]
1173 pub extra_instructions: Option<String>,
1174}
1175
1176#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1177pub struct FirstMemory {
1178 pub text: String,
1179 pub established_at: chrono::DateTime<chrono::Utc>,
1180}
1181
1182#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1183pub struct OnboardingState {
1184 #[serde(default, skip_serializing_if = "Option::is_none")]
1185 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1186 #[serde(default)]
1187 pub version: u32,
1188 #[serde(default, skip_serializing_if = "Option::is_none")]
1189 pub agent_display_name: Option<String>,
1190 #[serde(default, skip_serializing_if = "Option::is_none")]
1191 pub first_memory: Option<FirstMemory>,
1192}
1193
1194#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1197pub struct RhythmConfig {
1198 #[serde(default)]
1199 pub enabled: bool,
1200}
1201
1202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1203pub struct ProactiveConfig {
1204 #[serde(default)]
1205 pub enabled: bool,
1206 #[serde(default, skip_serializing_if = "Option::is_none")]
1208 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1209 #[serde(default, skip_serializing_if = "Option::is_none")]
1210 pub quiet_hours: Option<QuietHours>,
1211 #[serde(default, skip_serializing_if = "Option::is_none")]
1212 pub active_hours: Option<ActiveHours>,
1213 #[serde(default = "default_daily_cap")]
1214 pub daily_cap: u8,
1215 #[serde(default = "default_channels")]
1216 pub channels: Vec<String>,
1217 #[serde(default, skip_serializing_if = "Option::is_none")]
1218 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1219}
1220
1221impl Default for ProactiveConfig {
1222 fn default() -> Self {
1223 Self {
1224 enabled: false,
1225 learning_until: None,
1226 quiet_hours: None,
1227 active_hours: None,
1228 daily_cap: default_daily_cap(),
1229 channels: default_channels(),
1230 paused_until: None,
1231 }
1232 }
1233}
1234
1235fn default_daily_cap() -> u8 {
1236 3
1237}
1238fn default_channels() -> Vec<String> {
1239 vec!["stdout".into()]
1240}
1241
1242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1243pub struct QuietHours {
1244 pub start: String,
1245 pub end: String,
1246}
1247
1248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1249pub struct ActiveHours {
1250 pub start: String,
1251 pub end: String,
1252}
1253
1254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1259pub struct AgentAppearance {
1260 #[serde(default = "default_style_preset")]
1262 pub style_preset: String,
1263 #[serde(default)]
1264 pub behavior_preset: BehaviorPreset,
1265 #[serde(default, skip_serializing_if = "Option::is_none")]
1267 pub source_image_path: Option<std::path::PathBuf>,
1268 #[serde(default = "default_expressions_dir")]
1270 pub expressions_dir: std::path::PathBuf,
1271 #[serde(default, skip_serializing_if = "Option::is_none")]
1272 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1273 #[serde(default)]
1274 pub render_status: RenderStatus,
1275}
1276
1277fn default_style_preset() -> String {
1278 "default-blob".into()
1279}
1280
1281fn default_expressions_dir() -> std::path::PathBuf {
1282 std::path::PathBuf::from("expressions")
1283}
1284
1285impl Default for AgentAppearance {
1286 fn default() -> Self {
1287 Self {
1288 style_preset: default_style_preset(),
1289 behavior_preset: BehaviorPreset::Normal,
1290 source_image_path: None,
1291 expressions_dir: default_expressions_dir(),
1292 last_rendered_at: None,
1293 render_status: RenderStatus::Pending,
1294 }
1295 }
1296}
1297
1298#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1299#[serde(rename_all = "snake_case")]
1300pub enum BehaviorPreset {
1301 Quiet,
1302 #[default]
1303 Normal,
1304 Lively,
1305}
1306
1307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1308#[serde(tag = "status", rename_all = "snake_case")]
1309pub enum RenderStatus {
1310 #[default]
1311 Pending,
1312 Rendering {
1313 done: u8,
1314 total: u8,
1315 },
1316 Ready,
1317 Failed {
1318 reason: String,
1319 },
1320}
1321
1322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1328#[serde(rename_all = "kebab-case")]
1329pub enum SnapshotPolicy {
1330 #[default]
1331 PullOnStart,
1332 PullPeriodic,
1333 Manual,
1334}
1335
1336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1338pub struct PatternFilter {
1339 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1340 pub applies_in: Vec<String>,
1341 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1342 pub tier: Vec<String>,
1343 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1344 pub maturity: Vec<String>,
1345 #[serde(default)]
1346 pub importance_min: f64,
1347 #[serde(default = "default_max_snapshot_count")]
1348 pub max_count: usize,
1349 #[serde(default)]
1350 pub snapshot_policy: SnapshotPolicy,
1351}
1352
1353fn default_max_snapshot_count() -> usize {
1354 200
1355}
1356
1357impl Default for PatternFilter {
1358 fn default() -> Self {
1359 Self {
1360 applies_in: vec![],
1361 tier: vec![],
1362 maturity: vec![],
1363 importance_min: 0.0,
1364 max_count: 200,
1365 snapshot_policy: SnapshotPolicy::default(),
1366 }
1367 }
1368}
1369
1370#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1372pub struct SnapshotRef {
1373 pub knowledge_commit: String,
1374 pub taken_at: String,
1375 pub filter: PatternFilter,
1376}
1377
1378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1380pub struct FederationConfig {
1381 #[serde(default)]
1382 pub filter: PatternFilter,
1383 #[serde(default, skip_serializing_if = "Option::is_none")]
1384 pub snapshot_ref: Option<SnapshotRef>,
1385 #[serde(default)]
1386 pub evidence_flush_interval_minutes: u32,
1387}
1388
1389impl AgentProfile {
1390 #[doc(hidden)]
1396 pub fn default_for_tests() -> Self {
1397 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1398 .expect("minimal profile fixture")
1399 }
1400
1401 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1403 self.addons.iter().find(|g| {
1404 g.skills.iter().any(|n| n == name)
1405 || g.mcp.iter().any(|n| n == name)
1406 || g.commands.iter().any(|n| n == name)
1407 })
1408 }
1409
1410 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1413 name_enabled(&self.disabled_skills, skill_name)
1414 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1415 }
1416
1417 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1419 name_enabled(&self.disabled_mcp, server_id)
1420 && self.group_of(server_id).is_none_or(|g| g.enabled)
1421 }
1422
1423 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1425 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1426 }
1427
1428 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1430 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1431 }
1432
1433 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1436 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1437 Some(g) => {
1438 g.enabled = enabled;
1439 true
1440 }
1441 None => false,
1442 }
1443 }
1444
1445 pub fn disable_all_addons(&mut self) {
1451 for g in &mut self.addons {
1452 g.enabled = false;
1453 }
1454 }
1455
1456 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1458 self.mcp_servers
1459 .iter()
1460 .filter(|m| self.mcp_enabled(&m.name))
1461 .cloned()
1462 .collect()
1463 }
1464}
1465
1466#[cfg(test)]
1467mod tests {
1468 use super::*;
1469
1470 #[test]
1471 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1472 let net = McpServerNetwork {
1473 mode: McpNetMode::BroadAudited,
1474 allow_hosts: vec![],
1475 deny_hosts: vec!["evil.example".into()],
1476 authorization: Some(EgressAuthorization {
1477 authorized_by: "david".into(),
1478 authorized_at_ms: 1_750_000_000_000,
1479 }),
1480 };
1481 let y = serde_yaml::to_string(&net).unwrap();
1482 assert!(y.contains("broad_audited"));
1483 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1484 assert_eq!(back, net);
1485 let legacy: McpServerNetwork =
1487 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1488 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1489 assert!(legacy.authorization.is_none());
1490 }
1491
1492 #[test]
1493 fn mcp_entry_network_is_optional_and_round_trips() {
1494 let bare = "name: x\ncommand: npx\n";
1496 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1497 assert!(e.network.is_none());
1498
1499 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1501 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1502 let net = e2.network.expect("network present");
1503 assert_eq!(net.mode, McpNetMode::Restricted);
1504 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1505
1506 let out = serde_yaml_ng::to_string(&e).unwrap();
1508 assert!(!out.contains("network"));
1509 }
1510
1511 #[test]
1512 fn profile_round_trip_yaml() {
1513 let yaml = r#"
1514schema: 1
1515id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1516name: agent_a
1517display_name: "Price Hunter"
1518version: "0.1.0"
1519persona:
1520 category: research
1521 description: "Finds prices"
1522 traits: { tone: concise, risk: cautious, verbosity: low }
1523sys_prompt_file: "sys_prompt.md"
1524model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1525mcp_servers: []
1526skills: []
1527transport:
1528 stdio: true
1529 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1530communication: { accepts_from: ["*"], sends_to: [] }
1531capabilities: ["a2a.message.send", "a2a.tasks"]
1532entitlements:
1533 network:
1534 inbound: { ports: [] }
1535 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1536 filesystem: { read: [], write: [], deny: [] }
1537 processes: { spawn: { mode: allowlist, allowed: [] } }
1538 syscalls: { mode: default }
1539 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1540notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1541retry:
1542 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1543 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1544lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1545created_at: "2026-04-22T10:00:00+08:00"
1546updated_at: "2026-04-22T10:00:00+08:00"
1547"#;
1548 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1549 assert_eq!(profile.name, "agent_a");
1550 assert_eq!(profile.persona.category, PersonaCategory::Research);
1551 assert_eq!(
1552 profile.entitlements.network.outbound.mode,
1553 NetworkOutboundMode::Restricted
1554 );
1555 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1556 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1557 assert_eq!(profile.id, round_tripped.id);
1558 }
1559}
1560
1561#[cfg(test)]
1562mod model_ref_tests {
1563 use super::*;
1564
1565 #[test]
1566 fn legacy_profile_without_model_ref_still_parses() {
1567 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1568 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1569 assert!(
1570 p.model_ref.is_none(),
1571 "legacy profile must not have model_ref"
1572 );
1573 }
1574
1575 #[test]
1576 fn round_trip_with_model_ref_preserves_field() {
1577 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1578 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1579 p.model_ref = Some("anthropic_opus_4_7".into());
1580 let s = serde_yaml_ng::to_string(&p).unwrap();
1581 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1582 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1583 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1584 }
1585}
1586
1587#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1595#[serde(rename_all = "snake_case")]
1596pub enum ProactiveTier {
1597 Off,
1598 WarmOnly,
1599 WarmAndBehavior,
1600 All,
1601}
1602
1603impl ProactiveTier {
1604 pub fn from_config(c: &CompanionConfig) -> Self {
1605 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1606 (false, _, _) => Self::Off,
1607 (true, false, false) => Self::WarmOnly,
1608 (true, true, false) => Self::WarmAndBehavior,
1609 (true, _, true) => Self::All,
1610 }
1611 }
1612
1613 pub fn apply(&self, c: &mut CompanionConfig) {
1614 match self {
1615 Self::Off => {
1616 c.enabled = false;
1617 c.rhythm.enabled = false;
1618 c.proactive.enabled = false;
1619 }
1620 Self::WarmOnly => {
1621 c.enabled = true;
1622 c.rhythm.enabled = false;
1623 c.proactive.enabled = false;
1624 }
1625 Self::WarmAndBehavior => {
1626 c.enabled = true;
1627 c.rhythm.enabled = true;
1628 c.proactive.enabled = false;
1629 }
1630 Self::All => {
1631 c.enabled = true;
1632 c.rhythm.enabled = true;
1633 c.proactive.enabled = true;
1634 }
1635 }
1636 }
1637}
1638
1639#[cfg(test)]
1640mod mcp_pin_tests {
1641 use super::*;
1642
1643 #[test]
1647 fn pre_m9_entry_roundtrips_without_pin_fields() {
1648 let yaml = r#"
1649name: weather
1650command: /opt/mcp/weather
1651args: ["--port", "0"]
1652"#;
1653 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1654 assert_eq!(entry.name, "weather");
1655 assert_eq!(entry.binary_sha256, None);
1656 assert_eq!(entry.description_hash, None);
1657 assert_eq!(entry.publisher, None);
1658 assert_eq!(entry.installed_at, None);
1659
1660 let out = serde_yaml_ng::to_string(&entry).unwrap();
1663 assert!(!out.contains("binary_sha256"), "got {out}");
1664 assert!(!out.contains("description_hash"), "got {out}");
1665 assert!(!out.contains("publisher"), "got {out}");
1666 assert!(!out.contains("installed_at"), "got {out}");
1667 }
1668
1669 #[test]
1671 fn full_m9_entry_roundtrips_all_fields() {
1672 let yaml = r#"
1673name: weather
1674command: /opt/mcp/weather
1675args: []
1676binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1677description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1678publisher:
1679 name: "@anthropic-mcp/weather"
1680 homepage: "https://github.com/anthropic-mcp/weather"
1681 registry_id: "@anthropic-mcp/weather@1.2.3"
1682installed_at: "2026-05-06T08:00:00Z"
1683"#;
1684 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1685 assert!(
1686 entry
1687 .binary_sha256
1688 .as_deref()
1689 .unwrap()
1690 .starts_with("3f4abca8")
1691 );
1692 assert!(
1693 entry
1694 .description_hash
1695 .as_deref()
1696 .unwrap()
1697 .starts_with("9a01b2c3")
1698 );
1699 let pub_info = entry.publisher.clone().unwrap();
1700 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1701 assert_eq!(
1702 pub_info.homepage.as_deref(),
1703 Some("https://github.com/anthropic-mcp/weather"),
1704 );
1705 assert_eq!(
1706 pub_info.registry_id.as_deref(),
1707 Some("@anthropic-mcp/weather@1.2.3"),
1708 );
1709 let installed = entry.installed_at.unwrap();
1710 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1711 }
1712
1713 #[test]
1717 fn partial_pin_only_binary_sha_roundtrips() {
1718 let yaml = r#"
1719name: weather
1720command: /opt/mcp/weather
1721args: []
1722binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1723"#;
1724 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1725 assert_eq!(
1726 entry.binary_sha256.as_deref(),
1727 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1728 );
1729 assert_eq!(entry.description_hash, None);
1730 assert_eq!(entry.publisher, None);
1731 }
1732
1733 #[test]
1736 fn publisher_minimal_just_name() {
1737 let yaml = r#"
1738name: weather
1739command: /opt/mcp/weather
1740args: []
1741publisher:
1742 name: "alice"
1743"#;
1744 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1745 let p = entry.publisher.as_ref().unwrap();
1746 assert_eq!(p.name, "alice");
1747 assert_eq!(p.homepage, None);
1748 assert_eq!(p.registry_id, None);
1749
1750 let out = serde_yaml_ng::to_string(&entry).unwrap();
1752 assert!(!out.contains("homepage:"), "got {out}");
1753 assert!(!out.contains("registry_id:"), "got {out}");
1754 }
1755}
1756
1757#[cfg(test)]
1758mod voice_tests {
1759 use super::*;
1760 use std::str::FromStr;
1761
1762 #[test]
1763 fn voice_config_round_trips() {
1764 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1766 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1767
1768 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1769 assert!(profile.voice.enabled);
1770 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1771
1772 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1774 assert!(!legacy.voice.enabled);
1775 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1776 }
1777
1778 #[test]
1779 fn voice_id_from_str_roundtrips() {
1780 let cases = [
1781 ("af_heart", VoiceId::AfHeart),
1782 ("af_bella", VoiceId::AfBella),
1783 ("af_nicole", VoiceId::AfNicole),
1784 ("am_adam", VoiceId::AmAdam),
1785 ("am_michael", VoiceId::AmMichael),
1786 ];
1787 for (s, expected) in cases {
1788 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1789 assert_eq!(expected.as_str(), s);
1790 }
1791 }
1792
1793 #[test]
1794 fn voice_id_from_str_rejects_unknown() {
1795 assert!(VoiceId::from_str("bogus").is_err());
1796 }
1797}
1798
1799#[cfg(test)]
1800mod idle_trigger_tests {
1801 use super::*;
1802
1803 #[test]
1804 fn idle_trigger_yaml_round_trip() {
1805 let yaml = r#"
1806restart: on_failure
1807idle_triggers:
1808 - after_secs: 3600
1809 message: "still there?"
1810 sends_to: other_agent
1811 cooldown_secs: 1800
1812 respect_quiet_hours: true
1813"#;
1814 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1815 assert_eq!(cfg.idle_triggers.len(), 1);
1816 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1817 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1818 assert_eq!(
1819 cfg.idle_triggers[0].sends_to.as_deref(),
1820 Some("other_agent")
1821 );
1822 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1823 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1824 }
1825
1826 #[test]
1827 fn idle_trigger_defaults_when_omitted() {
1828 let yaml = "restart: on_failure\n";
1829 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1830 assert!(cfg.idle_triggers.is_empty());
1831 }
1832}
1833
1834#[cfg(test)]
1835mod appearance_tests {
1836 use super::*;
1837
1838 #[test]
1839 fn appearance_default_style_preset_is_default_blob() {
1840 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1841 }
1842
1843 #[test]
1844 fn appearance_default_behavior_is_normal() {
1845 assert_eq!(
1846 AgentAppearance::default().behavior_preset,
1847 BehaviorPreset::Normal
1848 );
1849 }
1850
1851 #[test]
1852 fn appearance_default_render_status_is_pending() {
1853 assert_eq!(
1854 AgentAppearance::default().render_status,
1855 RenderStatus::Pending
1856 );
1857 }
1858
1859 #[test]
1860 fn render_status_serde_round_trip() {
1861 let cases = [
1862 RenderStatus::Pending,
1863 RenderStatus::Rendering { done: 3, total: 12 },
1864 RenderStatus::Ready,
1865 RenderStatus::Failed {
1866 reason: "out of quota".into(),
1867 },
1868 ];
1869 for status in cases {
1870 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1871 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1872 assert_eq!(status, back);
1873 }
1874 }
1875
1876 #[test]
1877 fn agent_profile_with_appearance_round_trips() {
1878 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1879 let yaml = format!(
1880 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
1881 );
1882 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1883 assert_eq!(profile.appearance.style_preset, "chiikawa");
1884 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1885
1886 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1887 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1888 assert_eq!(profile.appearance, back.appearance);
1889 }
1890
1891 #[test]
1892 fn legacy_profile_without_appearance_uses_default() {
1893 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1894 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1895 assert_eq!(profile.appearance.style_preset, "default-blob");
1896 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1897 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1898 }
1899
1900 #[test]
1901 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1902 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1903 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1904 assert!(p.file_actions.is_empty());
1905 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1906 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1907 }
1908}
1909
1910#[cfg(test)]
1911mod federation_tests {
1912 use super::*;
1913
1914 #[test]
1915 fn test_pattern_filter_default() {
1916 let f = PatternFilter::default();
1917 assert_eq!(f.max_count, 200);
1918 assert_eq!(f.importance_min, 0.0);
1919 assert!(f.tier.is_empty());
1920 }
1921
1922 #[test]
1923 fn test_federation_config_roundtrip() {
1924 let cfg = FederationConfig {
1925 filter: PatternFilter {
1926 tier: vec!["core".into()],
1927 max_count: 50,
1928 ..Default::default()
1929 },
1930 snapshot_ref: Some(SnapshotRef {
1931 knowledge_commit: "abc123def456".into(),
1932 taken_at: "2026-05-19T00:00:00Z".into(),
1933 filter: PatternFilter::default(),
1934 }),
1935 evidence_flush_interval_minutes: 15,
1936 };
1937 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1938 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1939 assert_eq!(cfg, back);
1940 }
1941
1942 #[test]
1943 fn test_agent_profile_federation_defaults() {
1944 let cfg = FederationConfig::default();
1948 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1949 assert!(cfg.snapshot_ref.is_none());
1950 }
1951}
1952
1953#[cfg(test)]
1954mod skill_card_tests {
1955 use super::*;
1956
1957 #[test]
1958 fn installed_skills_default_to_empty_when_absent() {
1959 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1960 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1961 assert!(p.installed_skills.is_empty());
1962 }
1963
1964 #[test]
1965 fn installed_skills_roundtrip_preserves_entries() {
1966 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1967 let yaml = format!(
1968 "{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"
1969 );
1970 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
1971 assert_eq!(p.installed_skills.len(), 1);
1972 assert_eq!(p.installed_skills[0].name, "s1");
1973 assert_eq!(p.installed_skills[0].abstract_text, "does things");
1974 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
1975
1976 let out = serde_yaml_ng::to_string(&p).unwrap();
1977 assert!(out.contains("abstract: does things"));
1978 assert!(out.contains("pattern: /find"));
1979
1980 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
1981 assert_eq!(p.installed_skills, back.installed_skills);
1982 }
1983
1984 #[test]
1985 fn installed_skills_minimal_entry_serializes_compactly() {
1986 let entry = SkillCardEntry {
1988 name: "minimal".into(),
1989 ..Default::default()
1990 };
1991 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
1992 assert!(yaml.contains("name: minimal"));
1993 assert!(
1994 !yaml.contains("version:"),
1995 "empty version must be skipped: {yaml}"
1996 );
1997 assert!(
1998 !yaml.contains("publisher:"),
1999 "empty publisher must be skipped: {yaml}"
2000 );
2001 assert!(
2002 !yaml.contains("abstract:"),
2003 "empty abstract must be skipped: {yaml}"
2004 );
2005 }
2006}
2007
2008#[cfg(test)]
2009mod tool_policy_tests {
2010 use super::*;
2011
2012 fn rules() -> Vec<ToolRule> {
2013 vec![
2014 ToolRule {
2015 pattern: "mcp__github__merge_pr".into(),
2016 policy: ToolPolicy::Ask,
2017 risk: None,
2018 },
2019 ToolRule {
2020 pattern: "mcp__github__*".into(),
2021 policy: ToolPolicy::Allow,
2022 risk: None,
2023 },
2024 ToolRule {
2025 pattern: "mcp__*".into(),
2026 policy: ToolPolicy::Deny,
2027 risk: None,
2028 },
2029 ToolRule {
2030 pattern: "bash".into(),
2031 policy: ToolPolicy::Allow,
2032 risk: None,
2033 },
2034 ]
2035 }
2036
2037 #[test]
2038 fn exact_beats_glob() {
2039 assert_eq!(
2040 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2041 ToolPolicy::Ask
2042 );
2043 }
2044
2045 #[test]
2046 fn longer_glob_wins() {
2047 assert_eq!(
2048 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2049 ToolPolicy::Allow
2050 );
2051 }
2052
2053 #[test]
2054 fn shorter_glob_fallback() {
2055 assert_eq!(
2056 resolve_tool_policy(&rules(), "mcp__slack__send"),
2057 ToolPolicy::Deny
2058 );
2059 }
2060
2061 #[test]
2062 fn exact_bash() {
2063 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2064 }
2065
2066 #[test]
2067 fn unknown_tool_defaults_ask() {
2068 assert_eq!(
2069 resolve_tool_policy(&rules(), "unknown_tool"),
2070 ToolPolicy::Ask
2071 );
2072 }
2073
2074 #[test]
2075 fn empty_rules_defaults_ask() {
2076 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2077 }
2078
2079 fn minimal_entitlements_yaml() -> &'static str {
2080 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2081 }
2082
2083 #[test]
2084 fn entitlements_tools_defaults_empty() {
2085 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2086 assert!(e.tools.is_empty());
2087 }
2088
2089 #[test]
2090 fn entitlements_tools_roundtrip() {
2091 let base = minimal_entitlements_yaml();
2092 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2093 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2094 assert_eq!(e.tools.len(), 1);
2095 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2096 let y = serde_yaml_ng::to_string(&e).unwrap();
2097 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2098 assert_eq!(back.tools.len(), 1);
2099 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2100 }
2101 #[test]
2102 fn denylist_membership_and_mutation() {
2103 let mut list: Vec<String> = vec![];
2104 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2105
2106 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2108 assert_eq!(list, ["a"]);
2109
2110 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2112
2113 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2115 assert!(list.is_empty());
2116
2117 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2119 }
2120
2121 #[test]
2122 fn addon_group_rule_truth_table() {
2123 let mut p = AgentProfile::default_for_tests();
2124 p.addons.push(AddonRef {
2125 id: "grp".into(),
2126 source: "claude-local:grp@1.0.0".into(),
2127 enabled: false,
2128 skills: vec!["g_skill".into()],
2129 mcp: vec!["g_mcp".into()],
2130 commands: vec!["g_cmd".into()],
2131 });
2132
2133 assert!(p.skill_enabled("standalone"));
2135 assert!(p.mcp_enabled("standalone_mcp"));
2136
2137 assert!(!p.skill_enabled("g_skill"));
2139 assert!(!p.mcp_enabled("g_mcp"));
2140
2141 assert!(p.set_addon_enabled("grp", true));
2143 assert!(p.skill_enabled("g_skill"));
2144 assert!(p.mcp_enabled("g_mcp"));
2145
2146 p.set_skill_enabled("g_skill", false);
2148 assert!(!p.skill_enabled("g_skill"));
2149
2150 assert!(!p.set_addon_enabled("nope", true));
2152
2153 p.disable_all_addons();
2155 assert!(p.addons.iter().all(|g| !g.enabled));
2156 assert!(!p.skill_enabled("g_skill"));
2157 assert!(!p.skill_enabled("g_cmd"));
2158 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2164 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);
2170 assert!(p.skill_enabled("g_skill"));
2171 }
2172}
2173
2174#[cfg(test)]
2175mod lockfile_compat_tests {
2176 use super::*;
2177
2178 #[test]
2179 fn lockfile_new_fields_default_for_old_locks() {
2180 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2183 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2184 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2185 let lock: LockFile = serde_json::from_str(old).unwrap();
2186 assert_eq!(lock.build_sha, "");
2187 assert_eq!(lock.proto_version, 0);
2188 }
2189}
2190
2191#[cfg(test)]
2192mod remote_mcp_tests {
2193 use super::*;
2194
2195 #[test]
2196 fn mcp_entry_roundtrips_remote_bearer() {
2197 let e = McpServerEntry {
2198 name: "gh".into(),
2199 command: String::new(),
2200 url: Some("https://api.example.com/mcp".into()),
2201 auth: Some(McpAuth::Bearer {
2202 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2203 }),
2204 ..Default::default()
2205 };
2206 let y = serde_yaml_ng::to_string(&e).unwrap();
2207 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2208 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2209 assert!(matches!(
2210 back.auth,
2211 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2212 ));
2213 let legacy: McpServerEntry =
2215 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2216 assert!(legacy.url.is_none());
2217 assert!(legacy.auth.is_none());
2218 }
2219}