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 crate::deps::ProgramDep;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9/// Skill metadata broadcast in the Agent Card (Layer 1 + Layer 2).
10///
11/// Populated by `mur skill install` (registry or agent:// URL). Distinct from
12/// `AgentProfile.skills`, which is the legacy per-agent-path list managed by
13/// `mur agent skill add`.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
15pub struct SkillCardEntry {
16    pub name: String,
17    #[serde(default, skip_serializing_if = "String::is_empty")]
18    pub version: String,
19    #[serde(default, skip_serializing_if = "String::is_empty")]
20    pub publisher: String,
21    #[serde(default, skip_serializing_if = "String::is_empty")]
22    pub description: String,
23    #[serde(default, skip_serializing_if = "String::is_empty")]
24    pub category: String,
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub tags: Vec<String>,
27    #[serde(default, skip_serializing_if = "Vec::is_empty")]
28    pub triggers: Vec<SkillCardTrigger>,
29    /// Layer 2 abstract — injected at session start (~200 tokens).
30    /// On-disk YAML key is `abstract` (a Rust reserved word).
31    #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
32    pub abstract_text: String,
33    /// Provenance chain copied from the installed manifest. Empty for
34    /// registry-installed skills.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub transfer_chain: Vec<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
40pub struct SkillCardTrigger {
41    #[serde(rename = "type")]
42    pub kind: String,
43    #[serde(default, skip_serializing_if = "String::is_empty")]
44    pub pattern: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct AgentProfile {
49    pub schema: u32,
50    pub id: String, // UUIDv7
51    pub name: String,
52    pub display_name: String,
53    /// Coarse human-facing role for grouping/filtering (e.g. "Engineer").
54    /// A free label, not a registry — bundled defaults are UI suggestions and
55    /// users can type their own. Also the SOFT signal in the dispatch index
56    /// (`agent_facts`), where it explains and ranks candidates but never
57    /// filters them: what an agent may actually do is decided by
58    /// `entitlements`, which the kernel enforces and a stale label cannot
59    /// overstate.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub role: Option<String>,
62    /// How hard this agent's model should work per turn
63    /// (`low`/`medium`/`high`/`xhigh`/`max`). `None` leaves the field off,
64    /// which is the API default (`high`) — not "no effort".
65    ///
66    /// Set it where the agent's JOB is known: a single-purpose build
67    /// specialist earns `xhigh`, a fan-out research worker `medium`, a
68    /// classifier `low`. Narrowed to what the resolved model accepts at the
69    /// client boundary, so an agent pinned to an older model degrades rather
70    /// than 400s.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub effort: Option<crate::llm::Effort>,
73    pub version: String,
74    pub persona: Persona,
75    pub sys_prompt_file: String,
76    pub model: ModelConfig,
77    /// Optional pointer into ~/.mur/models.yaml. When set, the runtime
78    /// prefers the registry entry over the inline `model:` block.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub model_ref: Option<String>,
81    /// Per-agent fallback chain (ordered model_refs). Overrides the global
82    /// `models.fallback_chain` when non-empty. See the model-switch spec.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub fallback_chain: Vec<String>,
85    /// Per-agent difficulty-routing override. Inherits the global
86    /// `models.routing` when `None`.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub routing: Option<crate::config::RoutingConfig>,
89    #[serde(default)]
90    pub mcp_servers: Vec<McpServerEntry>,
91    #[serde(default)]
92    pub skills: Vec<String>,
93    /// Skills installed via `mur skill install`. Distinct from `skills`
94    /// (which holds legacy per-agent paths from `mur agent skill add`).
95    /// Broadcast in the Agent Card alongside `skills`.
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub installed_skills: Vec<SkillCardEntry>,
98    /// Per-agent skill denylist (add-on Phase 1). Skill names that are
99    /// installed/visible to this agent but suppressed from injection.
100    /// Non-destructive: the skill's files/stats are untouched. Empty = all
101    /// visible skills enabled (back-compat: absent in old profiles).
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub disabled_skills: Vec<String>,
104
105    /// Per-agent MCP denylist (add-on Phase 1). `McpServerEntry` names not
106    /// spawned for this agent. Non-destructive: the entry + its pin stay in
107    /// the profile. Empty = all configured servers enabled.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub disabled_mcp: Vec<String>,
110    /// Plugin-groups imported by this agent (add-on Phase 2). Each is
111    /// self-contained (members installed per-agent). Absent/empty in
112    /// legacy profiles (back-compat).
113    #[serde(default, skip_serializing_if = "Vec::is_empty")]
114    pub addons: Vec<AddonRef>,
115    pub transport: TransportConfig,
116    pub communication: CommunicationConfig,
117    #[serde(default)]
118    pub capabilities: Vec<String>,
119    pub entitlements: Entitlements,
120    #[serde(default)]
121    pub notifications: NotificationsConfig,
122    pub retry: RetryConfig,
123    pub lifecycle: LifecycleConfig,
124    /// Cryptographic identity for cross-host A2A (P0a.5+). Default = empty
125    /// (legacy P0a profiles continue to load without this block).
126    #[serde(default)]
127    pub identity: IdentityConfig,
128    #[serde(default)]
129    pub file_transfer: FileTransferConfig,
130    #[serde(default)]
131    pub deployment: DeploymentConfig,
132    /// Companion subsystem (Phase 1.1+). Default = disabled (legacy profiles
133    /// continue to load without this block).
134    #[serde(default)]
135    pub companion: CompanionConfig,
136    /// Human-in-the-loop configuration (Phase 2). Default = disabled.
137    #[serde(default)]
138    pub hitl: HitlConfig,
139    /// Voice I/O configuration (D1). Default = disabled.
140    #[serde(default)]
141    pub voice: VoiceConfig,
142    /// A1: config-driven handler picker. Absent block = all defaults.
143    #[serde(default)]
144    pub hooks: crate::HooksConfig,
145    /// Pubkeys of bridges (and other LLM-less peers) this agent will accept
146    /// signed envelopes from. Empty = accept no bridge traffic. Default = empty.
147    #[serde(default)]
148    pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
149    pub created_at: String,
150    pub updated_at: String,
151    /// Hub companion visual identity (M-h3). Default = default-blob / Normal / Pending.
152    #[serde(default)]
153    pub appearance: AgentAppearance,
154    /// E6: Pattern federation — snapshot filter + outbox config.
155    #[serde(default)]
156    pub federation: FederationConfig,
157
158    /// A1: declarative UI action list — file_actions rendered as action
159    /// buttons in the pending-item selection UI. New top-level key; NOT
160    /// nested under `capabilities:`.
161    #[serde(default)]
162    pub file_actions: Vec<crate::action::FileAction>,
163
164    /// A2 + A3: action pipeline configuration (deletion safety + queue limits).
165    #[serde(default)]
166    pub action_pipeline: crate::action::ActionPipelineConfig,
167
168    /// External programs this artifact needs at runtime (portable-deps spec).
169    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub requires_programs: Vec<ProgramDep>,
172
173    /// Capability refs installed into this agent (Pack S3). Absent → empty;
174    /// resolved against the local capability registry / bundle store.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub requires_capabilities: Vec<String>,
177}
178
179fn default_algorithm() -> String {
180    "ed25519".into()
181}
182
183/// Algorithms the runtime can generate + verify.
184pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
187pub struct IdentityConfig {
188    /// Multibase-encoded Ed25519 public key (base58btc, `z` prefix).
189    /// Empty string for legacy P0a profiles; filled on P0a.5 `mur agent create`.
190    #[serde(default)]
191    pub pubkey: String,
192    /// Free-form owner identity (email / SSO sub). None for legacy profiles.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub owner: Option<String>,
195
196    // P0a.6 rekey extensions (all #[serde(default)] — back-compat)
197    /// Cryptographic algorithm for this key. Defaults to "ed25519".
198    #[serde(default = "default_algorithm")]
199    pub algorithm: String,
200    /// Monotonic version counter; 0 = initial create, increments on each rotation.
201    #[serde(default)]
202    pub key_version: u32,
203    /// RFC3339 timestamp of when this key was created.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub created_at_key: Option<String>,
206    /// Previous public key (before most recent rotation). None if not rotated yet.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub previous_pubkey: Option<String>,
209    /// Version of the previous key. None if not rotated yet.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub previous_key_version: Option<u32>,
212    /// RFC3339 timestamp when grace period expires and old key is fully retired.
213    /// Only set during rotation; cleared once grace period ends.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub grace_expires_at: Option<String>,
216    /// RFC3339 timestamp of the most recent key rotation (normal, not emergency).
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub rotated_at: Option<String>,
219    /// RFC3339 timestamp of emergency key rotation (set only if emergency rekey occurred).
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub emergency_rekey_at: Option<String>,
222}
223
224impl Default for IdentityConfig {
225    fn default() -> Self {
226        Self {
227            pubkey: String::new(),
228            owner: None,
229            algorithm: default_algorithm(),
230            key_version: 0,
231            created_at_key: None,
232            previous_pubkey: None,
233            previous_key_version: None,
234            grace_expires_at: None,
235            rotated_at: None,
236            emergency_rekey_at: None,
237        }
238    }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
242pub struct Persona {
243    pub category: PersonaCategory,
244    pub description: String,
245    pub traits: PersonaTraits,
246}
247
248#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
249#[serde(rename_all = "lowercase")]
250pub enum PersonaCategory {
251    Research,
252    Automation,
253    Monitor,
254    Notify,
255    Commerce,
256    Custom,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
260pub struct PersonaTraits {
261    pub tone: String,
262    pub risk: String,
263    pub verbosity: String,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267pub struct ModelConfig {
268    pub provider: String,
269    pub name: String,
270    #[serde(default)]
271    pub params: BTreeMap<String, serde_yaml_ng::Value>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
275pub struct McpServerEntry {
276    pub name: String,
277    pub command: String,
278    #[serde(default)]
279    pub args: Vec<String>,
280
281    /// SHA-256 (hex, lowercase) of the binary at `command`'s resolved
282    /// path, captured at install time. `None` means the entry was
283    /// added before B0 M9.1 (back-compat) and rule-6 enforcement is
284    /// not applied — the supervisor will warn but not block.
285    /// (B0 rule 6 / M9.1)
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub binary_sha256: Option<String>,
288
289    /// SHA-256 (hex, lowercase) of the canonical-JSON of the MCP's
290    /// `tools/list` response, captured at install time. `None` means
291    /// the install path skipped the description probe (e.g. the MCP
292    /// uses a non-stdio transport or the binary couldn't be reached)
293    /// or the entry pre-dates M9. (B0 rule 6 / M9.1)
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub description_hash: Option<String>,
296
297    /// Display-only publisher metadata captured at install time so
298    /// the user can recall what they consented to. `None` for older
299    /// entries. (B0 rule 6 / M9.1)
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub publisher: Option<McpPublisherInfo>,
302
303    /// RFC3339 timestamp of when the entry was added or last
304    /// re-approved by the user via `mur agent mcp pin`. Used by the
305    /// rug-pull dialog UX. `None` for older entries. (B0 rule 6 / M9.1)
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
308
309    /// Per-tool-call timeout for this server, in seconds. `None` uses the
310    /// runtime default. Slow tools (e.g. `video_analyze`: transcript fetch
311    /// + local-model map-reduce) need a longer budget than the default.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub timeout_secs: Option<u32>,
314
315    /// Per-server outbound egress override. `None` = inherit the agent-level
316    /// policy (default; unchanged behavior). `Restricted` routes this server's
317    /// child through the runtime egress proxy with `allow_hosts` (advisory).
318    /// See `docs/superpowers/plans/2026-06-26-mcp-per-server-egress.md`.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub network: Option<McpServerNetwork>,
321
322    /// HTTP(S) base URL for a remote (Streamable-HTTP or SSE) MCP server.
323    /// Mutually exclusive with `command` in practice; `None` = stdio transport.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub url: Option<String>,
326
327    /// Authentication credentials for a remote MCP server.
328    /// `None` = no auth (or stdio transport).
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub auth: Option<McpAuth>,
331
332    /// External programs this artifact needs at runtime (portable-deps spec).
333    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
334    #[serde(default, skip_serializing_if = "Vec::is_empty")]
335    pub requires_programs: Vec<ProgramDep>,
336}
337
338/// Authentication scheme for a remote (HTTP) MCP server.
339#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
340#[serde(rename_all = "snake_case", tag = "kind")]
341pub enum McpAuth {
342    /// Static bearer token stored as a secret reference.
343    Bearer { token: crate::secret::SecretRef },
344    /// OAuth 2.1 token, with dynamic client registration state.
345    Oauth(OauthAuth),
346}
347
348/// OAuth 2.1 state persisted alongside remote MCP entry.
349#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
350pub struct OauthAuth {
351    /// Authorization-server token endpoint (from discovery).
352    pub token_endpoint: String,
353    /// Client id from dynamic client registration.
354    pub client_id: String,
355    /// Keychain ref to access token.
356    pub access_token: crate::secret::SecretRef,
357    /// Keychain ref refresh token, if server issued one.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub refresh_token: Option<crate::secret::SecretRef>,
360    /// Unix-epoch seconds access token expires (0 = unknown).
361    #[serde(default)]
362    pub expires_at: u64,
363}
364
365/// How an MCP server's outbound network is scoped.
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
367#[serde(rename_all = "snake_case")]
368pub enum McpNetMode {
369    /// Inherit the agent-level outbound policy (today's behavior). No proxy.
370    #[default]
371    Inherit,
372    /// Allow only `allow_hosts`, routed through the runtime egress proxy.
373    Restricted,
374    /// Allow ALL hosts EXCEPT `deny_hosts`, routed through the runtime egress
375    /// proxy, with every CONNECT audited. For trusted-but-broad tools (e.g. a
376    /// web-research browser) that cannot enumerate their destinations. Requires
377    /// explicit operator consent (records `authorization`); downgraded to
378    /// `Inherit` on import (lowest trust). Advisory enforcement (see egress_proxy).
379    BroadAudited,
380    /// No outbound for this server at all.
381    Off,
382}
383
384/// Env var name a sandboxed MCP child reads to self-enforce the operator's
385/// `deny_hosts` overlay on connections the egress proxy cannot observe (e.g.
386/// `mur-research-gateway`'s tier-2/3 browser subprocesses — the proxy only
387/// sees tier-1 `reqwest` traffic). `mur-agent-runtime`'s `proxy_env_for` sets
388/// this on the child's env alongside the proxy vars; a cooperating child
389/// (currently `mur-research-gateway`, via `config::load`) reads it to source
390/// its own deny list. Single definition shared by both crates (CLAUDE.md
391/// rule 1: no duplicated literal).
392pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
393
394/// Per-MCP-server outbound egress policy.
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
396pub struct McpServerNetwork {
397    #[serde(default)]
398    pub mode: McpNetMode,
399    #[serde(default)]
400    pub allow_hosts: Vec<String>,
401    /// Deny overlay for `BroadAudited` mode: hosts blocked even though all
402    /// others are allowed. Ignored by `Restricted`/`Inherit`/`Off`.
403    #[serde(default)]
404    pub deny_hosts: Vec<String>,
405    /// Who authorized a `BroadAudited` grant, and when. `None` for other modes.
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub authorization: Option<EgressAuthorization>,
408}
409
410/// A plugin-group imported by one agent (add-on Phase 2). Self-contained:
411/// members are installed PER-AGENT (skills under
412/// `~/.mur/agents/<a>/skills/`, mcp appended to this profile's
413/// `mcp_servers`). No global library, no refcounting.
414///
415/// Fail-closed: `enabled` defaults to `false`. Only an explicit user
416/// toggle (CLI/Hub) or a trusted native installer flips it true — the
417/// importer always constructs it `false`.
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
419pub struct AddonRef {
420    /// e.g. "superpowers" (local) or "superpowers@claude-plugins-official".
421    pub id: String,
422    /// Provenance, free-text. e.g. "claude-local:superpowers@6.0.3".
423    pub source: String,
424    #[serde(default)]
425    pub enabled: bool,
426    #[serde(default, skip_serializing_if = "Vec::is_empty")]
427    pub skills: Vec<String>,
428    #[serde(default, skip_serializing_if = "Vec::is_empty")]
429    pub mcp: Vec<String>,
430    #[serde(default, skip_serializing_if = "Vec::is_empty")]
431    pub commands: Vec<String>,
432    /// Content-hash pin over the imported skill/command manifests, recorded
433    /// at import. `None` on legacy refs. Enables drift detection + refresh.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub content_hash: Option<String>,
436    /// The re-fetchable source (the original `import` argument: a local path
437    /// or `owner/repo`), distinct from the free-text provenance `source`.
438    /// `None` on legacy refs. Used by `reimport`.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    pub fetch_ref: Option<String>,
441    /// The `--plugin <name>` selector used at import time to pick one plugin
442    /// out of a multi-plugin marketplace `fetch_ref`. `None` when the source
443    /// was a single-plugin dir/repo, or on legacy refs. Used by `reimport` so
444    /// a marketplace add-on can be re-fetched without re-specifying it.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub fetch_plugin: Option<String>,
447}
448
449/// Display-only publisher metadata captured at install time. None of
450/// the fields are validated against any external authority — they're
451/// shown to the user during the install confirm prompt and reproduced
452/// in `mur agent mcp inspect` output so the user can audit who they
453/// thought they were trusting. (B0 rule 6 / M9.1)
454#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
455pub struct McpPublisherInfo {
456    /// Free-form publisher identifier — e.g. `"Anthropic"`,
457    /// `"@github-user-alice"`, or whatever `serverInfo.name` returned.
458    pub name: String,
459
460    /// Optional homepage / docs URL. Best-effort: extracted from the
461    /// MCP's `serverInfo.metadata.homepage` or registry entry when
462    /// available; otherwise left unset.
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub homepage: Option<String>,
465
466    /// Optional registry coordinate — e.g. `"@anthropic-mcp/weather@1.2.3"`.
467    /// Used purely for display; not consumed by any verification path.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub registry_id: Option<String>,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
473pub struct TransportConfig {
474    pub stdio: bool,
475    pub socket: SocketTransportConfig,
476    #[serde(default)]
477    pub tcp: TcpTransportConfig,
478    /// Track C5 — HTTP webhook receiver. Default off; enabling
479    /// requires an HMAC secret in the OS keychain (`SecretRef`).
480    /// See `docs/superpowers/specs/2026-05-05-mur-agent-c5-webhook-design.md`.
481    #[serde(default)]
482    pub webhook: WebhookTransportConfig,
483}
484
485#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
486pub struct TcpTransportConfig {
487    #[serde(default)]
488    pub enabled: bool,
489    #[serde(default)]
490    pub bind: String,
491    #[serde(default)]
492    pub noise: NoiseConfig,
493}
494
495/// HTTP webhook receiver — Track C5.
496///
497/// External systems POST `SharePayload`-shaped JSON to
498/// `http://<bind>:<port>/agents/<slug>/webhook` with an
499/// `X-Mur-Signature: sha256=<hex>` header carrying an HMAC-SHA256
500/// over the raw body. The HMAC secret is stored in the OS keychain
501/// via `SecretRef` (same pattern as Telegram bot tokens in C2);
502/// `hmac_secret_ref` is the `service:account` lookup key.
503///
504/// `bind` defaults to `127.0.0.1` so a fresh enable doesn't
505/// inadvertently expose the agent to the local network. Users who
506/// want VPN / Tailscale reachability override to `0.0.0.0` or the
507/// VPN interface address explicitly.
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
509pub struct WebhookTransportConfig {
510    #[serde(default)]
511    pub enabled: bool,
512    #[serde(default = "default_webhook_bind")]
513    pub bind: String,
514    #[serde(default = "default_webhook_port")]
515    pub port: u16,
516    /// `service:account` key into the OS keychain. Empty string
517    /// when `enabled = false`; required (and validated) at startup
518    /// when enabled.
519    #[serde(default)]
520    pub hmac_secret_ref: String,
521}
522
523fn default_webhook_bind() -> String {
524    "127.0.0.1".to_string()
525}
526
527fn default_webhook_port() -> u16 {
528    6789
529}
530
531impl Default for WebhookTransportConfig {
532    fn default() -> Self {
533        Self {
534            enabled: false,
535            bind: default_webhook_bind(),
536            port: default_webhook_port(),
537            hmac_secret_ref: String::new(),
538        }
539    }
540}
541
542#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
543pub struct NoiseConfig {
544    pub pattern: String,
545}
546
547impl Default for NoiseConfig {
548    fn default() -> Self {
549        Self {
550            pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
551        }
552    }
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
556pub struct SocketTransportConfig {
557    pub enabled: bool,
558    pub bind: String, // "unix:///path" or "tcp://host:port" (P0b)
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub auth: Option<AuthConfig>,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
564pub struct AuthConfig {
565    pub scheme: String,
566    pub token_file: String,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
570pub struct CommunicationConfig {
571    #[serde(default = "default_accepts_all")]
572    pub accepts_from: Vec<String>,
573    #[serde(default)]
574    pub sends_to: Vec<String>,
575}
576fn default_accepts_all() -> Vec<String> {
577    vec!["*".to_string()]
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
581pub struct Entitlements {
582    pub network: NetworkEntitlement,
583    pub filesystem: FilesystemEntitlement,
584    pub processes: ProcessesEntitlement,
585    #[serde(default)]
586    pub syscalls: SyscallsEntitlement,
587    #[serde(default)]
588    pub limits: LimitsEntitlement,
589    /// LLM call permission. Default = Allowed (back-compat). Bridges set to Off
590    /// so the supervisor refuses to construct an LLM client.
591    #[serde(default)]
592    pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
593    /// Per-tool allow/ask/deny policy. Empty = all tools use default (Ask).
594    #[serde(default, skip_serializing_if = "Vec::is_empty")]
595    pub tools: Vec<ToolRule>,
596    /// When `true` (the default), a sandbox apply failure is fatal: the agent
597    /// refuses to start rather than running advisory-only (unconfined).
598    /// Set to `false` only for development or trusted-workstation agents that
599    /// intentionally run without kernel sandbox enforcement.
600    #[serde(default = "default_true")]
601    pub fail_closed_on_sandbox_error: bool,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
605pub struct NetworkEntitlement {
606    pub inbound: InboundNetwork,
607    pub outbound: OutboundNetwork,
608}
609
610#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
611pub struct InboundNetwork {
612    #[serde(default)]
613    pub ports: Vec<u16>,
614}
615
616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
617pub struct OutboundNetwork {
618    pub mode: NetworkOutboundMode,
619    #[serde(default)]
620    pub allow_hosts: Vec<String>,
621    #[serde(default = "default_protocols")]
622    pub protocols: Vec<String>,
623    #[serde(default)]
624    pub resolve_dns: ResolveDnsConfig,
625}
626fn default_protocols() -> Vec<String> {
627    vec!["tcp".to_string()]
628}
629
630/// Record of who authorized a broad egress grant, and when. Attached to a
631/// per-MCP-server `McpServerNetwork` when its mode is `BroadAudited`, so the
632/// grant is persisted, portable, and re-approvable on import.
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
634pub struct EgressAuthorization {
635    pub authorized_by: String,
636    pub authorized_at_ms: u64,
637}
638
639#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
640#[serde(rename_all = "lowercase")]
641pub enum NetworkOutboundMode {
642    Unrestricted,
643    Restricted,
644    /// Deny all general outbound TCP; egress is ONLY via loopback proxies
645    /// (the agent's cc-proxy LLM port + the egress proxy). Hostnames are still
646    /// governed by `allow_hosts` (HostGuard) — unlike `Off`, which blocks all.
647    ProxyOnly,
648    Off,
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
652pub struct ResolveDnsConfig {
653    #[serde(default = "default_dns_mode")]
654    pub mode: String,
655    #[serde(default)]
656    pub servers: Vec<String>,
657}
658impl Default for ResolveDnsConfig {
659    fn default() -> Self {
660        Self {
661            mode: default_dns_mode(),
662            servers: vec![],
663        }
664    }
665}
666fn default_dns_mode() -> String {
667    "system".to_string()
668}
669
670#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
671pub struct FilesystemEntitlement {
672    #[serde(default)]
673    pub read: Vec<String>,
674    #[serde(default)]
675    pub write: Vec<String>,
676    #[serde(default)]
677    pub deny: Vec<String>,
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
681pub struct ProcessesEntitlement {
682    pub spawn: SpawnEntitlement,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
686pub struct SpawnEntitlement {
687    pub mode: SpawnMode,
688    #[serde(default)]
689    pub allowed: Vec<String>,
690}
691
692#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
693#[serde(rename_all = "lowercase")]
694pub enum SpawnMode {
695    Allowlist,
696    Any,
697    None,
698    /// Shell-only: fences the system exec paths (`/bin`, `/usr/bin`,
699    /// `/usr/lib`) that `Allowlist` mode exempts by default, so only the
700    /// resolved shell binary the `bash` tool itself spawns plus the
701    /// profile's own `spawn_allowed_paths`/`spawn_allowed_prefixes` may be
702    /// exec'd -- no other system binary (coreutils, `git`, etc.) is implied.
703    Strict,
704}
705
706#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
707pub struct SyscallsEntitlement {
708    #[serde(default = "default_syscalls_mode")]
709    pub mode: String,
710    #[serde(default)]
711    pub extra_deny: Vec<String>,
712}
713fn default_syscalls_mode() -> String {
714    "default".to_string()
715}
716
717#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
718pub struct LimitsEntitlement {
719    #[serde(default)]
720    pub cpu_seconds: Option<u64>,
721    #[serde(default = "default_memory_mb")]
722    pub memory_mb: u64,
723    #[serde(default = "default_fds")]
724    pub file_descriptors: u32,
725    #[serde(default = "default_procs")]
726    pub processes: u32,
727}
728fn default_memory_mb() -> u64 {
729    512
730}
731fn default_fds() -> u32 {
732    1024
733}
734fn default_procs() -> u32 {
735    32
736}
737
738#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
739#[serde(rename_all = "lowercase")]
740pub enum ToolPolicy {
741    Allow,
742    #[default]
743    Ask,
744    Deny,
745}
746
747#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
748pub struct ToolRule {
749    pub pattern: String,
750    pub policy: ToolPolicy,
751    /// Intrinsic risk tier of this tool (v3c). Resolved most-restrictive-wins
752    /// against per-step risk + channel policy; gates pre-execution when not Read.
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub risk: Option<crate::hitl::RiskTier>,
755}
756
757/// Resolve the effective policy for `tool_name` against an ordered rule list.
758///
759/// Precedence: exact-name match > longest-prefix glob (trailing `*`) > default (`Ask`).
760pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
761    resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
762}
763
764/// Like [`resolve_tool_policy`] but distinguishes "no rule matched" (`None`)
765/// from an explicit rule — for tools whose registration is already gated
766/// elsewhere (e.g. `fleet_run`'s config allowlist) and that therefore want a
767/// different default than `Ask` while still honoring explicit rules.
768pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
769    for rule in rules {
770        if rule.pattern == tool_name {
771            return Some(rule.policy);
772        }
773    }
774    let mut best: Option<(&ToolRule, usize)> = None;
775    for rule in rules {
776        if let Some(prefix) = rule.pattern.strip_suffix('*')
777            && tool_name.starts_with(prefix)
778        {
779            let len = prefix.len();
780            if best.is_none_or(|(_, best_len)| len > best_len) {
781                best = Some((rule, len));
782            }
783        }
784    }
785    best.map(|(rule, _)| rule.policy)
786}
787
788#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
789pub struct NotificationsConfig {
790    #[serde(default)]
791    pub on_task_complete: Vec<NotificationTarget>,
792    #[serde(default)]
793    pub on_error: Vec<NotificationTarget>,
794    #[serde(default)]
795    pub on_shutdown: Vec<NotificationTarget>,
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
799#[serde(tag = "target", rename_all = "lowercase")]
800pub enum NotificationTarget {
801    Agent {
802        name: String,
803    },
804    Commander,
805    Email {
806        address: String,
807        #[serde(default)]
808        smtp_config_file: Option<String>,
809    },
810    Slack {
811        #[serde(default)]
812        channel: Option<String>,
813        #[serde(default)]
814        webhook_url_env: Option<String>,
815    },
816    Webpush {
817        url: String,
818    },
819    Webhook {
820        url: String,
821        #[serde(default = "default_post")]
822        method: String,
823        #[serde(default)]
824        auth: Option<String>,
825    },
826}
827fn default_post() -> String {
828    "POST".to_string()
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
832pub struct RetryConfig {
833    pub llm: RetryPolicy,
834    pub tool: RetryPolicy,
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
838pub struct RetryPolicy {
839    pub max_retries: u32,
840    pub backoff: BackoffStrategy,
841    pub initial_delay_ms: u64,
842    #[serde(default)]
843    pub max_delay_ms: Option<u64>,
844    #[serde(default)]
845    pub retry_on: Vec<String>,
846}
847
848#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
849#[serde(rename_all = "lowercase")]
850pub enum BackoffStrategy {
851    Linear,
852    Exponential,
853    Fixed,
854}
855
856#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
857pub struct LifecycleConfig {
858    pub restart: RestartPolicy,
859    #[serde(default = "default_max_restarts")]
860    pub max_restarts: u32,
861    #[serde(default = "default_window")]
862    pub restart_window_secs: u64,
863    #[serde(default = "default_stop_timeout")]
864    pub stop_timeout_secs: u64,
865    #[serde(default = "default_mcp_required")]
866    pub mcp_required: bool,
867    #[serde(default)]
868    pub execution: ExecutionMode,
869    #[serde(default)]
870    pub schedule: Vec<ScheduleEntry>,
871    #[serde(default)]
872    pub idle_triggers: Vec<IdleTrigger>,
873}
874fn default_max_restarts() -> u32 {
875    3
876}
877fn default_window() -> u64 {
878    600
879}
880fn default_stop_timeout() -> u64 {
881    15
882}
883fn default_mcp_required() -> bool {
884    true
885}
886
887#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
888#[serde(rename_all = "snake_case")]
889pub enum RestartPolicy {
890    Never,
891    OnFailure,
892    Always,
893}
894
895#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
896#[serde(rename_all = "snake_case")]
897pub enum ExecutionMode {
898    #[default]
899    Daemon,
900    OnDemand,
901}
902
903#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
904pub struct ScheduleEntry {
905    pub cron: String,
906    pub message: String,
907    #[serde(default, skip_serializing_if = "Option::is_none")]
908    pub sends_to: Option<String>,
909}
910
911#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
912pub struct IdleTrigger {
913    /// Idle threshold in seconds. Fires when (now - last_activity) >= after_secs.
914    pub after_secs: u64,
915    /// Message body injected into the task runner when this trigger fires.
916    pub message: String,
917    /// Optional A2A peer to route the resulting reply to. None means the agent itself.
918    #[serde(default, skip_serializing_if = "Option::is_none")]
919    pub sends_to: Option<String>,
920    /// Per-trigger refire cooldown in seconds. Prevents tight loops when the
921    /// idle threshold is short and the runner finishes quickly. Default 600.
922    #[serde(default = "default_idle_cooldown")]
923    pub cooldown_secs: u64,
924    /// When true, suppress firing during the agent's quiet-hours window.
925    /// Default true — idle pings should not wake the user at 3 a.m.
926    #[serde(default = "default_true")]
927    pub respect_quiet_hours: bool,
928}
929
930fn default_idle_cooldown() -> u64 {
931    600
932}
933/// True if `name` is not present in a denylist (i.e. enabled).
934pub fn name_enabled(denylist: &[String], name: &str) -> bool {
935    !denylist.iter().any(|n| n == name)
936}
937
938/// Add/remove `name` in a denylist. `enabled=true` removes it (idempotent),
939/// `enabled=false` adds it once (idempotent).
940pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
941    if enabled {
942        list.retain(|n| n != name);
943    } else if !list.iter().any(|n| n == name) {
944        list.push(name.to_string());
945    }
946}
947
948fn default_true() -> bool {
949    true
950}
951
952#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
953pub struct FileTransferConfig {
954    #[serde(default = "default_accept_max")]
955    pub accept_incoming_file_max_bytes: u64,
956    #[serde(default = "default_accept_total")]
957    pub accept_incoming_total_per_hour: u64,
958    #[serde(default = "default_approval_threshold")]
959    pub require_approval_above_bytes: u64,
960    #[serde(default = "default_reject_paths")]
961    pub reject_paths: Vec<String>,
962    #[serde(default = "default_allowed_mime")]
963    pub allowed_mime_types: Vec<String>,
964}
965
966impl Default for FileTransferConfig {
967    fn default() -> Self {
968        Self {
969            accept_incoming_file_max_bytes: default_accept_max(),
970            accept_incoming_total_per_hour: default_accept_total(),
971            require_approval_above_bytes: default_approval_threshold(),
972            reject_paths: default_reject_paths(),
973            allowed_mime_types: default_allowed_mime(),
974        }
975    }
976}
977
978fn default_accept_max() -> u64 {
979    10_485_760
980}
981fn default_accept_total() -> u64 {
982    104_857_600
983}
984fn default_approval_threshold() -> u64 {
985    10_485_760
986}
987fn default_reject_paths() -> Vec<String> {
988    vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
989}
990fn default_allowed_mime() -> Vec<String> {
991    vec!["*".into()]
992}
993
994#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
995#[serde(rename_all = "snake_case")]
996pub enum DeploymentType {
997    #[default]
998    Laptop,
999    Vm,
1000    Docker,
1001    K8s,
1002    Lambda,
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1006pub struct DeploymentConfig {
1007    #[serde(rename = "type", default)]
1008    pub deployment_type: DeploymentType,
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub region: Option<String>,
1011    #[serde(default = "default_env")]
1012    pub environment: Option<String>,
1013}
1014
1015impl Default for DeploymentConfig {
1016    fn default() -> Self {
1017        Self {
1018            deployment_type: DeploymentType::default(),
1019            region: None,
1020            environment: default_env(),
1021        }
1022    }
1023}
1024
1025fn default_env() -> Option<String> {
1026    Some("dev".into())
1027}
1028
1029#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1030pub struct LockFile {
1031    pub schema: u32,
1032    pub uuid: String,
1033    pub name: String,
1034    pub pid: u32,
1035    pub ppid: u32,
1036    pub started_at: String,
1037    pub binary_version: String,
1038    pub transports: LockTransports,
1039    pub card_digest: String,
1040    pub capabilities: Vec<String>,
1041    /// Git sha the running binary was built from (mur_common::build::SHORT_SHA).
1042    /// Empty = an old lock predating this field. Drives stale detection.
1043    #[serde(default)]
1044    pub build_sha: String,
1045    /// A2A method-surface version this runtime supports (A2A_PROTO_VERSION).
1046    /// 0 = an old lock; the dial gates versioned methods on it.
1047    #[serde(default)]
1048    pub proto_version: u32,
1049}
1050
1051#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1052pub struct LockTransports {
1053    pub stdio: bool,
1054    #[serde(default)]
1055    pub unix_socket: Option<String>,
1056    #[serde(default)]
1057    pub tcp: Option<String>,
1058    /// C5 / M5.3 — webhook listener URL (e.g. `http://127.0.0.1:6789`).
1059    /// Populated by the supervisor when `transport.webhook.enabled =
1060    /// true` so peers and the commander can discover the live
1061    /// endpoint without re-reading `profile.yaml`.
1062    #[serde(default)]
1063    pub webhook: Option<String>,
1064}
1065
1066// ──────────────────────────────────────────────────────────────────────────
1067// Voice I/O configuration (D1 — Kokoro 82M TTS + whisper.cpp STT)
1068// ──────────────────────────────────────────────────────────────────────────
1069
1070/// Kokoro 82M voice identity. Maps to the per-voice style vector
1071/// embedded in the Kokoro ONNX model.
1072#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1073#[serde(rename_all = "snake_case")]
1074pub enum VoiceId {
1075    /// Default: Kokoro af_heart voice.
1076    #[default]
1077    AfHeart,
1078    AfBella,
1079    AfNicole,
1080    AmAdam,
1081    AmMichael,
1082}
1083
1084impl VoiceId {
1085    /// Index into the Kokoro voices.bin style matrix (row index).
1086    pub fn style_index(&self) -> usize {
1087        match self {
1088            VoiceId::AfHeart => 0,
1089            VoiceId::AfBella => 1,
1090            VoiceId::AfNicole => 2,
1091            VoiceId::AmAdam => 3,
1092            VoiceId::AmMichael => 4,
1093        }
1094    }
1095
1096    /// Canonical lowercase string representation (matches `FromStr` inputs).
1097    pub fn as_str(&self) -> &'static str {
1098        match self {
1099            VoiceId::AfHeart => "af_heart",
1100            VoiceId::AfBella => "af_bella",
1101            VoiceId::AfNicole => "af_nicole",
1102            VoiceId::AmAdam => "am_adam",
1103            VoiceId::AmMichael => "am_michael",
1104        }
1105    }
1106}
1107
1108impl std::str::FromStr for VoiceId {
1109    type Err = anyhow::Error;
1110
1111    fn from_str(s: &str) -> anyhow::Result<Self> {
1112        match s {
1113            "af_heart" => Ok(VoiceId::AfHeart),
1114            "af_bella" => Ok(VoiceId::AfBella),
1115            "af_nicole" => Ok(VoiceId::AfNicole),
1116            "am_adam" => Ok(VoiceId::AmAdam),
1117            "am_michael" => Ok(VoiceId::AmMichael),
1118            other => anyhow::bail!(
1119                "unknown voice ID '{other}' \
1120                 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1121            ),
1122        }
1123    }
1124}
1125
1126/// Per-agent voice I/O configuration (D1).
1127/// Default = disabled so existing profiles continue to load unchanged.
1128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1129pub struct VoiceConfig {
1130    /// Whether TTS (Kokoro) + STT (whisper.cpp) are enabled.
1131    #[serde(default)]
1132    pub enabled: bool,
1133    /// Kokoro voice identity for TTS output. Default: af_heart.
1134    #[serde(default)]
1135    pub voice_id: VoiceId,
1136    /// Optional cpal input device name for mic capture.
1137    /// None means the OS default input device.
1138    #[serde(default, skip_serializing_if = "Option::is_none")]
1139    pub input_device: Option<String>,
1140}
1141
1142// ──────────────────────────────────────────────────────────────────────────
1143// Human-in-the-loop configuration (Phase 2)
1144// ──────────────────────────────────────────────────────────────────────────
1145
1146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1147pub struct HitlConfig {
1148    #[serde(default = "default_hitl_timeout_secs")]
1149    pub timeout_secs: u32,
1150    /// Hard cap on agentic-loop iterations (one LLM turn + its tool calls).
1151    /// `None` falls back to the runner default (25). On exceeding the cap the
1152    /// loop exits gracefully with a summary, not a hard error.
1153    #[serde(default)]
1154    pub max_iterations: Option<u32>,
1155    /// Per-task ceiling on cumulative *input* tokens for the agentic loop. When
1156    /// crossed before a turn, the loop stops gracefully with a summary.
1157    /// `None` falls back to the runner default (750_000 ≈ a few dollars on
1158    /// Sonnet); set a lower value per profile to bound spend tightly.
1159    #[serde(default)]
1160    pub max_tokens: Option<u64>,
1161}
1162
1163fn default_hitl_timeout_secs() -> u32 {
1164    300
1165}
1166
1167impl Default for HitlConfig {
1168    fn default() -> Self {
1169        Self {
1170            timeout_secs: default_hitl_timeout_secs(),
1171            max_iterations: None,
1172            max_tokens: None,
1173        }
1174    }
1175}
1176
1177#[cfg(test)]
1178mod hitl_tests {
1179    use super::*;
1180
1181    #[test]
1182    fn hitl_config_default_max_iterations_is_none() {
1183        let cfg = HitlConfig::default();
1184        assert!(cfg.max_iterations.is_none());
1185    }
1186
1187    #[test]
1188    fn hitl_config_max_iterations_explicit() {
1189        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1190        assert_eq!(cfg.max_iterations, Some(5));
1191    }
1192
1193    #[test]
1194    fn hitl_config_default_max_tokens_is_none() {
1195        let cfg = HitlConfig::default();
1196        assert!(cfg.max_tokens.is_none());
1197    }
1198
1199    #[test]
1200    fn hitl_config_max_tokens_explicit() {
1201        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1202        assert_eq!(cfg.max_tokens, Some(250_000));
1203    }
1204}
1205
1206// ──────────────────────────────────────────────────────────────────────────
1207// Companion subsystem (Phase 1.1+) — see
1208// docs/superpowers/specs/2026-04-29-mur-companion-phase-1-1-design.md §3.1
1209// ──────────────────────────────────────────────────────────────────────────
1210
1211#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1212pub struct CompanionConfig {
1213    #[serde(default)]
1214    pub enabled: bool,
1215    #[serde(default = "default_locale")]
1216    pub locale: String,
1217    #[serde(default)]
1218    pub relationship: Relationship,
1219    #[serde(default)]
1220    pub voice_overrides: VoiceOverrides,
1221    #[serde(default)]
1222    pub onboarding: OnboardingState,
1223    #[serde(default)]
1224    pub rhythm: RhythmConfig,
1225    #[serde(default)]
1226    pub proactive: ProactiveConfig,
1227}
1228
1229/// Resolve a default BCP-47 locale from the `LANG` environment variable
1230/// (e.g. `zh_TW.UTF-8` → `zh-TW`). Falls back to `en-US`.
1231pub fn default_locale() -> String {
1232    std::env::var("LANG")
1233        .ok()
1234        .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
1235        .unwrap_or_else(|| "en-US".into())
1236}
1237
1238#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1239pub struct VoiceOverrides {
1240    #[serde(default, skip_serializing_if = "Option::is_none")]
1241    pub name_for_user: Option<String>,
1242    #[serde(default, skip_serializing_if = "Option::is_none")]
1243    pub formality: Option<Formality>,
1244    #[serde(default, skip_serializing_if = "Option::is_none")]
1245    pub extra_instructions: Option<String>,
1246}
1247
1248#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1249pub struct FirstMemory {
1250    pub text: String,
1251    pub established_at: chrono::DateTime<chrono::Utc>,
1252}
1253
1254#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1255pub struct OnboardingState {
1256    #[serde(default, skip_serializing_if = "Option::is_none")]
1257    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1258    #[serde(default)]
1259    pub version: u32,
1260    #[serde(default, skip_serializing_if = "Option::is_none")]
1261    pub agent_display_name: Option<String>,
1262    #[serde(default, skip_serializing_if = "Option::is_none")]
1263    pub first_memory: Option<FirstMemory>,
1264}
1265
1266/// Phase 1.2 reservation. 1.1 keeps `enabled = false` (rhythm collection is
1267/// out of 1.1 scope).
1268#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1269pub struct RhythmConfig {
1270    #[serde(default)]
1271    pub enabled: bool,
1272}
1273
1274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1275pub struct ProactiveConfig {
1276    #[serde(default)]
1277    pub enabled: bool,
1278    /// 1.1 reserves the field; 1.2 will write `now + 7d` at rhythm-enable.
1279    #[serde(default, skip_serializing_if = "Option::is_none")]
1280    pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1281    #[serde(default, skip_serializing_if = "Option::is_none")]
1282    pub quiet_hours: Option<QuietHours>,
1283    #[serde(default, skip_serializing_if = "Option::is_none")]
1284    pub active_hours: Option<ActiveHours>,
1285    #[serde(default = "default_daily_cap")]
1286    pub daily_cap: u8,
1287    #[serde(default = "default_channels")]
1288    pub channels: Vec<String>,
1289    #[serde(default, skip_serializing_if = "Option::is_none")]
1290    pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1291}
1292
1293impl Default for ProactiveConfig {
1294    fn default() -> Self {
1295        Self {
1296            enabled: false,
1297            learning_until: None,
1298            quiet_hours: None,
1299            active_hours: None,
1300            daily_cap: default_daily_cap(),
1301            channels: default_channels(),
1302            paused_until: None,
1303        }
1304    }
1305}
1306
1307fn default_daily_cap() -> u8 {
1308    3
1309}
1310fn default_channels() -> Vec<String> {
1311    vec!["stdout".into()]
1312}
1313
1314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1315pub struct QuietHours {
1316    pub start: String,
1317    pub end: String,
1318}
1319
1320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1321pub struct ActiveHours {
1322    pub start: String,
1323    pub end: String,
1324}
1325
1326// ──────────────────────────────────────────────────────────────────────────
1327// Hub companion appearance (M-h3)
1328// ──────────────────────────────────────────────────────────────────────────
1329
1330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1331pub struct AgentAppearance {
1332    /// ID of the active style preset (e.g. "chiikawa", "default-blob").
1333    #[serde(default = "default_style_preset")]
1334    pub style_preset: String,
1335    #[serde(default)]
1336    pub behavior_preset: BehaviorPreset,
1337    /// Required for the polaroid family; none for all others.
1338    #[serde(default, skip_serializing_if = "Option::is_none")]
1339    pub source_image_path: Option<std::path::PathBuf>,
1340    /// Local dir where rendered .webp expression frames are stored.
1341    #[serde(default = "default_expressions_dir")]
1342    pub expressions_dir: std::path::PathBuf,
1343    #[serde(default, skip_serializing_if = "Option::is_none")]
1344    pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1345    #[serde(default)]
1346    pub render_status: RenderStatus,
1347}
1348
1349fn default_style_preset() -> String {
1350    "default-blob".into()
1351}
1352
1353fn default_expressions_dir() -> std::path::PathBuf {
1354    std::path::PathBuf::from("expressions")
1355}
1356
1357impl Default for AgentAppearance {
1358    fn default() -> Self {
1359        Self {
1360            style_preset: default_style_preset(),
1361            behavior_preset: BehaviorPreset::Normal,
1362            source_image_path: None,
1363            expressions_dir: default_expressions_dir(),
1364            last_rendered_at: None,
1365            render_status: RenderStatus::Pending,
1366        }
1367    }
1368}
1369
1370#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1371#[serde(rename_all = "snake_case")]
1372pub enum BehaviorPreset {
1373    Quiet,
1374    #[default]
1375    Normal,
1376    Lively,
1377}
1378
1379#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1380#[serde(tag = "status", rename_all = "snake_case")]
1381pub enum RenderStatus {
1382    #[default]
1383    Pending,
1384    Rendering {
1385        done: u8,
1386        total: u8,
1387    },
1388    Ready,
1389    Failed {
1390        reason: String,
1391    },
1392}
1393
1394// ──────────────────────────────────────────────────────────────────────────
1395// E6 — Agent Pattern Federation types
1396// ──────────────────────────────────────────────────────────────────────────
1397
1398/// When the agent pulls an updated pattern snapshot from the daemon.
1399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1400#[serde(rename_all = "kebab-case")]
1401pub enum SnapshotPolicy {
1402    #[default]
1403    PullOnStart,
1404    PullPeriodic,
1405    Manual,
1406}
1407
1408/// Filter criteria for the pattern snapshot written to the agent's patterns_cache.
1409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1410pub struct PatternFilter {
1411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1412    pub applies_in: Vec<String>,
1413    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1414    pub tier: Vec<String>,
1415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1416    pub maturity: Vec<String>,
1417    #[serde(default)]
1418    pub importance_min: f64,
1419    #[serde(default = "default_max_snapshot_count")]
1420    pub max_count: usize,
1421    #[serde(default)]
1422    pub snapshot_policy: SnapshotPolicy,
1423}
1424
1425fn default_max_snapshot_count() -> usize {
1426    200
1427}
1428
1429impl Default for PatternFilter {
1430    fn default() -> Self {
1431        Self {
1432            applies_in: vec![],
1433            tier: vec![],
1434            maturity: vec![],
1435            importance_min: 0.0,
1436            max_count: 200,
1437            snapshot_policy: SnapshotPolicy::default(),
1438        }
1439    }
1440}
1441
1442/// Points to the knowledge-layer commit this agent's patterns_cache was built from.
1443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1444pub struct SnapshotRef {
1445    pub knowledge_commit: String,
1446    pub taken_at: String,
1447    pub filter: PatternFilter,
1448}
1449
1450/// Federation configuration embedded in AgentProfile (E6).
1451#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1452pub struct FederationConfig {
1453    #[serde(default)]
1454    pub filter: PatternFilter,
1455    #[serde(default, skip_serializing_if = "Option::is_none")]
1456    pub snapshot_ref: Option<SnapshotRef>,
1457    #[serde(default)]
1458    pub evidence_flush_interval_minutes: u32,
1459}
1460
1461impl AgentProfile {
1462    /// Minimal valid profile for tests — no voice, no MCP, no skills.
1463    ///
1464    /// Available in all compilation modes so integration tests in
1465    /// dependent crates can call it (unlike `#[cfg(test)]` items which
1466    /// are invisible to downstream test binaries).
1467    #[doc(hidden)]
1468    pub fn default_for_tests() -> Self {
1469        serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1470            .expect("minimal profile fixture")
1471    }
1472
1473    /// Load an agent's profile from `<mur_home>/agents/<name>/profile.yaml`.
1474    ///
1475    /// Canonical read-path counterpart to the atomic-write path used by
1476    /// `mur agent create`/`mur agent mcp add` (`write_atomic` in
1477    /// `mur-core::cmd::agent`) — callers that already have `mur_home` in
1478    /// hand (e.g. provisioning flows, tests) can load a profile without
1479    /// going through the `MUR_HOME`-env-var-based `resolve_mur_home`.
1480    pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1481        let path = mur_home.join("agents").join(name).join("profile.yaml");
1482        let yaml = std::fs::read_to_string(&path)
1483            .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1484        serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1485    }
1486
1487    /// The imported add-on group a skill/mcp/command name belongs to.
1488    pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1489        self.addons.iter().find(|g| {
1490            g.skills.iter().any(|n| n == name)
1491                || g.mcp.iter().any(|n| n == name)
1492                || g.commands.iter().any(|n| n == name)
1493        })
1494    }
1495
1496    /// Whether `skill_name` is enabled (§3.3): not denied AND, if it
1497    /// belongs to an imported group, that group is enabled.
1498    pub fn skill_enabled(&self, skill_name: &str) -> bool {
1499        name_enabled(&self.disabled_skills, skill_name)
1500            && self.group_of(skill_name).is_none_or(|g| g.enabled)
1501    }
1502
1503    /// Whether MCP server `server_id` is enabled (§3.3).
1504    pub fn mcp_enabled(&self, server_id: &str) -> bool {
1505        name_enabled(&self.disabled_mcp, server_id)
1506            && self.group_of(server_id).is_none_or(|g| g.enabled)
1507    }
1508
1509    /// Toggle a skill for this agent without uninstalling it.
1510    pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1511        set_denylist(&mut self.disabled_skills, skill_name, enabled);
1512    }
1513
1514    /// Toggle an MCP server for this agent without removing it.
1515    pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1516        set_denylist(&mut self.disabled_mcp, server_id, enabled);
1517    }
1518
1519    /// Toggle an imported plugin-group as a unit. Returns false if no
1520    /// add-on has that id.
1521    pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1522        match self.addons.iter_mut().find(|g| g.id == addon_id) {
1523            Some(g) => {
1524                g.enabled = enabled;
1525                true
1526            }
1527            None => false,
1528        }
1529    }
1530
1531    /// Emergency kill-switch (§7): clears every add-on group's `enabled` flag.
1532    /// Members are already forced off by the group AND-gate in `skill_enabled` /
1533    /// `mcp_enabled`, so no denylist push is needed — and avoiding it means
1534    /// `set_addon_enabled(id, true)` fully restores the group without leftover
1535    /// per-member denials.
1536    pub fn disable_all_addons(&mut self) {
1537        for g in &mut self.addons {
1538            g.enabled = false;
1539        }
1540    }
1541
1542    /// This agent's MCP servers minus any disabled for it.
1543    pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1544        self.mcp_servers
1545            .iter()
1546            .filter(|m| self.mcp_enabled(&m.name))
1547            .cloned()
1548            .collect()
1549    }
1550}
1551
1552#[cfg(test)]
1553mod tests {
1554    use super::*;
1555
1556    #[test]
1557    fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
1558        let net = McpServerNetwork {
1559            mode: McpNetMode::BroadAudited,
1560            allow_hosts: vec![],
1561            deny_hosts: vec!["evil.example".into()],
1562            authorization: Some(EgressAuthorization {
1563                authorized_by: "david".into(),
1564                authorized_at_ms: 1_750_000_000_000,
1565            }),
1566        };
1567        let y = serde_yaml::to_string(&net).unwrap();
1568        assert!(y.contains("broad_audited"));
1569        let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
1570        assert_eq!(back, net);
1571        // legacy per-server policy without the new fields still parses (serde default)
1572        let legacy: McpServerNetwork =
1573            serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
1574        assert_eq!(legacy.deny_hosts, Vec::<String>::new());
1575        assert!(legacy.authorization.is_none());
1576    }
1577
1578    #[test]
1579    fn mcp_entry_network_is_optional_and_round_trips() {
1580        // Absent in YAML → None (every existing profile keeps working).
1581        let bare = "name: x\ncommand: npx\n";
1582        let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
1583        assert!(e.network.is_none());
1584
1585        // Present → parsed.
1586        let with = "name: browser\ncommand: npx\nnetwork:\n  mode: restricted\n  allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
1587        let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
1588        let net = e2.network.expect("network present");
1589        assert_eq!(net.mode, McpNetMode::Restricted);
1590        assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
1591
1592        // Round-trip keeps None out of the serialized form.
1593        let out = serde_yaml_ng::to_string(&e).unwrap();
1594        assert!(!out.contains("network"));
1595    }
1596
1597    #[test]
1598    fn profile_round_trip_yaml() {
1599        let yaml = r#"
1600schema: 1
1601id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1602name: agent_a
1603display_name: "Price Hunter"
1604version: "0.1.0"
1605persona:
1606  category: research
1607  description: "Finds prices"
1608  traits: { tone: concise, risk: cautious, verbosity: low }
1609sys_prompt_file: "sys_prompt.md"
1610model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1611mcp_servers: []
1612skills: []
1613transport:
1614  stdio: true
1615  socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1616communication: { accepts_from: ["*"], sends_to: [] }
1617capabilities: ["a2a.message.send", "a2a.tasks"]
1618entitlements:
1619  network:
1620    inbound: { ports: [] }
1621    outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1622  filesystem: { read: [], write: [], deny: [] }
1623  processes: { spawn: { mode: allowlist, allowed: [] } }
1624  syscalls: { mode: default }
1625  limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1626notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1627retry:
1628  llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1629  tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1630lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1631created_at: "2026-04-22T10:00:00+08:00"
1632updated_at: "2026-04-22T10:00:00+08:00"
1633"#;
1634        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1635        assert_eq!(profile.name, "agent_a");
1636        assert_eq!(profile.persona.category, PersonaCategory::Research);
1637        assert_eq!(
1638            profile.entitlements.network.outbound.mode,
1639            NetworkOutboundMode::Restricted
1640        );
1641        let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1642        let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1643        assert_eq!(profile.id, round_tripped.id);
1644    }
1645
1646    #[test]
1647    fn requires_capabilities_defaults_empty_and_round_trips() {
1648        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1649        let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
1650        assert!(p.requires_capabilities.is_empty());
1651        let with = format!("{base}\nrequires_capabilities:\n  - media\n");
1652        let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
1653        assert_eq!(p2.requires_capabilities, vec!["media"]);
1654    }
1655}
1656
1657#[cfg(test)]
1658mod model_ref_tests {
1659    use super::*;
1660
1661    #[test]
1662    fn legacy_profile_without_model_ref_still_parses() {
1663        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1664        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1665        assert!(
1666            p.model_ref.is_none(),
1667            "legacy profile must not have model_ref"
1668        );
1669    }
1670
1671    #[test]
1672    fn round_trip_with_model_ref_preserves_field() {
1673        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1674        let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1675        p.model_ref = Some("anthropic_opus_4_7".into());
1676        let s = serde_yaml_ng::to_string(&p).unwrap();
1677        assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1678        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1679        assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1680    }
1681
1682    #[test]
1683    fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
1684        // Load fixture (no fallback_chain / routing) — legacy safe.
1685        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1686        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1687        assert!(
1688            p.fallback_chain.is_empty(),
1689            "legacy profile must have empty fallback_chain"
1690        );
1691        assert!(
1692            p.routing.is_none(),
1693            "legacy profile must have no routing override"
1694        );
1695
1696        // Round-trip with fallback_chain and routing.
1697        let mut p = p.clone();
1698        p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
1699        p.routing = Some(crate::config::RoutingConfig {
1700            enabled: true,
1701            ..Default::default()
1702        });
1703        let s = serde_yaml_ng::to_string(&p).unwrap();
1704        assert!(
1705            s.contains("fallback_chain:"),
1706            "yaml must contain fallback_chain"
1707        );
1708        assert!(s.contains("routing:"), "yaml must contain routing");
1709        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1710        assert_eq!(
1711            p2.fallback_chain,
1712            vec!["claude_opus", "claude_sonnet"],
1713            "fallback_chain must round-trip"
1714        );
1715        assert!(
1716            p2.routing.as_ref().unwrap().enabled,
1717            "routing.enabled must round-trip"
1718        );
1719    }
1720}
1721
1722/// GUI-facing reification of the companion's three-layer permission toggle.
1723///
1724/// On-disk schema doesn't change — this helper just maps between the
1725/// three independent booleans (`enabled`, `rhythm.enabled`,
1726/// `proactive.enabled`) and a single ordered tier. Use
1727/// [`ProactiveTier::from_config`] to read and [`ProactiveTier::apply`]
1728/// to write.
1729#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1730#[serde(rename_all = "snake_case")]
1731pub enum ProactiveTier {
1732    Off,
1733    WarmOnly,
1734    WarmAndBehavior,
1735    All,
1736}
1737
1738impl ProactiveTier {
1739    pub fn from_config(c: &CompanionConfig) -> Self {
1740        match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1741            (false, _, _) => Self::Off,
1742            (true, false, false) => Self::WarmOnly,
1743            (true, true, false) => Self::WarmAndBehavior,
1744            (true, _, true) => Self::All,
1745        }
1746    }
1747
1748    pub fn apply(&self, c: &mut CompanionConfig) {
1749        match self {
1750            Self::Off => {
1751                c.enabled = false;
1752                c.rhythm.enabled = false;
1753                c.proactive.enabled = false;
1754            }
1755            Self::WarmOnly => {
1756                c.enabled = true;
1757                c.rhythm.enabled = false;
1758                c.proactive.enabled = false;
1759            }
1760            Self::WarmAndBehavior => {
1761                c.enabled = true;
1762                c.rhythm.enabled = true;
1763                c.proactive.enabled = false;
1764            }
1765            Self::All => {
1766                c.enabled = true;
1767                c.rhythm.enabled = true;
1768                c.proactive.enabled = true;
1769            }
1770        }
1771    }
1772}
1773
1774#[cfg(test)]
1775mod mcp_pin_tests {
1776    use super::*;
1777
1778    /// Pre-M9 profiles must continue to deserialize with the new
1779    /// optional fields absent. Round-trip: serialize back out and
1780    /// confirm the optional fields don't leak into the YAML.
1781    #[test]
1782    fn pre_m9_entry_roundtrips_without_pin_fields() {
1783        let yaml = r#"
1784name: weather
1785command: /opt/mcp/weather
1786args: ["--port", "0"]
1787"#;
1788        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1789        assert_eq!(entry.name, "weather");
1790        assert_eq!(entry.binary_sha256, None);
1791        assert_eq!(entry.description_hash, None);
1792        assert_eq!(entry.publisher, None);
1793        assert_eq!(entry.installed_at, None);
1794
1795        // skip_serializing_if = "Option::is_none" must keep the YAML
1796        // free of empty pin fields when the entry is pre-M9.
1797        let out = serde_yaml_ng::to_string(&entry).unwrap();
1798        assert!(!out.contains("binary_sha256"), "got {out}");
1799        assert!(!out.contains("description_hash"), "got {out}");
1800        assert!(!out.contains("publisher"), "got {out}");
1801        assert!(!out.contains("installed_at"), "got {out}");
1802    }
1803
1804    /// Full M9 entry with all fields set round-trips losslessly.
1805    #[test]
1806    fn full_m9_entry_roundtrips_all_fields() {
1807        let yaml = r#"
1808name: weather
1809command: /opt/mcp/weather
1810args: []
1811binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1812description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1813publisher:
1814  name: "@anthropic-mcp/weather"
1815  homepage: "https://github.com/anthropic-mcp/weather"
1816  registry_id: "@anthropic-mcp/weather@1.2.3"
1817installed_at: "2026-05-06T08:00:00Z"
1818"#;
1819        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1820        assert!(
1821            entry
1822                .binary_sha256
1823                .as_deref()
1824                .unwrap()
1825                .starts_with("3f4abca8")
1826        );
1827        assert!(
1828            entry
1829                .description_hash
1830                .as_deref()
1831                .unwrap()
1832                .starts_with("9a01b2c3")
1833        );
1834        let pub_info = entry.publisher.clone().unwrap();
1835        assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1836        assert_eq!(
1837            pub_info.homepage.as_deref(),
1838            Some("https://github.com/anthropic-mcp/weather"),
1839        );
1840        assert_eq!(
1841            pub_info.registry_id.as_deref(),
1842            Some("@anthropic-mcp/weather@1.2.3"),
1843        );
1844        let installed = entry.installed_at.unwrap();
1845        assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1846    }
1847
1848    /// Partial — only the binary hash is set (e.g. probe failed but
1849    /// install proceeded). The supervisor still needs to be able to
1850    /// deserialize this without panicking.
1851    #[test]
1852    fn partial_pin_only_binary_sha_roundtrips() {
1853        let yaml = r#"
1854name: weather
1855command: /opt/mcp/weather
1856args: []
1857binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1858"#;
1859        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1860        assert_eq!(
1861            entry.binary_sha256.as_deref(),
1862            Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1863        );
1864        assert_eq!(entry.description_hash, None);
1865        assert_eq!(entry.publisher, None);
1866    }
1867
1868    /// Publisher with only the required `name` field — homepage and
1869    /// registry_id are optional.
1870    #[test]
1871    fn publisher_minimal_just_name() {
1872        let yaml = r#"
1873name: weather
1874command: /opt/mcp/weather
1875args: []
1876publisher:
1877  name: "alice"
1878"#;
1879        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1880        let p = entry.publisher.as_ref().unwrap();
1881        assert_eq!(p.name, "alice");
1882        assert_eq!(p.homepage, None);
1883        assert_eq!(p.registry_id, None);
1884
1885        // skip_serializing_if must omit the optional sub-fields too.
1886        let out = serde_yaml_ng::to_string(&entry).unwrap();
1887        assert!(!out.contains("homepage:"), "got {out}");
1888        assert!(!out.contains("registry_id:"), "got {out}");
1889    }
1890}
1891
1892#[cfg(test)]
1893mod voice_tests {
1894    use super::*;
1895    use std::str::FromStr;
1896
1897    #[test]
1898    fn voice_config_round_trips() {
1899        // Base: use the canonical minimal fixture and append a voice: block.
1900        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1901        let yaml = format!("{base}voice:\n  enabled: true\n  voice_id: af_bella\n");
1902
1903        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1904        assert!(profile.voice.enabled);
1905        assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1906
1907        // Legacy profiles (no voice: block) must still load.
1908        let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1909        assert!(!legacy.voice.enabled);
1910        assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1911    }
1912
1913    #[test]
1914    fn voice_id_from_str_roundtrips() {
1915        let cases = [
1916            ("af_heart", VoiceId::AfHeart),
1917            ("af_bella", VoiceId::AfBella),
1918            ("af_nicole", VoiceId::AfNicole),
1919            ("am_adam", VoiceId::AmAdam),
1920            ("am_michael", VoiceId::AmMichael),
1921        ];
1922        for (s, expected) in cases {
1923            assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1924            assert_eq!(expected.as_str(), s);
1925        }
1926    }
1927
1928    #[test]
1929    fn voice_id_from_str_rejects_unknown() {
1930        assert!(VoiceId::from_str("bogus").is_err());
1931    }
1932}
1933
1934#[cfg(test)]
1935mod idle_trigger_tests {
1936    use super::*;
1937
1938    #[test]
1939    fn idle_trigger_yaml_round_trip() {
1940        let yaml = r#"
1941restart: on_failure
1942idle_triggers:
1943  - after_secs: 3600
1944    message: "still there?"
1945    sends_to: other_agent
1946    cooldown_secs: 1800
1947    respect_quiet_hours: true
1948"#;
1949        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1950        assert_eq!(cfg.idle_triggers.len(), 1);
1951        assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1952        assert_eq!(cfg.idle_triggers[0].message, "still there?");
1953        assert_eq!(
1954            cfg.idle_triggers[0].sends_to.as_deref(),
1955            Some("other_agent")
1956        );
1957        assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1958        assert!(cfg.idle_triggers[0].respect_quiet_hours);
1959    }
1960
1961    #[test]
1962    fn idle_trigger_defaults_when_omitted() {
1963        let yaml = "restart: on_failure\n";
1964        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1965        assert!(cfg.idle_triggers.is_empty());
1966    }
1967}
1968
1969#[cfg(test)]
1970mod appearance_tests {
1971    use super::*;
1972
1973    #[test]
1974    fn appearance_default_style_preset_is_default_blob() {
1975        assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1976    }
1977
1978    #[test]
1979    fn appearance_default_behavior_is_normal() {
1980        assert_eq!(
1981            AgentAppearance::default().behavior_preset,
1982            BehaviorPreset::Normal
1983        );
1984    }
1985
1986    #[test]
1987    fn appearance_default_render_status_is_pending() {
1988        assert_eq!(
1989            AgentAppearance::default().render_status,
1990            RenderStatus::Pending
1991        );
1992    }
1993
1994    #[test]
1995    fn render_status_serde_round_trip() {
1996        let cases = [
1997            RenderStatus::Pending,
1998            RenderStatus::Rendering { done: 3, total: 12 },
1999            RenderStatus::Ready,
2000            RenderStatus::Failed {
2001                reason: "out of quota".into(),
2002            },
2003        ];
2004        for status in cases {
2005            let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
2006            let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
2007            assert_eq!(status, back);
2008        }
2009    }
2010
2011    #[test]
2012    fn agent_profile_with_appearance_round_trips() {
2013        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2014        let yaml = format!(
2015            "{base}appearance:\n  style_preset: chiikawa\n  render_status:\n    status: ready\n"
2016        );
2017        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2018        assert_eq!(profile.appearance.style_preset, "chiikawa");
2019        assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2020
2021        let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2022        let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2023        assert_eq!(profile.appearance, back.appearance);
2024    }
2025
2026    #[test]
2027    fn legacy_profile_without_appearance_uses_default() {
2028        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2029        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2030        assert_eq!(profile.appearance.style_preset, "default-blob");
2031        assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2032        assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2033    }
2034
2035    #[test]
2036    fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2037        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2038        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2039        assert!(p.file_actions.is_empty());
2040        assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2041        assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2042    }
2043}
2044
2045#[cfg(test)]
2046mod federation_tests {
2047    use super::*;
2048
2049    #[test]
2050    fn test_pattern_filter_default() {
2051        let f = PatternFilter::default();
2052        assert_eq!(f.max_count, 200);
2053        assert_eq!(f.importance_min, 0.0);
2054        assert!(f.tier.is_empty());
2055    }
2056
2057    #[test]
2058    fn test_federation_config_roundtrip() {
2059        let cfg = FederationConfig {
2060            filter: PatternFilter {
2061                tier: vec!["core".into()],
2062                max_count: 50,
2063                ..Default::default()
2064            },
2065            snapshot_ref: Some(SnapshotRef {
2066                knowledge_commit: "abc123def456".into(),
2067                taken_at: "2026-05-19T00:00:00Z".into(),
2068                filter: PatternFilter::default(),
2069            }),
2070            evidence_flush_interval_minutes: 15,
2071        };
2072        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2073        let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2074        assert_eq!(cfg, back);
2075    }
2076
2077    #[test]
2078    fn test_agent_profile_federation_defaults() {
2079        // AgentProfile without a federation block deserializes with FederationConfig::default().
2080        // Use the minimal YAML that passes validation — just the required fields.
2081        // (We check only that the field has its zero value, not full profile parse.)
2082        let cfg = FederationConfig::default();
2083        assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2084        assert!(cfg.snapshot_ref.is_none());
2085    }
2086}
2087
2088#[cfg(test)]
2089mod skill_card_tests {
2090    use super::*;
2091
2092    #[test]
2093    fn installed_skills_default_to_empty_when_absent() {
2094        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2095        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2096        assert!(p.installed_skills.is_empty());
2097    }
2098
2099    #[test]
2100    fn installed_skills_roundtrip_preserves_entries() {
2101        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2102        let yaml = format!(
2103            "{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"
2104        );
2105        let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2106        assert_eq!(p.installed_skills.len(), 1);
2107        assert_eq!(p.installed_skills[0].name, "s1");
2108        assert_eq!(p.installed_skills[0].abstract_text, "does things");
2109        assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2110
2111        let out = serde_yaml_ng::to_string(&p).unwrap();
2112        assert!(out.contains("abstract: does things"));
2113        assert!(out.contains("pattern: /find"));
2114
2115        let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2116        assert_eq!(p.installed_skills, back.installed_skills);
2117    }
2118
2119    #[test]
2120    fn installed_skills_minimal_entry_serializes_compactly() {
2121        // A name-only entry must NOT emit empty string fields.
2122        let entry = SkillCardEntry {
2123            name: "minimal".into(),
2124            ..Default::default()
2125        };
2126        let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2127        assert!(yaml.contains("name: minimal"));
2128        assert!(
2129            !yaml.contains("version:"),
2130            "empty version must be skipped: {yaml}"
2131        );
2132        assert!(
2133            !yaml.contains("publisher:"),
2134            "empty publisher must be skipped: {yaml}"
2135        );
2136        assert!(
2137            !yaml.contains("abstract:"),
2138            "empty abstract must be skipped: {yaml}"
2139        );
2140    }
2141}
2142
2143#[cfg(test)]
2144mod tool_policy_tests {
2145    use super::*;
2146
2147    fn rules() -> Vec<ToolRule> {
2148        vec![
2149            ToolRule {
2150                pattern: "mcp__github__merge_pr".into(),
2151                policy: ToolPolicy::Ask,
2152                risk: None,
2153            },
2154            ToolRule {
2155                pattern: "mcp__github__*".into(),
2156                policy: ToolPolicy::Allow,
2157                risk: None,
2158            },
2159            ToolRule {
2160                pattern: "mcp__*".into(),
2161                policy: ToolPolicy::Deny,
2162                risk: None,
2163            },
2164            ToolRule {
2165                pattern: "bash".into(),
2166                policy: ToolPolicy::Allow,
2167                risk: None,
2168            },
2169        ]
2170    }
2171
2172    #[test]
2173    fn exact_beats_glob() {
2174        assert_eq!(
2175            resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2176            ToolPolicy::Ask
2177        );
2178    }
2179
2180    #[test]
2181    fn longer_glob_wins() {
2182        assert_eq!(
2183            resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2184            ToolPolicy::Allow
2185        );
2186    }
2187
2188    #[test]
2189    fn shorter_glob_fallback() {
2190        assert_eq!(
2191            resolve_tool_policy(&rules(), "mcp__slack__send"),
2192            ToolPolicy::Deny
2193        );
2194    }
2195
2196    #[test]
2197    fn exact_bash() {
2198        assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2199    }
2200
2201    #[test]
2202    fn unknown_tool_defaults_ask() {
2203        assert_eq!(
2204            resolve_tool_policy(&rules(), "unknown_tool"),
2205            ToolPolicy::Ask
2206        );
2207    }
2208
2209    #[test]
2210    fn empty_rules_defaults_ask() {
2211        assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2212    }
2213
2214    fn minimal_entitlements_yaml() -> &'static str {
2215        "network:\n  inbound: {}\n  outbound:\n    mode: off\nfilesystem: {}\nprocesses:\n  spawn:\n    mode: none\n"
2216    }
2217
2218    #[test]
2219    fn entitlements_tools_defaults_empty() {
2220        let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2221        assert!(e.tools.is_empty());
2222    }
2223
2224    #[test]
2225    fn entitlements_tools_roundtrip() {
2226        let base = minimal_entitlements_yaml();
2227        let yaml = format!("{base}tools:\n  - pattern: \"mcp__github__*\"\n    policy: allow\n");
2228        let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2229        assert_eq!(e.tools.len(), 1);
2230        assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2231        let y = serde_yaml_ng::to_string(&e).unwrap();
2232        let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2233        assert_eq!(back.tools.len(), 1);
2234        assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2235    }
2236    #[test]
2237    fn denylist_membership_and_mutation() {
2238        let mut list: Vec<String> = vec![];
2239        assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2240
2241        set_denylist(&mut list, "a", false); // disable
2242        assert!(!name_enabled(&list, "a"));
2243        assert_eq!(list, ["a"]);
2244
2245        set_denylist(&mut list, "a", false); // idempotent disable
2246        assert_eq!(list, ["a"], "no duplicate entries");
2247
2248        set_denylist(&mut list, "a", true); // enable removes
2249        assert!(name_enabled(&list, "a"));
2250        assert!(list.is_empty());
2251
2252        set_denylist(&mut list, "b", true); // enabling an absent name is a no-op
2253        assert!(list.is_empty());
2254    }
2255
2256    #[test]
2257    fn addon_group_rule_truth_table() {
2258        let mut p = AgentProfile::default_for_tests();
2259        p.addons.push(AddonRef {
2260            id: "grp".into(),
2261            source: "claude-local:grp@1.0.0".into(),
2262            enabled: false,
2263            skills: vec!["g_skill".into()],
2264            mcp: vec!["g_mcp".into()],
2265            commands: vec!["g_cmd".into()],
2266            content_hash: None,
2267            fetch_ref: None,
2268            fetch_plugin: None,
2269        });
2270
2271        // 1. standalone item, no entry anywhere => enabled (back-compat)
2272        assert!(p.skill_enabled("standalone"));
2273        assert!(p.mcp_enabled("standalone_mcp"));
2274
2275        // 2. grouped item, group disabled => off (cannot enable one member of a disabled group)
2276        assert!(!p.skill_enabled("g_skill"));
2277        assert!(!p.mcp_enabled("g_mcp"));
2278
2279        // 3. grouped item, group enabled, name not denied => on
2280        assert!(p.set_addon_enabled("grp", true));
2281        assert!(p.skill_enabled("g_skill"));
2282        assert!(p.mcp_enabled("g_mcp"));
2283
2284        // 4. name in denylist overrides an enabled group => off (silence one member)
2285        p.set_skill_enabled("g_skill", false);
2286        assert!(!p.skill_enabled("g_skill"));
2287
2288        // set_addon_enabled on a missing id reports false
2289        assert!(!p.set_addon_enabled("nope", true));
2290
2291        // kill-switch: only flips group flags — no denylist push
2292        p.disable_all_addons();
2293        assert!(p.addons.iter().all(|g| !g.enabled));
2294        assert!(!p.skill_enabled("g_skill"));
2295        assert!(!p.skill_enabled("g_cmd"));
2296        assert!(!p.mcp_enabled("g_mcp")); // mcp kill-switch asserted
2297
2298        // re-enable restores members — kill-switch is NOT sticky
2299        // (g_skill was individually denied in step 4 above and stays off;
2300        //  g_cmd and g_mcp were never individually denied so they come back on)
2301        assert!(p.set_addon_enabled("grp", true));
2302        assert!(!p.skill_enabled("g_skill")); // still individually denied from step 4
2303        assert!(p.skill_enabled("g_cmd")); // restored: never individually denied
2304        assert!(p.mcp_enabled("g_mcp")); // restored: never individually denied
2305
2306        // clearing the individual deny fully restores g_skill too
2307        p.set_skill_enabled("g_skill", true);
2308        assert!(p.skill_enabled("g_skill"));
2309    }
2310
2311    #[test]
2312    fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2313        // legacy AddonRef (no new fields) → None
2314        let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2315        let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2316        assert_eq!(r.content_hash, None);
2317        assert_eq!(r.fetch_ref, None);
2318
2319        // with the new fields → round-trips
2320        let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2321        let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2322        assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2323        assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2324        let back = serde_yaml_ng::to_string(&r2).unwrap();
2325        let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2326        assert_eq!(r2, r3);
2327    }
2328}
2329
2330#[cfg(test)]
2331mod lockfile_compat_tests {
2332    use super::*;
2333
2334    #[test]
2335    fn lockfile_new_fields_default_for_old_locks() {
2336        // An old lock JSON without build_sha/proto_version must still parse,
2337        // defaulting to "" / 0 (= "predates this feature → stale/unsupported").
2338        let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2339          "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2340          "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2341        let lock: LockFile = serde_json::from_str(old).unwrap();
2342        assert_eq!(lock.build_sha, "");
2343        assert_eq!(lock.proto_version, 0);
2344    }
2345}
2346
2347#[cfg(test)]
2348mod remote_mcp_tests {
2349    use super::*;
2350
2351    #[test]
2352    fn mcp_entry_roundtrips_remote_bearer() {
2353        let e = McpServerEntry {
2354            name: "gh".into(),
2355            command: String::new(),
2356            url: Some("https://api.example.com/mcp".into()),
2357            auth: Some(McpAuth::Bearer {
2358                token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2359            }),
2360            ..Default::default()
2361        };
2362        let y = serde_yaml_ng::to_string(&e).unwrap();
2363        let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2364        assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2365        assert!(matches!(
2366            back.auth,
2367            Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2368        ));
2369        // A legacy stdio entry (no url/auth) still parses.
2370        let legacy: McpServerEntry =
2371            serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2372        assert!(legacy.url.is_none());
2373        assert!(legacy.auth.is_none());
2374    }
2375}
2376
2377#[cfg(test)]
2378mod requires_programs_tests {
2379    #[test]
2380    fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2381        let with = r#"
2382name: research-gateway
2383command: mur-research-gateway
2384requires_programs:
2385  - name: lightpanda
2386    detect: { file: "~/.mur/aura/lightpanda" }
2387    reason: "render tier"
2388    registry: lightpanda
2389"#;
2390        let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2391        assert_eq!(e.requires_programs.len(), 1);
2392        assert_eq!(e.requires_programs[0].name, "lightpanda");
2393
2394        // Absent block → empty (back-compat).
2395        let without = "name: x\ncommand: y\n";
2396        let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2397        assert!(e2.requires_programs.is_empty());
2398    }
2399}