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 Off,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
342pub struct McpServerNetwork {
343 #[serde(default)]
344 pub mode: McpNetMode,
345 #[serde(default)]
346 pub allow_hosts: Vec<String>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
358pub struct AddonRef {
359 pub id: String,
361 pub source: String,
363 #[serde(default)]
364 pub enabled: bool,
365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
366 pub skills: Vec<String>,
367 #[serde(default, skip_serializing_if = "Vec::is_empty")]
368 pub mcp: Vec<String>,
369 #[serde(default, skip_serializing_if = "Vec::is_empty")]
370 pub commands: Vec<String>,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
379pub struct McpPublisherInfo {
380 pub name: String,
383
384 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub homepage: Option<String>,
389
390 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub registry_id: Option<String>,
394}
395
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
397pub struct TransportConfig {
398 pub stdio: bool,
399 pub socket: SocketTransportConfig,
400 #[serde(default)]
401 pub tcp: TcpTransportConfig,
402 #[serde(default)]
406 pub webhook: WebhookTransportConfig,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
410pub struct TcpTransportConfig {
411 #[serde(default)]
412 pub enabled: bool,
413 #[serde(default)]
414 pub bind: String,
415 #[serde(default)]
416 pub noise: NoiseConfig,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
433pub struct WebhookTransportConfig {
434 #[serde(default)]
435 pub enabled: bool,
436 #[serde(default = "default_webhook_bind")]
437 pub bind: String,
438 #[serde(default = "default_webhook_port")]
439 pub port: u16,
440 #[serde(default)]
444 pub hmac_secret_ref: String,
445}
446
447fn default_webhook_bind() -> String {
448 "127.0.0.1".to_string()
449}
450
451fn default_webhook_port() -> u16 {
452 6789
453}
454
455impl Default for WebhookTransportConfig {
456 fn default() -> Self {
457 Self {
458 enabled: false,
459 bind: default_webhook_bind(),
460 port: default_webhook_port(),
461 hmac_secret_ref: String::new(),
462 }
463 }
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
467pub struct NoiseConfig {
468 pub pattern: String,
469}
470
471impl Default for NoiseConfig {
472 fn default() -> Self {
473 Self {
474 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
475 }
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
480pub struct SocketTransportConfig {
481 pub enabled: bool,
482 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub auth: Option<AuthConfig>,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
488pub struct AuthConfig {
489 pub scheme: String,
490 pub token_file: String,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
494pub struct CommunicationConfig {
495 #[serde(default = "default_accepts_all")]
496 pub accepts_from: Vec<String>,
497 #[serde(default)]
498 pub sends_to: Vec<String>,
499}
500fn default_accepts_all() -> Vec<String> {
501 vec!["*".to_string()]
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
505pub struct Entitlements {
506 pub network: NetworkEntitlement,
507 pub filesystem: FilesystemEntitlement,
508 pub processes: ProcessesEntitlement,
509 #[serde(default)]
510 pub syscalls: SyscallsEntitlement,
511 #[serde(default)]
512 pub limits: LimitsEntitlement,
513 #[serde(default)]
516 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
517 #[serde(default, skip_serializing_if = "Vec::is_empty")]
519 pub tools: Vec<ToolRule>,
520 #[serde(default = "default_true")]
525 pub fail_closed_on_sandbox_error: bool,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
529pub struct NetworkEntitlement {
530 pub inbound: InboundNetwork,
531 pub outbound: OutboundNetwork,
532}
533
534#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
535pub struct InboundNetwork {
536 #[serde(default)]
537 pub ports: Vec<u16>,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
541pub struct OutboundNetwork {
542 pub mode: NetworkOutboundMode,
543 #[serde(default)]
544 pub allow_hosts: Vec<String>,
545 #[serde(default = "default_protocols")]
546 pub protocols: Vec<String>,
547 #[serde(default)]
548 pub resolve_dns: ResolveDnsConfig,
549}
550fn default_protocols() -> Vec<String> {
551 vec!["tcp".to_string()]
552}
553
554#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
555#[serde(rename_all = "lowercase")]
556pub enum NetworkOutboundMode {
557 Unrestricted,
558 Restricted,
559 Off,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
563pub struct ResolveDnsConfig {
564 #[serde(default = "default_dns_mode")]
565 pub mode: String,
566 #[serde(default)]
567 pub servers: Vec<String>,
568}
569impl Default for ResolveDnsConfig {
570 fn default() -> Self {
571 Self {
572 mode: default_dns_mode(),
573 servers: vec![],
574 }
575 }
576}
577fn default_dns_mode() -> String {
578 "system".to_string()
579}
580
581#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
582pub struct FilesystemEntitlement {
583 #[serde(default)]
584 pub read: Vec<String>,
585 #[serde(default)]
586 pub write: Vec<String>,
587 #[serde(default)]
588 pub deny: Vec<String>,
589}
590
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
592pub struct ProcessesEntitlement {
593 pub spawn: SpawnEntitlement,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
597pub struct SpawnEntitlement {
598 pub mode: SpawnMode,
599 #[serde(default)]
600 pub allowed: Vec<String>,
601}
602
603#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
604#[serde(rename_all = "lowercase")]
605pub enum SpawnMode {
606 Allowlist,
607 Any,
608 None,
609}
610
611#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
612pub struct SyscallsEntitlement {
613 #[serde(default = "default_syscalls_mode")]
614 pub mode: String,
615 #[serde(default)]
616 pub extra_deny: Vec<String>,
617}
618fn default_syscalls_mode() -> String {
619 "default".to_string()
620}
621
622#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
623pub struct LimitsEntitlement {
624 #[serde(default)]
625 pub cpu_seconds: Option<u64>,
626 #[serde(default = "default_memory_mb")]
627 pub memory_mb: u64,
628 #[serde(default = "default_fds")]
629 pub file_descriptors: u32,
630 #[serde(default = "default_procs")]
631 pub processes: u32,
632}
633fn default_memory_mb() -> u64 {
634 512
635}
636fn default_fds() -> u32 {
637 1024
638}
639fn default_procs() -> u32 {
640 32
641}
642
643#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
644#[serde(rename_all = "lowercase")]
645pub enum ToolPolicy {
646 Allow,
647 #[default]
648 Ask,
649 Deny,
650}
651
652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
653pub struct ToolRule {
654 pub pattern: String,
655 pub policy: ToolPolicy,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub risk: Option<crate::hitl::RiskTier>,
660}
661
662pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
666 for rule in rules {
667 if rule.pattern == tool_name {
668 return rule.policy;
669 }
670 }
671 let mut best: Option<(&ToolRule, usize)> = None;
672 for rule in rules {
673 if let Some(prefix) = rule.pattern.strip_suffix('*')
674 && tool_name.starts_with(prefix)
675 {
676 let len = prefix.len();
677 if best.is_none_or(|(_, best_len)| len > best_len) {
678 best = Some((rule, len));
679 }
680 }
681 }
682 if let Some((rule, _)) = best {
683 return rule.policy;
684 }
685 ToolPolicy::default()
686}
687
688#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
689pub struct NotificationsConfig {
690 #[serde(default)]
691 pub on_task_complete: Vec<NotificationTarget>,
692 #[serde(default)]
693 pub on_error: Vec<NotificationTarget>,
694 #[serde(default)]
695 pub on_shutdown: Vec<NotificationTarget>,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
699#[serde(tag = "target", rename_all = "lowercase")]
700pub enum NotificationTarget {
701 Agent {
702 name: String,
703 },
704 Commander,
705 Email {
706 address: String,
707 #[serde(default)]
708 smtp_config_file: Option<String>,
709 },
710 Slack {
711 #[serde(default)]
712 channel: Option<String>,
713 #[serde(default)]
714 webhook_url_env: Option<String>,
715 },
716 Webpush {
717 url: String,
718 },
719 Webhook {
720 url: String,
721 #[serde(default = "default_post")]
722 method: String,
723 #[serde(default)]
724 auth: Option<String>,
725 },
726}
727fn default_post() -> String {
728 "POST".to_string()
729}
730
731#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
732pub struct RetryConfig {
733 pub llm: RetryPolicy,
734 pub tool: RetryPolicy,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
738pub struct RetryPolicy {
739 pub max_retries: u32,
740 pub backoff: BackoffStrategy,
741 pub initial_delay_ms: u64,
742 #[serde(default)]
743 pub max_delay_ms: Option<u64>,
744 #[serde(default)]
745 pub retry_on: Vec<String>,
746}
747
748#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
749#[serde(rename_all = "lowercase")]
750pub enum BackoffStrategy {
751 Linear,
752 Exponential,
753 Fixed,
754}
755
756#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
757pub struct LifecycleConfig {
758 pub restart: RestartPolicy,
759 #[serde(default = "default_max_restarts")]
760 pub max_restarts: u32,
761 #[serde(default = "default_window")]
762 pub restart_window_secs: u64,
763 #[serde(default = "default_stop_timeout")]
764 pub stop_timeout_secs: u64,
765 #[serde(default = "default_mcp_required")]
766 pub mcp_required: bool,
767 #[serde(default)]
768 pub execution: ExecutionMode,
769 #[serde(default)]
770 pub schedule: Vec<ScheduleEntry>,
771 #[serde(default)]
772 pub idle_triggers: Vec<IdleTrigger>,
773}
774fn default_max_restarts() -> u32 {
775 3
776}
777fn default_window() -> u64 {
778 600
779}
780fn default_stop_timeout() -> u64 {
781 15
782}
783fn default_mcp_required() -> bool {
784 true
785}
786
787#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
788#[serde(rename_all = "snake_case")]
789pub enum RestartPolicy {
790 Never,
791 OnFailure,
792 Always,
793}
794
795#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
796#[serde(rename_all = "snake_case")]
797pub enum ExecutionMode {
798 #[default]
799 Daemon,
800 OnDemand,
801}
802
803#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
804pub struct ScheduleEntry {
805 pub cron: String,
806 pub message: String,
807 #[serde(default, skip_serializing_if = "Option::is_none")]
808 pub sends_to: Option<String>,
809}
810
811#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
812pub struct IdleTrigger {
813 pub after_secs: u64,
815 pub message: String,
817 #[serde(default, skip_serializing_if = "Option::is_none")]
819 pub sends_to: Option<String>,
820 #[serde(default = "default_idle_cooldown")]
823 pub cooldown_secs: u64,
824 #[serde(default = "default_true")]
827 pub respect_quiet_hours: bool,
828}
829
830fn default_idle_cooldown() -> u64 {
831 600
832}
833pub fn name_enabled(denylist: &[String], name: &str) -> bool {
835 !denylist.iter().any(|n| n == name)
836}
837
838pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
841 if enabled {
842 list.retain(|n| n != name);
843 } else if !list.iter().any(|n| n == name) {
844 list.push(name.to_string());
845 }
846}
847
848fn default_true() -> bool {
849 true
850}
851
852#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
853pub struct FileTransferConfig {
854 #[serde(default = "default_accept_max")]
855 pub accept_incoming_file_max_bytes: u64,
856 #[serde(default = "default_accept_total")]
857 pub accept_incoming_total_per_hour: u64,
858 #[serde(default = "default_approval_threshold")]
859 pub require_approval_above_bytes: u64,
860 #[serde(default = "default_reject_paths")]
861 pub reject_paths: Vec<String>,
862 #[serde(default = "default_allowed_mime")]
863 pub allowed_mime_types: Vec<String>,
864}
865
866impl Default for FileTransferConfig {
867 fn default() -> Self {
868 Self {
869 accept_incoming_file_max_bytes: default_accept_max(),
870 accept_incoming_total_per_hour: default_accept_total(),
871 require_approval_above_bytes: default_approval_threshold(),
872 reject_paths: default_reject_paths(),
873 allowed_mime_types: default_allowed_mime(),
874 }
875 }
876}
877
878fn default_accept_max() -> u64 {
879 10_485_760
880}
881fn default_accept_total() -> u64 {
882 104_857_600
883}
884fn default_approval_threshold() -> u64 {
885 10_485_760
886}
887fn default_reject_paths() -> Vec<String> {
888 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
889}
890fn default_allowed_mime() -> Vec<String> {
891 vec!["*".into()]
892}
893
894#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
895#[serde(rename_all = "snake_case")]
896pub enum DeploymentType {
897 #[default]
898 Laptop,
899 Vm,
900 Docker,
901 K8s,
902 Lambda,
903}
904
905#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
906pub struct DeploymentConfig {
907 #[serde(rename = "type", default)]
908 pub deployment_type: DeploymentType,
909 #[serde(default, skip_serializing_if = "Option::is_none")]
910 pub region: Option<String>,
911 #[serde(default = "default_env")]
912 pub environment: Option<String>,
913}
914
915impl Default for DeploymentConfig {
916 fn default() -> Self {
917 Self {
918 deployment_type: DeploymentType::default(),
919 region: None,
920 environment: default_env(),
921 }
922 }
923}
924
925fn default_env() -> Option<String> {
926 Some("dev".into())
927}
928
929#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
930pub struct LockFile {
931 pub schema: u32,
932 pub uuid: String,
933 pub name: String,
934 pub pid: u32,
935 pub ppid: u32,
936 pub started_at: String,
937 pub binary_version: String,
938 pub transports: LockTransports,
939 pub card_digest: String,
940 pub capabilities: Vec<String>,
941 #[serde(default)]
944 pub build_sha: String,
945 #[serde(default)]
948 pub proto_version: u32,
949}
950
951#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
952pub struct LockTransports {
953 pub stdio: bool,
954 #[serde(default)]
955 pub unix_socket: Option<String>,
956 #[serde(default)]
957 pub tcp: Option<String>,
958 #[serde(default)]
963 pub webhook: Option<String>,
964}
965
966#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
973#[serde(rename_all = "snake_case")]
974pub enum VoiceId {
975 #[default]
977 AfHeart,
978 AfBella,
979 AfNicole,
980 AmAdam,
981 AmMichael,
982}
983
984impl VoiceId {
985 pub fn style_index(&self) -> usize {
987 match self {
988 VoiceId::AfHeart => 0,
989 VoiceId::AfBella => 1,
990 VoiceId::AfNicole => 2,
991 VoiceId::AmAdam => 3,
992 VoiceId::AmMichael => 4,
993 }
994 }
995
996 pub fn as_str(&self) -> &'static str {
998 match self {
999 VoiceId::AfHeart => "af_heart",
1000 VoiceId::AfBella => "af_bella",
1001 VoiceId::AfNicole => "af_nicole",
1002 VoiceId::AmAdam => "am_adam",
1003 VoiceId::AmMichael => "am_michael",
1004 }
1005 }
1006}
1007
1008impl std::str::FromStr for VoiceId {
1009 type Err = anyhow::Error;
1010
1011 fn from_str(s: &str) -> anyhow::Result<Self> {
1012 match s {
1013 "af_heart" => Ok(VoiceId::AfHeart),
1014 "af_bella" => Ok(VoiceId::AfBella),
1015 "af_nicole" => Ok(VoiceId::AfNicole),
1016 "am_adam" => Ok(VoiceId::AmAdam),
1017 "am_michael" => Ok(VoiceId::AmMichael),
1018 other => anyhow::bail!(
1019 "unknown voice ID '{other}' \
1020 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1021 ),
1022 }
1023 }
1024}
1025
1026#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1029pub struct VoiceConfig {
1030 #[serde(default)]
1032 pub enabled: bool,
1033 #[serde(default)]
1035 pub voice_id: VoiceId,
1036 #[serde(default, skip_serializing_if = "Option::is_none")]
1039 pub input_device: Option<String>,
1040}
1041
1042#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1047pub struct HitlConfig {
1048 #[serde(default = "default_hitl_timeout_secs")]
1049 pub timeout_secs: u32,
1050 #[serde(default)]
1054 pub max_iterations: Option<u32>,
1055 #[serde(default)]
1060 pub max_tokens: Option<u64>,
1061}
1062
1063fn default_hitl_timeout_secs() -> u32 {
1064 300
1065}
1066
1067impl Default for HitlConfig {
1068 fn default() -> Self {
1069 Self {
1070 timeout_secs: default_hitl_timeout_secs(),
1071 max_iterations: None,
1072 max_tokens: None,
1073 }
1074 }
1075}
1076
1077#[cfg(test)]
1078mod hitl_tests {
1079 use super::*;
1080
1081 #[test]
1082 fn hitl_config_default_max_iterations_is_none() {
1083 let cfg = HitlConfig::default();
1084 assert!(cfg.max_iterations.is_none());
1085 }
1086
1087 #[test]
1088 fn hitl_config_max_iterations_explicit() {
1089 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1090 assert_eq!(cfg.max_iterations, Some(5));
1091 }
1092
1093 #[test]
1094 fn hitl_config_default_max_tokens_is_none() {
1095 let cfg = HitlConfig::default();
1096 assert!(cfg.max_tokens.is_none());
1097 }
1098
1099 #[test]
1100 fn hitl_config_max_tokens_explicit() {
1101 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1102 assert_eq!(cfg.max_tokens, Some(250_000));
1103 }
1104}
1105
1106#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1112pub struct CompanionConfig {
1113 #[serde(default)]
1114 pub enabled: bool,
1115 #[serde(default = "default_locale")]
1116 pub locale: String,
1117 #[serde(default)]
1118 pub relationship: Relationship,
1119 #[serde(default)]
1120 pub voice_overrides: VoiceOverrides,
1121 #[serde(default)]
1122 pub onboarding: OnboardingState,
1123 #[serde(default)]
1124 pub rhythm: RhythmConfig,
1125 #[serde(default)]
1126 pub proactive: ProactiveConfig,
1127}
1128
1129pub fn default_locale() -> String {
1132 std::env::var("LANG")
1133 .ok()
1134 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
1135 .unwrap_or_else(|| "en-US".into())
1136}
1137
1138#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1139pub struct VoiceOverrides {
1140 #[serde(default, skip_serializing_if = "Option::is_none")]
1141 pub name_for_user: Option<String>,
1142 #[serde(default, skip_serializing_if = "Option::is_none")]
1143 pub formality: Option<Formality>,
1144 #[serde(default, skip_serializing_if = "Option::is_none")]
1145 pub extra_instructions: Option<String>,
1146}
1147
1148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1149pub struct FirstMemory {
1150 pub text: String,
1151 pub established_at: chrono::DateTime<chrono::Utc>,
1152}
1153
1154#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1155pub struct OnboardingState {
1156 #[serde(default, skip_serializing_if = "Option::is_none")]
1157 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1158 #[serde(default)]
1159 pub version: u32,
1160 #[serde(default, skip_serializing_if = "Option::is_none")]
1161 pub agent_display_name: Option<String>,
1162 #[serde(default, skip_serializing_if = "Option::is_none")]
1163 pub first_memory: Option<FirstMemory>,
1164}
1165
1166#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1169pub struct RhythmConfig {
1170 #[serde(default)]
1171 pub enabled: bool,
1172}
1173
1174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1175pub struct ProactiveConfig {
1176 #[serde(default)]
1177 pub enabled: bool,
1178 #[serde(default, skip_serializing_if = "Option::is_none")]
1180 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1181 #[serde(default, skip_serializing_if = "Option::is_none")]
1182 pub quiet_hours: Option<QuietHours>,
1183 #[serde(default, skip_serializing_if = "Option::is_none")]
1184 pub active_hours: Option<ActiveHours>,
1185 #[serde(default = "default_daily_cap")]
1186 pub daily_cap: u8,
1187 #[serde(default = "default_channels")]
1188 pub channels: Vec<String>,
1189 #[serde(default, skip_serializing_if = "Option::is_none")]
1190 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1191}
1192
1193impl Default for ProactiveConfig {
1194 fn default() -> Self {
1195 Self {
1196 enabled: false,
1197 learning_until: None,
1198 quiet_hours: None,
1199 active_hours: None,
1200 daily_cap: default_daily_cap(),
1201 channels: default_channels(),
1202 paused_until: None,
1203 }
1204 }
1205}
1206
1207fn default_daily_cap() -> u8 {
1208 3
1209}
1210fn default_channels() -> Vec<String> {
1211 vec!["stdout".into()]
1212}
1213
1214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1215pub struct QuietHours {
1216 pub start: String,
1217 pub end: String,
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1221pub struct ActiveHours {
1222 pub start: String,
1223 pub end: String,
1224}
1225
1226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1231pub struct AgentAppearance {
1232 #[serde(default = "default_style_preset")]
1234 pub style_preset: String,
1235 #[serde(default)]
1236 pub behavior_preset: BehaviorPreset,
1237 #[serde(default, skip_serializing_if = "Option::is_none")]
1239 pub source_image_path: Option<std::path::PathBuf>,
1240 #[serde(default = "default_expressions_dir")]
1242 pub expressions_dir: std::path::PathBuf,
1243 #[serde(default, skip_serializing_if = "Option::is_none")]
1244 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1245 #[serde(default)]
1246 pub render_status: RenderStatus,
1247}
1248
1249fn default_style_preset() -> String {
1250 "default-blob".into()
1251}
1252
1253fn default_expressions_dir() -> std::path::PathBuf {
1254 std::path::PathBuf::from("expressions")
1255}
1256
1257impl Default for AgentAppearance {
1258 fn default() -> Self {
1259 Self {
1260 style_preset: default_style_preset(),
1261 behavior_preset: BehaviorPreset::Normal,
1262 source_image_path: None,
1263 expressions_dir: default_expressions_dir(),
1264 last_rendered_at: None,
1265 render_status: RenderStatus::Pending,
1266 }
1267 }
1268}
1269
1270#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1271#[serde(rename_all = "snake_case")]
1272pub enum BehaviorPreset {
1273 Quiet,
1274 #[default]
1275 Normal,
1276 Lively,
1277}
1278
1279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1280#[serde(tag = "status", rename_all = "snake_case")]
1281pub enum RenderStatus {
1282 #[default]
1283 Pending,
1284 Rendering {
1285 done: u8,
1286 total: u8,
1287 },
1288 Ready,
1289 Failed {
1290 reason: String,
1291 },
1292}
1293
1294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1300#[serde(rename_all = "kebab-case")]
1301pub enum SnapshotPolicy {
1302 #[default]
1303 PullOnStart,
1304 PullPeriodic,
1305 Manual,
1306}
1307
1308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1310pub struct PatternFilter {
1311 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1312 pub applies_in: Vec<String>,
1313 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1314 pub tier: Vec<String>,
1315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1316 pub maturity: Vec<String>,
1317 #[serde(default)]
1318 pub importance_min: f64,
1319 #[serde(default = "default_max_snapshot_count")]
1320 pub max_count: usize,
1321 #[serde(default)]
1322 pub snapshot_policy: SnapshotPolicy,
1323}
1324
1325fn default_max_snapshot_count() -> usize {
1326 200
1327}
1328
1329impl Default for PatternFilter {
1330 fn default() -> Self {
1331 Self {
1332 applies_in: vec![],
1333 tier: vec![],
1334 maturity: vec![],
1335 importance_min: 0.0,
1336 max_count: 200,
1337 snapshot_policy: SnapshotPolicy::default(),
1338 }
1339 }
1340}
1341
1342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1344pub struct SnapshotRef {
1345 pub knowledge_commit: String,
1346 pub taken_at: String,
1347 pub filter: PatternFilter,
1348}
1349
1350#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1352pub struct FederationConfig {
1353 #[serde(default)]
1354 pub filter: PatternFilter,
1355 #[serde(default, skip_serializing_if = "Option::is_none")]
1356 pub snapshot_ref: Option<SnapshotRef>,
1357 #[serde(default)]
1358 pub evidence_flush_interval_minutes: u32,
1359}
1360
1361impl AgentProfile {
1362 #[doc(hidden)]
1368 pub fn default_for_tests() -> Self {
1369 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1370 .expect("minimal profile fixture")
1371 }
1372
1373 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1375 self.addons.iter().find(|g| {
1376 g.skills.iter().any(|n| n == name)
1377 || g.mcp.iter().any(|n| n == name)
1378 || g.commands.iter().any(|n| n == name)
1379 })
1380 }
1381
1382 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1385 name_enabled(&self.disabled_skills, skill_name)
1386 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1387 }
1388
1389 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1391 name_enabled(&self.disabled_mcp, server_id)
1392 && self.group_of(server_id).is_none_or(|g| g.enabled)
1393 }
1394
1395 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1397 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1398 }
1399
1400 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1402 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1403 }
1404
1405 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1408 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1409 Some(g) => {
1410 g.enabled = enabled;
1411 true
1412 }
1413 None => false,
1414 }
1415 }
1416
1417 pub fn disable_all_addons(&mut self) {
1423 for g in &mut self.addons {
1424 g.enabled = false;
1425 }
1426 }
1427
1428 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1430 self.mcp_servers
1431 .iter()
1432 .filter(|m| self.mcp_enabled(&m.name))
1433 .cloned()
1434 .collect()
1435 }
1436}
1437
1438#[cfg(test)]
1439mod tests {
1440 use super::*;
1441
1442 #[test]
1443 fn mcp_entry_network_is_optional_and_round_trips() {
1444 let bare = "name: x\ncommand: npx\n";
1446 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1447 assert!(e.network.is_none());
1448
1449 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1451 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1452 let net = e2.network.expect("network present");
1453 assert_eq!(net.mode, McpNetMode::Restricted);
1454 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1455
1456 let out = serde_yaml_ng::to_string(&e).unwrap();
1458 assert!(!out.contains("network"));
1459 }
1460
1461 #[test]
1462 fn profile_round_trip_yaml() {
1463 let yaml = r#"
1464schema: 1
1465id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1466name: agent_a
1467display_name: "Price Hunter"
1468version: "0.1.0"
1469persona:
1470 category: research
1471 description: "Finds prices"
1472 traits: { tone: concise, risk: cautious, verbosity: low }
1473sys_prompt_file: "sys_prompt.md"
1474model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1475mcp_servers: []
1476skills: []
1477transport:
1478 stdio: true
1479 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1480communication: { accepts_from: ["*"], sends_to: [] }
1481capabilities: ["a2a.message.send", "a2a.tasks"]
1482entitlements:
1483 network:
1484 inbound: { ports: [] }
1485 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1486 filesystem: { read: [], write: [], deny: [] }
1487 processes: { spawn: { mode: allowlist, allowed: [] } }
1488 syscalls: { mode: default }
1489 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1490notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1491retry:
1492 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1493 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1494lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1495created_at: "2026-04-22T10:00:00+08:00"
1496updated_at: "2026-04-22T10:00:00+08:00"
1497"#;
1498 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1499 assert_eq!(profile.name, "agent_a");
1500 assert_eq!(profile.persona.category, PersonaCategory::Research);
1501 assert_eq!(
1502 profile.entitlements.network.outbound.mode,
1503 NetworkOutboundMode::Restricted
1504 );
1505 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1506 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1507 assert_eq!(profile.id, round_tripped.id);
1508 }
1509}
1510
1511#[cfg(test)]
1512mod model_ref_tests {
1513 use super::*;
1514
1515 #[test]
1516 fn legacy_profile_without_model_ref_still_parses() {
1517 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1518 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1519 assert!(
1520 p.model_ref.is_none(),
1521 "legacy profile must not have model_ref"
1522 );
1523 }
1524
1525 #[test]
1526 fn round_trip_with_model_ref_preserves_field() {
1527 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1528 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1529 p.model_ref = Some("anthropic_opus_4_7".into());
1530 let s = serde_yaml_ng::to_string(&p).unwrap();
1531 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1532 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1533 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1534 }
1535}
1536
1537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1545#[serde(rename_all = "snake_case")]
1546pub enum ProactiveTier {
1547 Off,
1548 WarmOnly,
1549 WarmAndBehavior,
1550 All,
1551}
1552
1553impl ProactiveTier {
1554 pub fn from_config(c: &CompanionConfig) -> Self {
1555 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1556 (false, _, _) => Self::Off,
1557 (true, false, false) => Self::WarmOnly,
1558 (true, true, false) => Self::WarmAndBehavior,
1559 (true, _, true) => Self::All,
1560 }
1561 }
1562
1563 pub fn apply(&self, c: &mut CompanionConfig) {
1564 match self {
1565 Self::Off => {
1566 c.enabled = false;
1567 c.rhythm.enabled = false;
1568 c.proactive.enabled = false;
1569 }
1570 Self::WarmOnly => {
1571 c.enabled = true;
1572 c.rhythm.enabled = false;
1573 c.proactive.enabled = false;
1574 }
1575 Self::WarmAndBehavior => {
1576 c.enabled = true;
1577 c.rhythm.enabled = true;
1578 c.proactive.enabled = false;
1579 }
1580 Self::All => {
1581 c.enabled = true;
1582 c.rhythm.enabled = true;
1583 c.proactive.enabled = true;
1584 }
1585 }
1586 }
1587}
1588
1589#[cfg(test)]
1590mod mcp_pin_tests {
1591 use super::*;
1592
1593 #[test]
1597 fn pre_m9_entry_roundtrips_without_pin_fields() {
1598 let yaml = r#"
1599name: weather
1600command: /opt/mcp/weather
1601args: ["--port", "0"]
1602"#;
1603 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1604 assert_eq!(entry.name, "weather");
1605 assert_eq!(entry.binary_sha256, None);
1606 assert_eq!(entry.description_hash, None);
1607 assert_eq!(entry.publisher, None);
1608 assert_eq!(entry.installed_at, None);
1609
1610 let out = serde_yaml_ng::to_string(&entry).unwrap();
1613 assert!(!out.contains("binary_sha256"), "got {out}");
1614 assert!(!out.contains("description_hash"), "got {out}");
1615 assert!(!out.contains("publisher"), "got {out}");
1616 assert!(!out.contains("installed_at"), "got {out}");
1617 }
1618
1619 #[test]
1621 fn full_m9_entry_roundtrips_all_fields() {
1622 let yaml = r#"
1623name: weather
1624command: /opt/mcp/weather
1625args: []
1626binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1627description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1628publisher:
1629 name: "@anthropic-mcp/weather"
1630 homepage: "https://github.com/anthropic-mcp/weather"
1631 registry_id: "@anthropic-mcp/weather@1.2.3"
1632installed_at: "2026-05-06T08:00:00Z"
1633"#;
1634 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1635 assert!(
1636 entry
1637 .binary_sha256
1638 .as_deref()
1639 .unwrap()
1640 .starts_with("3f4abca8")
1641 );
1642 assert!(
1643 entry
1644 .description_hash
1645 .as_deref()
1646 .unwrap()
1647 .starts_with("9a01b2c3")
1648 );
1649 let pub_info = entry.publisher.clone().unwrap();
1650 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1651 assert_eq!(
1652 pub_info.homepage.as_deref(),
1653 Some("https://github.com/anthropic-mcp/weather"),
1654 );
1655 assert_eq!(
1656 pub_info.registry_id.as_deref(),
1657 Some("@anthropic-mcp/weather@1.2.3"),
1658 );
1659 let installed = entry.installed_at.unwrap();
1660 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1661 }
1662
1663 #[test]
1667 fn partial_pin_only_binary_sha_roundtrips() {
1668 let yaml = r#"
1669name: weather
1670command: /opt/mcp/weather
1671args: []
1672binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1673"#;
1674 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1675 assert_eq!(
1676 entry.binary_sha256.as_deref(),
1677 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1678 );
1679 assert_eq!(entry.description_hash, None);
1680 assert_eq!(entry.publisher, None);
1681 }
1682
1683 #[test]
1686 fn publisher_minimal_just_name() {
1687 let yaml = r#"
1688name: weather
1689command: /opt/mcp/weather
1690args: []
1691publisher:
1692 name: "alice"
1693"#;
1694 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1695 let p = entry.publisher.as_ref().unwrap();
1696 assert_eq!(p.name, "alice");
1697 assert_eq!(p.homepage, None);
1698 assert_eq!(p.registry_id, None);
1699
1700 let out = serde_yaml_ng::to_string(&entry).unwrap();
1702 assert!(!out.contains("homepage:"), "got {out}");
1703 assert!(!out.contains("registry_id:"), "got {out}");
1704 }
1705}
1706
1707#[cfg(test)]
1708mod voice_tests {
1709 use super::*;
1710 use std::str::FromStr;
1711
1712 #[test]
1713 fn voice_config_round_trips() {
1714 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1716 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1717
1718 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1719 assert!(profile.voice.enabled);
1720 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1721
1722 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1724 assert!(!legacy.voice.enabled);
1725 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1726 }
1727
1728 #[test]
1729 fn voice_id_from_str_roundtrips() {
1730 let cases = [
1731 ("af_heart", VoiceId::AfHeart),
1732 ("af_bella", VoiceId::AfBella),
1733 ("af_nicole", VoiceId::AfNicole),
1734 ("am_adam", VoiceId::AmAdam),
1735 ("am_michael", VoiceId::AmMichael),
1736 ];
1737 for (s, expected) in cases {
1738 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1739 assert_eq!(expected.as_str(), s);
1740 }
1741 }
1742
1743 #[test]
1744 fn voice_id_from_str_rejects_unknown() {
1745 assert!(VoiceId::from_str("bogus").is_err());
1746 }
1747}
1748
1749#[cfg(test)]
1750mod idle_trigger_tests {
1751 use super::*;
1752
1753 #[test]
1754 fn idle_trigger_yaml_round_trip() {
1755 let yaml = r#"
1756restart: on_failure
1757idle_triggers:
1758 - after_secs: 3600
1759 message: "still there?"
1760 sends_to: other_agent
1761 cooldown_secs: 1800
1762 respect_quiet_hours: true
1763"#;
1764 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1765 assert_eq!(cfg.idle_triggers.len(), 1);
1766 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1767 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1768 assert_eq!(
1769 cfg.idle_triggers[0].sends_to.as_deref(),
1770 Some("other_agent")
1771 );
1772 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1773 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1774 }
1775
1776 #[test]
1777 fn idle_trigger_defaults_when_omitted() {
1778 let yaml = "restart: on_failure\n";
1779 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1780 assert!(cfg.idle_triggers.is_empty());
1781 }
1782}
1783
1784#[cfg(test)]
1785mod appearance_tests {
1786 use super::*;
1787
1788 #[test]
1789 fn appearance_default_style_preset_is_default_blob() {
1790 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1791 }
1792
1793 #[test]
1794 fn appearance_default_behavior_is_normal() {
1795 assert_eq!(
1796 AgentAppearance::default().behavior_preset,
1797 BehaviorPreset::Normal
1798 );
1799 }
1800
1801 #[test]
1802 fn appearance_default_render_status_is_pending() {
1803 assert_eq!(
1804 AgentAppearance::default().render_status,
1805 RenderStatus::Pending
1806 );
1807 }
1808
1809 #[test]
1810 fn render_status_serde_round_trip() {
1811 let cases = [
1812 RenderStatus::Pending,
1813 RenderStatus::Rendering { done: 3, total: 12 },
1814 RenderStatus::Ready,
1815 RenderStatus::Failed {
1816 reason: "out of quota".into(),
1817 },
1818 ];
1819 for status in cases {
1820 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1821 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1822 assert_eq!(status, back);
1823 }
1824 }
1825
1826 #[test]
1827 fn agent_profile_with_appearance_round_trips() {
1828 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1829 let yaml = format!(
1830 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
1831 );
1832 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1833 assert_eq!(profile.appearance.style_preset, "chiikawa");
1834 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1835
1836 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1837 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1838 assert_eq!(profile.appearance, back.appearance);
1839 }
1840
1841 #[test]
1842 fn legacy_profile_without_appearance_uses_default() {
1843 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1844 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1845 assert_eq!(profile.appearance.style_preset, "default-blob");
1846 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1847 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1848 }
1849
1850 #[test]
1851 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1852 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1853 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1854 assert!(p.file_actions.is_empty());
1855 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1856 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1857 }
1858}
1859
1860#[cfg(test)]
1861mod federation_tests {
1862 use super::*;
1863
1864 #[test]
1865 fn test_pattern_filter_default() {
1866 let f = PatternFilter::default();
1867 assert_eq!(f.max_count, 200);
1868 assert_eq!(f.importance_min, 0.0);
1869 assert!(f.tier.is_empty());
1870 }
1871
1872 #[test]
1873 fn test_federation_config_roundtrip() {
1874 let cfg = FederationConfig {
1875 filter: PatternFilter {
1876 tier: vec!["core".into()],
1877 max_count: 50,
1878 ..Default::default()
1879 },
1880 snapshot_ref: Some(SnapshotRef {
1881 knowledge_commit: "abc123def456".into(),
1882 taken_at: "2026-05-19T00:00:00Z".into(),
1883 filter: PatternFilter::default(),
1884 }),
1885 evidence_flush_interval_minutes: 15,
1886 };
1887 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1888 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1889 assert_eq!(cfg, back);
1890 }
1891
1892 #[test]
1893 fn test_agent_profile_federation_defaults() {
1894 let cfg = FederationConfig::default();
1898 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1899 assert!(cfg.snapshot_ref.is_none());
1900 }
1901}
1902
1903#[cfg(test)]
1904mod skill_card_tests {
1905 use super::*;
1906
1907 #[test]
1908 fn installed_skills_default_to_empty_when_absent() {
1909 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1910 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1911 assert!(p.installed_skills.is_empty());
1912 }
1913
1914 #[test]
1915 fn installed_skills_roundtrip_preserves_entries() {
1916 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1917 let yaml = format!(
1918 "{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"
1919 );
1920 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
1921 assert_eq!(p.installed_skills.len(), 1);
1922 assert_eq!(p.installed_skills[0].name, "s1");
1923 assert_eq!(p.installed_skills[0].abstract_text, "does things");
1924 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
1925
1926 let out = serde_yaml_ng::to_string(&p).unwrap();
1927 assert!(out.contains("abstract: does things"));
1928 assert!(out.contains("pattern: /find"));
1929
1930 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
1931 assert_eq!(p.installed_skills, back.installed_skills);
1932 }
1933
1934 #[test]
1935 fn installed_skills_minimal_entry_serializes_compactly() {
1936 let entry = SkillCardEntry {
1938 name: "minimal".into(),
1939 ..Default::default()
1940 };
1941 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
1942 assert!(yaml.contains("name: minimal"));
1943 assert!(
1944 !yaml.contains("version:"),
1945 "empty version must be skipped: {yaml}"
1946 );
1947 assert!(
1948 !yaml.contains("publisher:"),
1949 "empty publisher must be skipped: {yaml}"
1950 );
1951 assert!(
1952 !yaml.contains("abstract:"),
1953 "empty abstract must be skipped: {yaml}"
1954 );
1955 }
1956}
1957
1958#[cfg(test)]
1959mod tool_policy_tests {
1960 use super::*;
1961
1962 fn rules() -> Vec<ToolRule> {
1963 vec![
1964 ToolRule {
1965 pattern: "mcp__github__merge_pr".into(),
1966 policy: ToolPolicy::Ask,
1967 risk: None,
1968 },
1969 ToolRule {
1970 pattern: "mcp__github__*".into(),
1971 policy: ToolPolicy::Allow,
1972 risk: None,
1973 },
1974 ToolRule {
1975 pattern: "mcp__*".into(),
1976 policy: ToolPolicy::Deny,
1977 risk: None,
1978 },
1979 ToolRule {
1980 pattern: "bash".into(),
1981 policy: ToolPolicy::Allow,
1982 risk: None,
1983 },
1984 ]
1985 }
1986
1987 #[test]
1988 fn exact_beats_glob() {
1989 assert_eq!(
1990 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
1991 ToolPolicy::Ask
1992 );
1993 }
1994
1995 #[test]
1996 fn longer_glob_wins() {
1997 assert_eq!(
1998 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
1999 ToolPolicy::Allow
2000 );
2001 }
2002
2003 #[test]
2004 fn shorter_glob_fallback() {
2005 assert_eq!(
2006 resolve_tool_policy(&rules(), "mcp__slack__send"),
2007 ToolPolicy::Deny
2008 );
2009 }
2010
2011 #[test]
2012 fn exact_bash() {
2013 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2014 }
2015
2016 #[test]
2017 fn unknown_tool_defaults_ask() {
2018 assert_eq!(
2019 resolve_tool_policy(&rules(), "unknown_tool"),
2020 ToolPolicy::Ask
2021 );
2022 }
2023
2024 #[test]
2025 fn empty_rules_defaults_ask() {
2026 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2027 }
2028
2029 fn minimal_entitlements_yaml() -> &'static str {
2030 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
2031 }
2032
2033 #[test]
2034 fn entitlements_tools_defaults_empty() {
2035 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2036 assert!(e.tools.is_empty());
2037 }
2038
2039 #[test]
2040 fn entitlements_tools_roundtrip() {
2041 let base = minimal_entitlements_yaml();
2042 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
2043 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2044 assert_eq!(e.tools.len(), 1);
2045 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2046 let y = serde_yaml_ng::to_string(&e).unwrap();
2047 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2048 assert_eq!(back.tools.len(), 1);
2049 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2050 }
2051 #[test]
2052 fn denylist_membership_and_mutation() {
2053 let mut list: Vec<String> = vec![];
2054 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2055
2056 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
2058 assert_eq!(list, ["a"]);
2059
2060 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
2062
2063 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
2065 assert!(list.is_empty());
2066
2067 set_denylist(&mut list, "b", true); assert!(list.is_empty());
2069 }
2070
2071 #[test]
2072 fn addon_group_rule_truth_table() {
2073 let mut p = AgentProfile::default_for_tests();
2074 p.addons.push(AddonRef {
2075 id: "grp".into(),
2076 source: "claude-local:grp@1.0.0".into(),
2077 enabled: false,
2078 skills: vec!["g_skill".into()],
2079 mcp: vec!["g_mcp".into()],
2080 commands: vec!["g_cmd".into()],
2081 });
2082
2083 assert!(p.skill_enabled("standalone"));
2085 assert!(p.mcp_enabled("standalone_mcp"));
2086
2087 assert!(!p.skill_enabled("g_skill"));
2089 assert!(!p.mcp_enabled("g_mcp"));
2090
2091 assert!(p.set_addon_enabled("grp", true));
2093 assert!(p.skill_enabled("g_skill"));
2094 assert!(p.mcp_enabled("g_mcp"));
2095
2096 p.set_skill_enabled("g_skill", false);
2098 assert!(!p.skill_enabled("g_skill"));
2099
2100 assert!(!p.set_addon_enabled("nope", true));
2102
2103 p.disable_all_addons();
2105 assert!(p.addons.iter().all(|g| !g.enabled));
2106 assert!(!p.skill_enabled("g_skill"));
2107 assert!(!p.skill_enabled("g_cmd"));
2108 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2114 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);
2120 assert!(p.skill_enabled("g_skill"));
2121 }
2122}
2123
2124#[cfg(test)]
2125mod lockfile_compat_tests {
2126 use super::*;
2127
2128 #[test]
2129 fn lockfile_new_fields_default_for_old_locks() {
2130 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2133 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2134 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2135 let lock: LockFile = serde_json::from_str(old).unwrap();
2136 assert_eq!(lock.build_sha, "");
2137 assert_eq!(lock.proto_version, 0);
2138 }
2139}
2140
2141#[cfg(test)]
2142mod remote_mcp_tests {
2143 use super::*;
2144
2145 #[test]
2146 fn mcp_entry_roundtrips_remote_bearer() {
2147 let e = McpServerEntry {
2148 name: "gh".into(),
2149 command: String::new(),
2150 url: Some("https://api.example.com/mcp".into()),
2151 auth: Some(McpAuth::Bearer {
2152 token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2153 }),
2154 ..Default::default()
2155 };
2156 let y = serde_yaml_ng::to_string(&e).unwrap();
2157 let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2158 assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2159 assert!(matches!(
2160 back.auth,
2161 Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2162 ));
2163 let legacy: McpServerEntry =
2165 serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2166 assert!(legacy.url.is_none());
2167 assert!(legacy.auth.is_none());
2168 }
2169}