Skip to main content

mur_common/
agent.rs

1//! Agent profile, Agent Card, and LockFile types shared between
2//! mur-agent-runtime and mur-core.
3
4use crate::companion::{Formality, Relationship};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8/// Skill metadata broadcast in the Agent Card (Layer 1 + Layer 2).
9///
10/// Populated by `mur skill install` (registry or agent:// URL). Distinct from
11/// `AgentProfile.skills`, which is the legacy per-agent-path list managed by
12/// `mur agent skill add`.
13#[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    /// Layer 2 abstract — injected at session start (~200 tokens).
29    /// On-disk YAML key is `abstract` (a Rust reserved word).
30    #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
31    pub abstract_text: String,
32    /// Provenance chain copied from the installed manifest. Empty for
33    /// registry-installed skills.
34    #[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, // UUIDv7
50    pub name: String,
51    pub display_name: String,
52    pub version: String,
53    pub persona: Persona,
54    pub sys_prompt_file: String,
55    pub model: ModelConfig,
56    /// Optional pointer into ~/.mur/models.yaml. When set, the runtime
57    /// prefers the registry entry over the inline `model:` block.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub model_ref: Option<String>,
60    #[serde(default)]
61    pub mcp_servers: Vec<McpServerEntry>,
62    #[serde(default)]
63    pub skills: Vec<String>,
64    /// Skills installed via `mur skill install`. Distinct from `skills`
65    /// (which holds legacy per-agent paths from `mur agent skill add`).
66    /// Broadcast in the Agent Card alongside `skills`.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub installed_skills: Vec<SkillCardEntry>,
69    pub transport: TransportConfig,
70    pub communication: CommunicationConfig,
71    #[serde(default)]
72    pub capabilities: Vec<String>,
73    pub entitlements: Entitlements,
74    #[serde(default)]
75    pub notifications: NotificationsConfig,
76    pub retry: RetryConfig,
77    pub lifecycle: LifecycleConfig,
78    /// Cryptographic identity for cross-host A2A (P0a.5+). Default = empty
79    /// (legacy P0a profiles continue to load without this block).
80    #[serde(default)]
81    pub identity: IdentityConfig,
82    #[serde(default)]
83    pub file_transfer: FileTransferConfig,
84    #[serde(default)]
85    pub deployment: DeploymentConfig,
86    /// Companion subsystem (Phase 1.1+). Default = disabled (legacy profiles
87    /// continue to load without this block).
88    #[serde(default)]
89    pub companion: CompanionConfig,
90    /// Voice I/O configuration (D1). Default = disabled.
91    #[serde(default)]
92    pub voice: VoiceConfig,
93    /// A1: config-driven handler picker. Absent block = all defaults.
94    #[serde(default)]
95    pub hooks: crate::HooksConfig,
96    /// Pubkeys of bridges (and other LLM-less peers) this agent will accept
97    /// signed envelopes from. Empty = accept no bridge traffic. Default = empty.
98    #[serde(default)]
99    pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
100    pub created_at: String,
101    pub updated_at: String,
102    /// Hub companion visual identity (M-h3). Default = default-blob / Normal / Pending.
103    #[serde(default)]
104    pub appearance: AgentAppearance,
105    /// E6: Pattern federation — snapshot filter + outbox config.
106    #[serde(default)]
107    pub federation: FederationConfig,
108
109    /// A1: declarative UI action list — file_actions rendered as action
110    /// buttons in the pending-item selection UI. New top-level key; NOT
111    /// nested under `capabilities:`.
112    #[serde(default)]
113    pub file_actions: Vec<crate::action::FileAction>,
114
115    /// A2 + A3: action pipeline configuration (deletion safety + queue limits).
116    #[serde(default)]
117    pub action_pipeline: crate::action::ActionPipelineConfig,
118}
119
120fn default_algorithm() -> String {
121    "ed25519".into()
122}
123
124/// Algorithms the runtime can generate + verify.
125pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128pub struct IdentityConfig {
129    /// Multibase-encoded Ed25519 public key (base58btc, `z` prefix).
130    /// Empty string for legacy P0a profiles; filled on P0a.5 `mur agent create`.
131    #[serde(default)]
132    pub pubkey: String,
133    /// Free-form owner identity (email / SSO sub). None for legacy profiles.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub owner: Option<String>,
136
137    // P0a.6 rekey extensions (all #[serde(default)] — back-compat)
138    /// Cryptographic algorithm for this key. Defaults to "ed25519".
139    #[serde(default = "default_algorithm")]
140    pub algorithm: String,
141    /// Monotonic version counter; 0 = initial create, increments on each rotation.
142    #[serde(default)]
143    pub key_version: u32,
144    /// RFC3339 timestamp of when this key was created.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub created_at_key: Option<String>,
147    /// Previous public key (before most recent rotation). None if not rotated yet.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub previous_pubkey: Option<String>,
150    /// Version of the previous key. None if not rotated yet.
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub previous_key_version: Option<u32>,
153    /// RFC3339 timestamp when grace period expires and old key is fully retired.
154    /// Only set during rotation; cleared once grace period ends.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub grace_expires_at: Option<String>,
157    /// RFC3339 timestamp of the most recent key rotation (normal, not emergency).
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub rotated_at: Option<String>,
160    /// RFC3339 timestamp of emergency key rotation (set only if emergency rekey occurred).
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub emergency_rekey_at: Option<String>,
163}
164
165impl Default for IdentityConfig {
166    fn default() -> Self {
167        Self {
168            pubkey: String::new(),
169            owner: None,
170            algorithm: default_algorithm(),
171            key_version: 0,
172            created_at_key: None,
173            previous_pubkey: None,
174            previous_key_version: None,
175            grace_expires_at: None,
176            rotated_at: None,
177            emergency_rekey_at: None,
178        }
179    }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
183pub struct Persona {
184    pub category: PersonaCategory,
185    pub description: String,
186    pub traits: PersonaTraits,
187}
188
189#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
190#[serde(rename_all = "lowercase")]
191pub enum PersonaCategory {
192    Research,
193    Automation,
194    Monitor,
195    Notify,
196    Commerce,
197    Custom,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub struct PersonaTraits {
202    pub tone: String,
203    pub risk: String,
204    pub verbosity: String,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
208pub struct ModelConfig {
209    pub provider: String,
210    pub name: String,
211    #[serde(default)]
212    pub params: BTreeMap<String, serde_yaml_ng::Value>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
216pub struct McpServerEntry {
217    pub name: String,
218    pub command: String,
219    #[serde(default)]
220    pub args: Vec<String>,
221
222    /// SHA-256 (hex, lowercase) of the binary at `command`'s resolved
223    /// path, captured at install time. `None` means the entry was
224    /// added before B0 M9.1 (back-compat) and rule-6 enforcement is
225    /// not applied — the supervisor will warn but not block.
226    /// (B0 rule 6 / M9.1)
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub binary_sha256: Option<String>,
229
230    /// SHA-256 (hex, lowercase) of the canonical-JSON of the MCP's
231    /// `tools/list` response, captured at install time. `None` means
232    /// the install path skipped the description probe (e.g. the MCP
233    /// uses a non-stdio transport or the binary couldn't be reached)
234    /// or the entry pre-dates M9. (B0 rule 6 / M9.1)
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub description_hash: Option<String>,
237
238    /// Display-only publisher metadata captured at install time so
239    /// the user can recall what they consented to. `None` for older
240    /// entries. (B0 rule 6 / M9.1)
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub publisher: Option<McpPublisherInfo>,
243
244    /// RFC3339 timestamp of when the entry was added or last
245    /// re-approved by the user via `mur agent mcp pin`. Used by the
246    /// rug-pull dialog UX. `None` for older entries. (B0 rule 6 / M9.1)
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
249}
250
251/// Display-only publisher metadata captured at install time. None of
252/// the fields are validated against any external authority — they're
253/// shown to the user during the install confirm prompt and reproduced
254/// in `mur agent mcp inspect` output so the user can audit who they
255/// thought they were trusting. (B0 rule 6 / M9.1)
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
257pub struct McpPublisherInfo {
258    /// Free-form publisher identifier — e.g. `"Anthropic"`,
259    /// `"@github-user-alice"`, or whatever `serverInfo.name` returned.
260    pub name: String,
261
262    /// Optional homepage / docs URL. Best-effort: extracted from the
263    /// MCP's `serverInfo.metadata.homepage` or registry entry when
264    /// available; otherwise left unset.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub homepage: Option<String>,
267
268    /// Optional registry coordinate — e.g. `"@anthropic-mcp/weather@1.2.3"`.
269    /// Used purely for display; not consumed by any verification path.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub registry_id: Option<String>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
275pub struct TransportConfig {
276    pub stdio: bool,
277    pub socket: SocketTransportConfig,
278    #[serde(default)]
279    pub tcp: TcpTransportConfig,
280    /// Track C5 — HTTP webhook receiver. Default off; enabling
281    /// requires an HMAC secret in the OS keychain (`SecretRef`).
282    /// See `docs/superpowers/specs/2026-05-05-mur-agent-c5-webhook-design.md`.
283    #[serde(default)]
284    pub webhook: WebhookTransportConfig,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
288pub struct TcpTransportConfig {
289    #[serde(default)]
290    pub enabled: bool,
291    #[serde(default)]
292    pub bind: String,
293    #[serde(default)]
294    pub noise: NoiseConfig,
295}
296
297/// HTTP webhook receiver — Track C5.
298///
299/// External systems POST `SharePayload`-shaped JSON to
300/// `http://<bind>:<port>/agents/<slug>/webhook` with an
301/// `X-Mur-Signature: sha256=<hex>` header carrying an HMAC-SHA256
302/// over the raw body. The HMAC secret is stored in the OS keychain
303/// via `SecretRef` (same pattern as Telegram bot tokens in C2);
304/// `hmac_secret_ref` is the `service:account` lookup key.
305///
306/// `bind` defaults to `127.0.0.1` so a fresh enable doesn't
307/// inadvertently expose the agent to the local network. Users who
308/// want VPN / Tailscale reachability override to `0.0.0.0` or the
309/// VPN interface address explicitly.
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
311pub struct WebhookTransportConfig {
312    #[serde(default)]
313    pub enabled: bool,
314    #[serde(default = "default_webhook_bind")]
315    pub bind: String,
316    #[serde(default = "default_webhook_port")]
317    pub port: u16,
318    /// `service:account` key into the OS keychain. Empty string
319    /// when `enabled = false`; required (and validated) at startup
320    /// when enabled.
321    #[serde(default)]
322    pub hmac_secret_ref: String,
323}
324
325fn default_webhook_bind() -> String {
326    "127.0.0.1".to_string()
327}
328
329fn default_webhook_port() -> u16 {
330    6789
331}
332
333impl Default for WebhookTransportConfig {
334    fn default() -> Self {
335        Self {
336            enabled: false,
337            bind: default_webhook_bind(),
338            port: default_webhook_port(),
339            hmac_secret_ref: String::new(),
340        }
341    }
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
345pub struct NoiseConfig {
346    pub pattern: String,
347}
348
349impl Default for NoiseConfig {
350    fn default() -> Self {
351        Self {
352            pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
353        }
354    }
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
358pub struct SocketTransportConfig {
359    pub enabled: bool,
360    pub bind: String, // "unix:///path" or "tcp://host:port" (P0b)
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub auth: Option<AuthConfig>,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
366pub struct AuthConfig {
367    pub scheme: String,
368    pub token_file: String,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
372pub struct CommunicationConfig {
373    #[serde(default = "default_accepts_all")]
374    pub accepts_from: Vec<String>,
375    #[serde(default)]
376    pub sends_to: Vec<String>,
377}
378fn default_accepts_all() -> Vec<String> {
379    vec!["*".to_string()]
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
383pub struct Entitlements {
384    pub network: NetworkEntitlement,
385    pub filesystem: FilesystemEntitlement,
386    pub processes: ProcessesEntitlement,
387    #[serde(default)]
388    pub syscalls: SyscallsEntitlement,
389    #[serde(default)]
390    pub limits: LimitsEntitlement,
391    /// LLM call permission. Default = Allowed (back-compat). Bridges set to Off
392    /// so the supervisor refuses to construct an LLM client.
393    #[serde(default)]
394    pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
398pub struct NetworkEntitlement {
399    pub inbound: InboundNetwork,
400    pub outbound: OutboundNetwork,
401}
402
403#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
404pub struct InboundNetwork {
405    #[serde(default)]
406    pub ports: Vec<u16>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
410pub struct OutboundNetwork {
411    pub mode: NetworkOutboundMode,
412    #[serde(default)]
413    pub allow_hosts: Vec<String>,
414    #[serde(default = "default_protocols")]
415    pub protocols: Vec<String>,
416    #[serde(default)]
417    pub resolve_dns: ResolveDnsConfig,
418}
419fn default_protocols() -> Vec<String> {
420    vec!["tcp".to_string()]
421}
422
423#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
424#[serde(rename_all = "lowercase")]
425pub enum NetworkOutboundMode {
426    Unrestricted,
427    Restricted,
428    Off,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
432pub struct ResolveDnsConfig {
433    #[serde(default = "default_dns_mode")]
434    pub mode: String,
435    #[serde(default)]
436    pub servers: Vec<String>,
437}
438impl Default for ResolveDnsConfig {
439    fn default() -> Self {
440        Self {
441            mode: default_dns_mode(),
442            servers: vec![],
443        }
444    }
445}
446fn default_dns_mode() -> String {
447    "system".to_string()
448}
449
450#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
451pub struct FilesystemEntitlement {
452    #[serde(default)]
453    pub read: Vec<String>,
454    #[serde(default)]
455    pub write: Vec<String>,
456    #[serde(default)]
457    pub deny: Vec<String>,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
461pub struct ProcessesEntitlement {
462    pub spawn: SpawnEntitlement,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
466pub struct SpawnEntitlement {
467    pub mode: SpawnMode,
468    #[serde(default)]
469    pub allowed: Vec<String>,
470}
471
472#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
473#[serde(rename_all = "lowercase")]
474pub enum SpawnMode {
475    Allowlist,
476    Any,
477    None,
478}
479
480#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
481pub struct SyscallsEntitlement {
482    #[serde(default = "default_syscalls_mode")]
483    pub mode: String,
484    #[serde(default)]
485    pub extra_deny: Vec<String>,
486}
487fn default_syscalls_mode() -> String {
488    "default".to_string()
489}
490
491#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
492pub struct LimitsEntitlement {
493    #[serde(default)]
494    pub cpu_seconds: Option<u64>,
495    #[serde(default = "default_memory_mb")]
496    pub memory_mb: u64,
497    #[serde(default = "default_fds")]
498    pub file_descriptors: u32,
499    #[serde(default = "default_procs")]
500    pub processes: u32,
501}
502fn default_memory_mb() -> u64 {
503    512
504}
505fn default_fds() -> u32 {
506    1024
507}
508fn default_procs() -> u32 {
509    32
510}
511
512#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
513pub struct NotificationsConfig {
514    #[serde(default)]
515    pub on_task_complete: Vec<NotificationTarget>,
516    #[serde(default)]
517    pub on_error: Vec<NotificationTarget>,
518    #[serde(default)]
519    pub on_shutdown: Vec<NotificationTarget>,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
523#[serde(tag = "target", rename_all = "lowercase")]
524pub enum NotificationTarget {
525    Agent {
526        name: String,
527    },
528    Commander,
529    Email {
530        address: String,
531        #[serde(default)]
532        smtp_config_file: Option<String>,
533    },
534    Slack {
535        #[serde(default)]
536        channel: Option<String>,
537        #[serde(default)]
538        webhook_url_env: Option<String>,
539    },
540    Webpush {
541        url: String,
542    },
543    Webhook {
544        url: String,
545        #[serde(default = "default_post")]
546        method: String,
547        #[serde(default)]
548        auth: Option<String>,
549    },
550}
551fn default_post() -> String {
552    "POST".to_string()
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
556pub struct RetryConfig {
557    pub llm: RetryPolicy,
558    pub tool: RetryPolicy,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
562pub struct RetryPolicy {
563    pub max_retries: u32,
564    pub backoff: BackoffStrategy,
565    pub initial_delay_ms: u64,
566    #[serde(default)]
567    pub max_delay_ms: Option<u64>,
568    #[serde(default)]
569    pub retry_on: Vec<String>,
570}
571
572#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
573#[serde(rename_all = "lowercase")]
574pub enum BackoffStrategy {
575    Linear,
576    Exponential,
577    Fixed,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
581pub struct LifecycleConfig {
582    pub restart: RestartPolicy,
583    #[serde(default = "default_max_restarts")]
584    pub max_restarts: u32,
585    #[serde(default = "default_window")]
586    pub restart_window_secs: u64,
587    #[serde(default = "default_stop_timeout")]
588    pub stop_timeout_secs: u64,
589    #[serde(default = "default_mcp_required")]
590    pub mcp_required: bool,
591    #[serde(default)]
592    pub execution: ExecutionMode,
593    #[serde(default)]
594    pub schedule: Vec<ScheduleEntry>,
595    #[serde(default)]
596    pub idle_triggers: Vec<IdleTrigger>,
597}
598fn default_max_restarts() -> u32 {
599    3
600}
601fn default_window() -> u64 {
602    600
603}
604fn default_stop_timeout() -> u64 {
605    15
606}
607fn default_mcp_required() -> bool {
608    true
609}
610
611#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
612#[serde(rename_all = "snake_case")]
613pub enum RestartPolicy {
614    Never,
615    OnFailure,
616    Always,
617}
618
619#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
620#[serde(rename_all = "snake_case")]
621pub enum ExecutionMode {
622    #[default]
623    Daemon,
624    OnDemand,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
628pub struct ScheduleEntry {
629    pub cron: String,
630    pub message: String,
631    #[serde(default, skip_serializing_if = "Option::is_none")]
632    pub sends_to: Option<String>,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
636pub struct IdleTrigger {
637    /// Idle threshold in seconds. Fires when (now - last_activity) >= after_secs.
638    pub after_secs: u64,
639    /// Message body injected into the task runner when this trigger fires.
640    pub message: String,
641    /// Optional A2A peer to route the resulting reply to. None means the agent itself.
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub sends_to: Option<String>,
644    /// Per-trigger refire cooldown in seconds. Prevents tight loops when the
645    /// idle threshold is short and the runner finishes quickly. Default 600.
646    #[serde(default = "default_idle_cooldown")]
647    pub cooldown_secs: u64,
648    /// When true, suppress firing during the agent's quiet-hours window.
649    /// Default true — idle pings should not wake the user at 3 a.m.
650    #[serde(default = "default_true")]
651    pub respect_quiet_hours: bool,
652}
653
654fn default_idle_cooldown() -> u64 {
655    600
656}
657fn default_true() -> bool {
658    true
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
662pub struct FileTransferConfig {
663    #[serde(default = "default_accept_max")]
664    pub accept_incoming_file_max_bytes: u64,
665    #[serde(default = "default_accept_total")]
666    pub accept_incoming_total_per_hour: u64,
667    #[serde(default = "default_approval_threshold")]
668    pub require_approval_above_bytes: u64,
669    #[serde(default = "default_reject_paths")]
670    pub reject_paths: Vec<String>,
671    #[serde(default = "default_allowed_mime")]
672    pub allowed_mime_types: Vec<String>,
673}
674
675impl Default for FileTransferConfig {
676    fn default() -> Self {
677        Self {
678            accept_incoming_file_max_bytes: default_accept_max(),
679            accept_incoming_total_per_hour: default_accept_total(),
680            require_approval_above_bytes: default_approval_threshold(),
681            reject_paths: default_reject_paths(),
682            allowed_mime_types: default_allowed_mime(),
683        }
684    }
685}
686
687fn default_accept_max() -> u64 {
688    10_485_760
689}
690fn default_accept_total() -> u64 {
691    104_857_600
692}
693fn default_approval_threshold() -> u64 {
694    10_485_760
695}
696fn default_reject_paths() -> Vec<String> {
697    vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
698}
699fn default_allowed_mime() -> Vec<String> {
700    vec!["*".into()]
701}
702
703#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
704#[serde(rename_all = "snake_case")]
705pub enum DeploymentType {
706    #[default]
707    Laptop,
708    Vm,
709    Docker,
710    K8s,
711    Lambda,
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
715pub struct DeploymentConfig {
716    #[serde(rename = "type", default)]
717    pub deployment_type: DeploymentType,
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub region: Option<String>,
720    #[serde(default = "default_env")]
721    pub environment: Option<String>,
722}
723
724impl Default for DeploymentConfig {
725    fn default() -> Self {
726        Self {
727            deployment_type: DeploymentType::default(),
728            region: None,
729            environment: default_env(),
730        }
731    }
732}
733
734fn default_env() -> Option<String> {
735    Some("dev".into())
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
739pub struct LockFile {
740    pub schema: u32,
741    pub uuid: String,
742    pub name: String,
743    pub pid: u32,
744    pub ppid: u32,
745    pub started_at: String,
746    pub binary_version: String,
747    pub transports: LockTransports,
748    pub card_digest: String,
749    pub capabilities: Vec<String>,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
753pub struct LockTransports {
754    pub stdio: bool,
755    #[serde(default)]
756    pub unix_socket: Option<String>,
757    #[serde(default)]
758    pub tcp: Option<String>,
759    /// C5 / M5.3 — webhook listener URL (e.g. `http://127.0.0.1:6789`).
760    /// Populated by the supervisor when `transport.webhook.enabled =
761    /// true` so peers and the commander can discover the live
762    /// endpoint without re-reading `profile.yaml`.
763    #[serde(default)]
764    pub webhook: Option<String>,
765}
766
767// ──────────────────────────────────────────────────────────────────────────
768// Voice I/O configuration (D1 — Kokoro 82M TTS + whisper.cpp STT)
769// ──────────────────────────────────────────────────────────────────────────
770
771/// Kokoro 82M voice identity. Maps to the per-voice style vector
772/// embedded in the Kokoro ONNX model.
773#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
774#[serde(rename_all = "snake_case")]
775pub enum VoiceId {
776    /// Default: Kokoro af_heart voice.
777    #[default]
778    AfHeart,
779    AfBella,
780    AfNicole,
781    AmAdam,
782    AmMichael,
783}
784
785impl VoiceId {
786    /// Index into the Kokoro voices.bin style matrix (row index).
787    pub fn style_index(&self) -> usize {
788        match self {
789            VoiceId::AfHeart => 0,
790            VoiceId::AfBella => 1,
791            VoiceId::AfNicole => 2,
792            VoiceId::AmAdam => 3,
793            VoiceId::AmMichael => 4,
794        }
795    }
796
797    /// Canonical lowercase string representation (matches `FromStr` inputs).
798    pub fn as_str(&self) -> &'static str {
799        match self {
800            VoiceId::AfHeart => "af_heart",
801            VoiceId::AfBella => "af_bella",
802            VoiceId::AfNicole => "af_nicole",
803            VoiceId::AmAdam => "am_adam",
804            VoiceId::AmMichael => "am_michael",
805        }
806    }
807}
808
809impl std::str::FromStr for VoiceId {
810    type Err = anyhow::Error;
811
812    fn from_str(s: &str) -> anyhow::Result<Self> {
813        match s {
814            "af_heart" => Ok(VoiceId::AfHeart),
815            "af_bella" => Ok(VoiceId::AfBella),
816            "af_nicole" => Ok(VoiceId::AfNicole),
817            "am_adam" => Ok(VoiceId::AmAdam),
818            "am_michael" => Ok(VoiceId::AmMichael),
819            other => anyhow::bail!(
820                "unknown voice ID '{other}' \
821                 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
822            ),
823        }
824    }
825}
826
827/// Per-agent voice I/O configuration (D1).
828/// Default = disabled so existing profiles continue to load unchanged.
829#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
830pub struct VoiceConfig {
831    /// Whether TTS (Kokoro) + STT (whisper.cpp) are enabled.
832    #[serde(default)]
833    pub enabled: bool,
834    /// Kokoro voice identity for TTS output. Default: af_heart.
835    #[serde(default)]
836    pub voice_id: VoiceId,
837    /// Optional cpal input device name for mic capture.
838    /// None means the OS default input device.
839    #[serde(default, skip_serializing_if = "Option::is_none")]
840    pub input_device: Option<String>,
841}
842
843// ──────────────────────────────────────────────────────────────────────────
844// Companion subsystem (Phase 1.1+) — see
845// docs/superpowers/specs/2026-04-29-mur-companion-phase-1-1-design.md §3.1
846// ──────────────────────────────────────────────────────────────────────────
847
848#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
849pub struct CompanionConfig {
850    #[serde(default)]
851    pub enabled: bool,
852    #[serde(default = "default_locale")]
853    pub locale: String,
854    #[serde(default)]
855    pub relationship: Relationship,
856    #[serde(default)]
857    pub voice_overrides: VoiceOverrides,
858    #[serde(default)]
859    pub onboarding: OnboardingState,
860    #[serde(default)]
861    pub rhythm: RhythmConfig,
862    #[serde(default)]
863    pub proactive: ProactiveConfig,
864}
865
866/// Resolve a default BCP-47 locale from the `LANG` environment variable
867/// (e.g. `zh_TW.UTF-8` → `zh-TW`). Falls back to `en-US`.
868pub fn default_locale() -> String {
869    std::env::var("LANG")
870        .ok()
871        .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
872        .unwrap_or_else(|| "en-US".into())
873}
874
875#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
876pub struct VoiceOverrides {
877    #[serde(default, skip_serializing_if = "Option::is_none")]
878    pub name_for_user: Option<String>,
879    #[serde(default, skip_serializing_if = "Option::is_none")]
880    pub formality: Option<Formality>,
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub extra_instructions: Option<String>,
883}
884
885#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
886pub struct FirstMemory {
887    pub text: String,
888    pub established_at: chrono::DateTime<chrono::Utc>,
889}
890
891#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
892pub struct OnboardingState {
893    #[serde(default, skip_serializing_if = "Option::is_none")]
894    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
895    #[serde(default)]
896    pub version: u32,
897    #[serde(default, skip_serializing_if = "Option::is_none")]
898    pub agent_display_name: Option<String>,
899    #[serde(default, skip_serializing_if = "Option::is_none")]
900    pub first_memory: Option<FirstMemory>,
901}
902
903/// Phase 1.2 reservation. 1.1 keeps `enabled = false` (rhythm collection is
904/// out of 1.1 scope).
905#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
906pub struct RhythmConfig {
907    #[serde(default)]
908    pub enabled: bool,
909}
910
911#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
912pub struct ProactiveConfig {
913    #[serde(default)]
914    pub enabled: bool,
915    /// 1.1 reserves the field; 1.2 will write `now + 7d` at rhythm-enable.
916    #[serde(default, skip_serializing_if = "Option::is_none")]
917    pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub quiet_hours: Option<QuietHours>,
920    #[serde(default, skip_serializing_if = "Option::is_none")]
921    pub active_hours: Option<ActiveHours>,
922    #[serde(default = "default_daily_cap")]
923    pub daily_cap: u8,
924    #[serde(default = "default_channels")]
925    pub channels: Vec<String>,
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
928}
929
930impl Default for ProactiveConfig {
931    fn default() -> Self {
932        Self {
933            enabled: false,
934            learning_until: None,
935            quiet_hours: None,
936            active_hours: None,
937            daily_cap: default_daily_cap(),
938            channels: default_channels(),
939            paused_until: None,
940        }
941    }
942}
943
944fn default_daily_cap() -> u8 {
945    3
946}
947fn default_channels() -> Vec<String> {
948    vec!["stdout".into()]
949}
950
951#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
952pub struct QuietHours {
953    pub start: String,
954    pub end: String,
955}
956
957#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
958pub struct ActiveHours {
959    pub start: String,
960    pub end: String,
961}
962
963// ──────────────────────────────────────────────────────────────────────────
964// Hub companion appearance (M-h3)
965// ──────────────────────────────────────────────────────────────────────────
966
967#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
968pub struct AgentAppearance {
969    /// ID of the active style preset (e.g. "chiikawa", "default-blob").
970    #[serde(default = "default_style_preset")]
971    pub style_preset: String,
972    #[serde(default)]
973    pub behavior_preset: BehaviorPreset,
974    /// Required for the polaroid family; none for all others.
975    #[serde(default, skip_serializing_if = "Option::is_none")]
976    pub source_image_path: Option<std::path::PathBuf>,
977    /// Local dir where rendered .webp expression frames are stored.
978    #[serde(default = "default_expressions_dir")]
979    pub expressions_dir: std::path::PathBuf,
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
982    #[serde(default)]
983    pub render_status: RenderStatus,
984}
985
986fn default_style_preset() -> String {
987    "default-blob".into()
988}
989
990fn default_expressions_dir() -> std::path::PathBuf {
991    std::path::PathBuf::from("expressions")
992}
993
994impl Default for AgentAppearance {
995    fn default() -> Self {
996        Self {
997            style_preset: default_style_preset(),
998            behavior_preset: BehaviorPreset::Normal,
999            source_image_path: None,
1000            expressions_dir: default_expressions_dir(),
1001            last_rendered_at: None,
1002            render_status: RenderStatus::Pending,
1003        }
1004    }
1005}
1006
1007#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1008#[serde(rename_all = "snake_case")]
1009pub enum BehaviorPreset {
1010    Quiet,
1011    #[default]
1012    Normal,
1013    Lively,
1014}
1015
1016#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1017#[serde(tag = "status", rename_all = "snake_case")]
1018pub enum RenderStatus {
1019    #[default]
1020    Pending,
1021    Rendering {
1022        done: u8,
1023        total: u8,
1024    },
1025    Ready,
1026    Failed {
1027        reason: String,
1028    },
1029}
1030
1031// ──────────────────────────────────────────────────────────────────────────
1032// E6 — Agent Pattern Federation types
1033// ──────────────────────────────────────────────────────────────────────────
1034
1035/// When the agent pulls an updated pattern snapshot from the daemon.
1036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1037#[serde(rename_all = "kebab-case")]
1038pub enum SnapshotPolicy {
1039    #[default]
1040    PullOnStart,
1041    PullPeriodic,
1042    Manual,
1043}
1044
1045/// Filter criteria for the pattern snapshot written to the agent's patterns_cache.
1046#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1047pub struct PatternFilter {
1048    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1049    pub applies_in: Vec<String>,
1050    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1051    pub tier: Vec<String>,
1052    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1053    pub maturity: Vec<String>,
1054    #[serde(default)]
1055    pub importance_min: f64,
1056    #[serde(default = "default_max_snapshot_count")]
1057    pub max_count: usize,
1058    #[serde(default)]
1059    pub snapshot_policy: SnapshotPolicy,
1060}
1061
1062fn default_max_snapshot_count() -> usize {
1063    200
1064}
1065
1066impl Default for PatternFilter {
1067    fn default() -> Self {
1068        Self {
1069            applies_in: vec![],
1070            tier: vec![],
1071            maturity: vec![],
1072            importance_min: 0.0,
1073            max_count: 200,
1074            snapshot_policy: SnapshotPolicy::default(),
1075        }
1076    }
1077}
1078
1079/// Points to the knowledge-layer commit this agent's patterns_cache was built from.
1080#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1081pub struct SnapshotRef {
1082    pub knowledge_commit: String,
1083    pub taken_at: String,
1084    pub filter: PatternFilter,
1085}
1086
1087/// Federation configuration embedded in AgentProfile (E6).
1088#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1089pub struct FederationConfig {
1090    #[serde(default)]
1091    pub filter: PatternFilter,
1092    #[serde(default, skip_serializing_if = "Option::is_none")]
1093    pub snapshot_ref: Option<SnapshotRef>,
1094    #[serde(default)]
1095    pub evidence_flush_interval_minutes: u32,
1096}
1097
1098impl AgentProfile {
1099    /// Minimal valid profile for tests — no voice, no MCP, no skills.
1100    ///
1101    /// Available in all compilation modes so integration tests in
1102    /// dependent crates can call it (unlike `#[cfg(test)]` items which
1103    /// are invisible to downstream test binaries).
1104    #[doc(hidden)]
1105    pub fn default_for_tests() -> Self {
1106        serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1107            .expect("minimal profile fixture")
1108    }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114
1115    #[test]
1116    fn profile_round_trip_yaml() {
1117        let yaml = r#"
1118schema: 1
1119id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1120name: agent_a
1121display_name: "Price Hunter"
1122version: "0.1.0"
1123persona:
1124  category: research
1125  description: "Finds prices"
1126  traits: { tone: concise, risk: cautious, verbosity: low }
1127sys_prompt_file: "sys_prompt.md"
1128model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1129mcp_servers: []
1130skills: []
1131transport:
1132  stdio: true
1133  socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1134communication: { accepts_from: ["*"], sends_to: [] }
1135capabilities: ["a2a.message.send", "a2a.tasks"]
1136entitlements:
1137  network:
1138    inbound: { ports: [] }
1139    outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1140  filesystem: { read: [], write: [], deny: [] }
1141  processes: { spawn: { mode: allowlist, allowed: [] } }
1142  syscalls: { mode: default }
1143  limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1144notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1145retry:
1146  llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1147  tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1148lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1149created_at: "2026-04-22T10:00:00+08:00"
1150updated_at: "2026-04-22T10:00:00+08:00"
1151"#;
1152        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1153        assert_eq!(profile.name, "agent_a");
1154        assert_eq!(profile.persona.category, PersonaCategory::Research);
1155        assert_eq!(
1156            profile.entitlements.network.outbound.mode,
1157            NetworkOutboundMode::Restricted
1158        );
1159        let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1160        let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1161        assert_eq!(profile.id, round_tripped.id);
1162    }
1163}
1164
1165#[cfg(test)]
1166mod model_ref_tests {
1167    use super::*;
1168
1169    #[test]
1170    fn legacy_profile_without_model_ref_still_parses() {
1171        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1172        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1173        assert!(
1174            p.model_ref.is_none(),
1175            "legacy profile must not have model_ref"
1176        );
1177    }
1178
1179    #[test]
1180    fn round_trip_with_model_ref_preserves_field() {
1181        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1182        let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1183        p.model_ref = Some("anthropic_opus_4_7".into());
1184        let s = serde_yaml_ng::to_string(&p).unwrap();
1185        assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1186        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1187        assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1188    }
1189}
1190
1191/// GUI-facing reification of the companion's three-layer permission toggle.
1192///
1193/// On-disk schema doesn't change — this helper just maps between the
1194/// three independent booleans (`enabled`, `rhythm.enabled`,
1195/// `proactive.enabled`) and a single ordered tier. Use
1196/// [`ProactiveTier::from_config`] to read and [`ProactiveTier::apply`]
1197/// to write.
1198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1199#[serde(rename_all = "snake_case")]
1200pub enum ProactiveTier {
1201    Off,
1202    WarmOnly,
1203    WarmAndBehavior,
1204    All,
1205}
1206
1207impl ProactiveTier {
1208    pub fn from_config(c: &CompanionConfig) -> Self {
1209        match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1210            (false, _, _) => Self::Off,
1211            (true, false, false) => Self::WarmOnly,
1212            (true, true, false) => Self::WarmAndBehavior,
1213            (true, _, true) => Self::All,
1214        }
1215    }
1216
1217    pub fn apply(&self, c: &mut CompanionConfig) {
1218        match self {
1219            Self::Off => {
1220                c.enabled = false;
1221                c.rhythm.enabled = false;
1222                c.proactive.enabled = false;
1223            }
1224            Self::WarmOnly => {
1225                c.enabled = true;
1226                c.rhythm.enabled = false;
1227                c.proactive.enabled = false;
1228            }
1229            Self::WarmAndBehavior => {
1230                c.enabled = true;
1231                c.rhythm.enabled = true;
1232                c.proactive.enabled = false;
1233            }
1234            Self::All => {
1235                c.enabled = true;
1236                c.rhythm.enabled = true;
1237                c.proactive.enabled = true;
1238            }
1239        }
1240    }
1241}
1242
1243#[cfg(test)]
1244mod mcp_pin_tests {
1245    use super::*;
1246
1247    /// Pre-M9 profiles must continue to deserialize with the new
1248    /// optional fields absent. Round-trip: serialize back out and
1249    /// confirm the optional fields don't leak into the YAML.
1250    #[test]
1251    fn pre_m9_entry_roundtrips_without_pin_fields() {
1252        let yaml = r#"
1253name: weather
1254command: /opt/mcp/weather
1255args: ["--port", "0"]
1256"#;
1257        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1258        assert_eq!(entry.name, "weather");
1259        assert_eq!(entry.binary_sha256, None);
1260        assert_eq!(entry.description_hash, None);
1261        assert_eq!(entry.publisher, None);
1262        assert_eq!(entry.installed_at, None);
1263
1264        // skip_serializing_if = "Option::is_none" must keep the YAML
1265        // free of empty pin fields when the entry is pre-M9.
1266        let out = serde_yaml_ng::to_string(&entry).unwrap();
1267        assert!(!out.contains("binary_sha256"), "got {out}");
1268        assert!(!out.contains("description_hash"), "got {out}");
1269        assert!(!out.contains("publisher"), "got {out}");
1270        assert!(!out.contains("installed_at"), "got {out}");
1271    }
1272
1273    /// Full M9 entry with all fields set round-trips losslessly.
1274    #[test]
1275    fn full_m9_entry_roundtrips_all_fields() {
1276        let yaml = r#"
1277name: weather
1278command: /opt/mcp/weather
1279args: []
1280binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1281description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1282publisher:
1283  name: "@anthropic-mcp/weather"
1284  homepage: "https://github.com/anthropic-mcp/weather"
1285  registry_id: "@anthropic-mcp/weather@1.2.3"
1286installed_at: "2026-05-06T08:00:00Z"
1287"#;
1288        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1289        assert!(
1290            entry
1291                .binary_sha256
1292                .as_deref()
1293                .unwrap()
1294                .starts_with("3f4abca8")
1295        );
1296        assert!(
1297            entry
1298                .description_hash
1299                .as_deref()
1300                .unwrap()
1301                .starts_with("9a01b2c3")
1302        );
1303        let pub_info = entry.publisher.clone().unwrap();
1304        assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1305        assert_eq!(
1306            pub_info.homepage.as_deref(),
1307            Some("https://github.com/anthropic-mcp/weather"),
1308        );
1309        assert_eq!(
1310            pub_info.registry_id.as_deref(),
1311            Some("@anthropic-mcp/weather@1.2.3"),
1312        );
1313        let installed = entry.installed_at.unwrap();
1314        assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1315    }
1316
1317    /// Partial — only the binary hash is set (e.g. probe failed but
1318    /// install proceeded). The supervisor still needs to be able to
1319    /// deserialize this without panicking.
1320    #[test]
1321    fn partial_pin_only_binary_sha_roundtrips() {
1322        let yaml = r#"
1323name: weather
1324command: /opt/mcp/weather
1325args: []
1326binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1327"#;
1328        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1329        assert_eq!(
1330            entry.binary_sha256.as_deref(),
1331            Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1332        );
1333        assert_eq!(entry.description_hash, None);
1334        assert_eq!(entry.publisher, None);
1335    }
1336
1337    /// Publisher with only the required `name` field — homepage and
1338    /// registry_id are optional.
1339    #[test]
1340    fn publisher_minimal_just_name() {
1341        let yaml = r#"
1342name: weather
1343command: /opt/mcp/weather
1344args: []
1345publisher:
1346  name: "alice"
1347"#;
1348        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1349        let p = entry.publisher.as_ref().unwrap();
1350        assert_eq!(p.name, "alice");
1351        assert_eq!(p.homepage, None);
1352        assert_eq!(p.registry_id, None);
1353
1354        // skip_serializing_if must omit the optional sub-fields too.
1355        let out = serde_yaml_ng::to_string(&entry).unwrap();
1356        assert!(!out.contains("homepage:"), "got {out}");
1357        assert!(!out.contains("registry_id:"), "got {out}");
1358    }
1359}
1360
1361#[cfg(test)]
1362mod voice_tests {
1363    use super::*;
1364    use std::str::FromStr;
1365
1366    #[test]
1367    fn voice_config_round_trips() {
1368        // Base: use the canonical minimal fixture and append a voice: block.
1369        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1370        let yaml = format!("{base}voice:\n  enabled: true\n  voice_id: af_bella\n");
1371
1372        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1373        assert!(profile.voice.enabled);
1374        assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1375
1376        // Legacy profiles (no voice: block) must still load.
1377        let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1378        assert!(!legacy.voice.enabled);
1379        assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1380    }
1381
1382    #[test]
1383    fn voice_id_from_str_roundtrips() {
1384        let cases = [
1385            ("af_heart", VoiceId::AfHeart),
1386            ("af_bella", VoiceId::AfBella),
1387            ("af_nicole", VoiceId::AfNicole),
1388            ("am_adam", VoiceId::AmAdam),
1389            ("am_michael", VoiceId::AmMichael),
1390        ];
1391        for (s, expected) in cases {
1392            assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1393            assert_eq!(expected.as_str(), s);
1394        }
1395    }
1396
1397    #[test]
1398    fn voice_id_from_str_rejects_unknown() {
1399        assert!(VoiceId::from_str("bogus").is_err());
1400    }
1401}
1402
1403#[cfg(test)]
1404mod idle_trigger_tests {
1405    use super::*;
1406
1407    #[test]
1408    fn idle_trigger_yaml_round_trip() {
1409        let yaml = r#"
1410restart: on_failure
1411idle_triggers:
1412  - after_secs: 3600
1413    message: "still there?"
1414    sends_to: other_agent
1415    cooldown_secs: 1800
1416    respect_quiet_hours: true
1417"#;
1418        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1419        assert_eq!(cfg.idle_triggers.len(), 1);
1420        assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1421        assert_eq!(cfg.idle_triggers[0].message, "still there?");
1422        assert_eq!(
1423            cfg.idle_triggers[0].sends_to.as_deref(),
1424            Some("other_agent")
1425        );
1426        assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1427        assert!(cfg.idle_triggers[0].respect_quiet_hours);
1428    }
1429
1430    #[test]
1431    fn idle_trigger_defaults_when_omitted() {
1432        let yaml = "restart: on_failure\n";
1433        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1434        assert!(cfg.idle_triggers.is_empty());
1435    }
1436}
1437
1438#[cfg(test)]
1439mod appearance_tests {
1440    use super::*;
1441
1442    #[test]
1443    fn appearance_default_style_preset_is_default_blob() {
1444        assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1445    }
1446
1447    #[test]
1448    fn appearance_default_behavior_is_normal() {
1449        assert_eq!(
1450            AgentAppearance::default().behavior_preset,
1451            BehaviorPreset::Normal
1452        );
1453    }
1454
1455    #[test]
1456    fn appearance_default_render_status_is_pending() {
1457        assert_eq!(
1458            AgentAppearance::default().render_status,
1459            RenderStatus::Pending
1460        );
1461    }
1462
1463    #[test]
1464    fn render_status_serde_round_trip() {
1465        let cases = [
1466            RenderStatus::Pending,
1467            RenderStatus::Rendering { done: 3, total: 12 },
1468            RenderStatus::Ready,
1469            RenderStatus::Failed {
1470                reason: "out of quota".into(),
1471            },
1472        ];
1473        for status in cases {
1474            let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1475            let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1476            assert_eq!(status, back);
1477        }
1478    }
1479
1480    #[test]
1481    fn agent_profile_with_appearance_round_trips() {
1482        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1483        let yaml = format!(
1484            "{base}appearance:\n  style_preset: chiikawa\n  render_status:\n    status: ready\n"
1485        );
1486        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1487        assert_eq!(profile.appearance.style_preset, "chiikawa");
1488        assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1489
1490        let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1491        let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1492        assert_eq!(profile.appearance, back.appearance);
1493    }
1494
1495    #[test]
1496    fn legacy_profile_without_appearance_uses_default() {
1497        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1498        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1499        assert_eq!(profile.appearance.style_preset, "default-blob");
1500        assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1501        assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1502    }
1503
1504    #[test]
1505    fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1506        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1507        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1508        assert!(p.file_actions.is_empty());
1509        assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1510        assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1511    }
1512}
1513
1514#[cfg(test)]
1515mod federation_tests {
1516    use super::*;
1517
1518    #[test]
1519    fn test_pattern_filter_default() {
1520        let f = PatternFilter::default();
1521        assert_eq!(f.max_count, 200);
1522        assert_eq!(f.importance_min, 0.0);
1523        assert!(f.tier.is_empty());
1524    }
1525
1526    #[test]
1527    fn test_federation_config_roundtrip() {
1528        let cfg = FederationConfig {
1529            filter: PatternFilter {
1530                tier: vec!["core".into()],
1531                max_count: 50,
1532                ..Default::default()
1533            },
1534            snapshot_ref: Some(SnapshotRef {
1535                knowledge_commit: "abc123def456".into(),
1536                taken_at: "2026-05-19T00:00:00Z".into(),
1537                filter: PatternFilter::default(),
1538            }),
1539            evidence_flush_interval_minutes: 15,
1540        };
1541        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1542        let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1543        assert_eq!(cfg, back);
1544    }
1545
1546    #[test]
1547    fn test_agent_profile_federation_defaults() {
1548        // AgentProfile without a federation block deserializes with FederationConfig::default().
1549        // Use the minimal YAML that passes validation — just the required fields.
1550        // (We check only that the field has its zero value, not full profile parse.)
1551        let cfg = FederationConfig::default();
1552        assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1553        assert!(cfg.snapshot_ref.is_none());
1554    }
1555}
1556
1557#[cfg(test)]
1558mod skill_card_tests {
1559    use super::*;
1560
1561    #[test]
1562    fn installed_skills_default_to_empty_when_absent() {
1563        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1564        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1565        assert!(p.installed_skills.is_empty());
1566    }
1567
1568    #[test]
1569    fn installed_skills_roundtrip_preserves_entries() {
1570        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1571        let yaml = format!(
1572            "{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"
1573        );
1574        let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
1575        assert_eq!(p.installed_skills.len(), 1);
1576        assert_eq!(p.installed_skills[0].name, "s1");
1577        assert_eq!(p.installed_skills[0].abstract_text, "does things");
1578        assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
1579
1580        let out = serde_yaml_ng::to_string(&p).unwrap();
1581        assert!(out.contains("abstract: does things"));
1582        assert!(out.contains("pattern: /find"));
1583
1584        let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
1585        assert_eq!(p.installed_skills, back.installed_skills);
1586    }
1587
1588    #[test]
1589    fn installed_skills_minimal_entry_serializes_compactly() {
1590        // A name-only entry must NOT emit empty string fields.
1591        let entry = SkillCardEntry {
1592            name: "minimal".into(),
1593            ..Default::default()
1594        };
1595        let yaml = serde_yaml_ng::to_string(&entry).unwrap();
1596        assert!(yaml.contains("name: minimal"));
1597        assert!(
1598            !yaml.contains("version:"),
1599            "empty version must be skipped: {yaml}"
1600        );
1601        assert!(
1602            !yaml.contains("publisher:"),
1603            "empty publisher must be skipped: {yaml}"
1604        );
1605        assert!(
1606            !yaml.contains("abstract:"),
1607            "empty abstract must be skipped: {yaml}"
1608        );
1609    }
1610}