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