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