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