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