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