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