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. Absent fields inherit the global
86    /// `models.routing`.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub routing: Option<crate::config::RoutingOverride>,
89    /// Per-agent Smart background-routing override. Absent fields inherit the
90    /// global `models.smart`; `None` means "follow the global setting".
91    /// Promoted out of `routing` — nesting it there meant overriding Smart
92    /// silently rewrote this agent's difficulty routing as a side effect.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub smart: Option<crate::config::SmartOverride>,
95    #[serde(default)]
96    pub mcp_servers: Vec<McpServerEntry>,
97    #[serde(default)]
98    pub skills: Vec<String>,
99    /// Skills installed via `mur skill install`. Distinct from `skills`
100    /// (which holds legacy per-agent paths from `mur agent skill add`).
101    /// Broadcast in the Agent Card alongside `skills`.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub installed_skills: Vec<SkillCardEntry>,
104    /// Per-agent skill denylist (add-on Phase 1). Skill names that are
105    /// installed/visible to this agent but suppressed from injection.
106    /// Non-destructive: the skill's files/stats are untouched. Empty = all
107    /// visible skills enabled (back-compat: absent in old profiles).
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub disabled_skills: Vec<String>,
110
111    /// Per-agent MCP denylist (add-on Phase 1). `McpServerEntry` names not
112    /// spawned for this agent. Non-destructive: the entry + its pin stay in
113    /// the profile. Empty = all configured servers enabled.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub disabled_mcp: Vec<String>,
116
117    /// Names of per-agent secrets the user handed this agent (murmur
118    /// `/secret`, `mur agent secret set`). NAMES ONLY — the values live in the
119    /// keychain under `mur-agent/<name>/<NAME>`. The list exists because the
120    /// keychain cannot be enumerated: the supervisor reads it pre-seal to know
121    /// which accounts to load. Empty = nothing to load (back-compat).
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub secrets: Vec<String>,
124    /// Plugin-groups imported by this agent (add-on Phase 2). Each is
125    /// self-contained (members installed per-agent). Absent/empty in
126    /// legacy profiles (back-compat).
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub addons: Vec<AddonRef>,
129    pub transport: TransportConfig,
130    pub communication: CommunicationConfig,
131    #[serde(default)]
132    pub capabilities: Vec<String>,
133    pub entitlements: Entitlements,
134    #[serde(default)]
135    pub notifications: NotificationsConfig,
136    pub retry: RetryConfig,
137    pub lifecycle: LifecycleConfig,
138    /// Cryptographic identity for cross-host A2A (P0a.5+). Default = empty
139    /// (legacy P0a profiles continue to load without this block).
140    #[serde(default)]
141    pub identity: IdentityConfig,
142    #[serde(default)]
143    pub file_transfer: FileTransferConfig,
144    #[serde(default)]
145    pub deployment: DeploymentConfig,
146    /// Companion subsystem (Phase 1.1+). Default = disabled (legacy profiles
147    /// continue to load without this block).
148    #[serde(default)]
149    pub companion: CompanionConfig,
150    /// Human-in-the-loop configuration (Phase 2). Default = disabled.
151    #[serde(default)]
152    pub hitl: HitlConfig,
153    /// Execution limits for this agent's own tasks (spec 2026-09-12 §3.1).
154    /// Absent → inherit. Replaces `hitl.max_iterations` / `hitl.max_tokens`,
155    /// which stay readable for the migration warning until the runtime
156    /// switch (step 4) stops applying them.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub limits: Option<crate::limits::Limits>,
159    /// Voice I/O configuration (D1). Default = disabled.
160    #[serde(default)]
161    pub voice: VoiceConfig,
162    /// A1: config-driven handler picker. Absent block = all defaults.
163    #[serde(default)]
164    pub hooks: crate::HooksConfig,
165    /// Pubkeys of bridges (and other LLM-less peers) this agent will accept
166    /// signed envelopes from. Empty = accept no bridge traffic. Default = empty.
167    #[serde(default)]
168    pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
169    pub created_at: String,
170    pub updated_at: String,
171    /// Hub companion visual identity (M-h3). Default = default-blob / Normal / Pending.
172    #[serde(default)]
173    pub appearance: AgentAppearance,
174    /// E6: Pattern federation — snapshot filter + outbox config.
175    #[serde(default)]
176    pub federation: FederationConfig,
177
178    /// A1: declarative UI action list — file_actions rendered as action
179    /// buttons in the pending-item selection UI. New top-level key; NOT
180    /// nested under `capabilities:`.
181    #[serde(default)]
182    pub file_actions: Vec<crate::action::FileAction>,
183
184    /// A2 + A3: action pipeline configuration (deletion safety + queue limits).
185    #[serde(default)]
186    pub action_pipeline: crate::action::ActionPipelineConfig,
187
188    /// External programs this artifact needs at runtime (portable-deps spec).
189    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
190    #[serde(default, skip_serializing_if = "Vec::is_empty")]
191    pub requires_programs: Vec<ProgramDep>,
192
193    /// Capability refs installed into this agent (Pack S3). Absent → empty;
194    /// resolved against the local capability registry / bundle store.
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    pub requires_capabilities: Vec<String>,
197}
198
199fn default_algorithm() -> String {
200    "ed25519".into()
201}
202
203/// Algorithms the runtime can generate + verify.
204pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
205
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
207pub struct IdentityConfig {
208    /// Multibase-encoded Ed25519 public key (base58btc, `z` prefix).
209    /// Empty string for legacy P0a profiles; filled on P0a.5 `mur agent create`.
210    #[serde(default)]
211    pub pubkey: String,
212    /// Free-form owner identity (email / SSO sub). None for legacy profiles.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub owner: Option<String>,
215
216    // P0a.6 rekey extensions (all #[serde(default)] — back-compat)
217    /// Cryptographic algorithm for this key. Defaults to "ed25519".
218    #[serde(default = "default_algorithm")]
219    pub algorithm: String,
220    /// Monotonic version counter; 0 = initial create, increments on each rotation.
221    #[serde(default)]
222    pub key_version: u32,
223    /// RFC3339 timestamp of when this key was created.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub created_at_key: Option<String>,
226    /// Previous public key (before most recent rotation). None if not rotated yet.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub previous_pubkey: Option<String>,
229    /// Version of the previous key. None if not rotated yet.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub previous_key_version: Option<u32>,
232    /// RFC3339 timestamp when grace period expires and old key is fully retired.
233    /// Only set during rotation; cleared once grace period ends.
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    pub grace_expires_at: Option<String>,
236    /// RFC3339 timestamp of the most recent key rotation (normal, not emergency).
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub rotated_at: Option<String>,
239    /// RFC3339 timestamp of emergency key rotation (set only if emergency rekey occurred).
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub emergency_rekey_at: Option<String>,
242}
243
244impl Default for IdentityConfig {
245    fn default() -> Self {
246        Self {
247            pubkey: String::new(),
248            owner: None,
249            algorithm: default_algorithm(),
250            key_version: 0,
251            created_at_key: None,
252            previous_pubkey: None,
253            previous_key_version: None,
254            grace_expires_at: None,
255            rotated_at: None,
256            emergency_rekey_at: None,
257        }
258    }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
262pub struct Persona {
263    pub category: PersonaCategory,
264    pub description: String,
265    pub traits: PersonaTraits,
266}
267
268#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
269#[serde(rename_all = "lowercase")]
270pub enum PersonaCategory {
271    Research,
272    Automation,
273    Monitor,
274    Notify,
275    Commerce,
276    Custom,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280pub struct PersonaTraits {
281    pub tone: String,
282    pub risk: String,
283    pub verbosity: String,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
287pub struct ModelConfig {
288    pub provider: String,
289    pub name: String,
290    #[serde(default)]
291    pub params: BTreeMap<String, serde_yaml_ng::Value>,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
295pub struct McpServerEntry {
296    pub name: String,
297    pub command: String,
298    #[serde(default)]
299    pub args: Vec<String>,
300
301    /// SHA-256 (hex, lowercase) of the binary at `command`'s resolved
302    /// path, captured at install time. `None` means the entry was
303    /// added before B0 M9.1 (back-compat) and rule-6 enforcement is
304    /// not applied — the supervisor will warn but not block.
305    /// (B0 rule 6 / M9.1)
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub binary_sha256: Option<String>,
308
309    /// SHA-256 (hex, lowercase) of the canonical-JSON of the MCP's
310    /// `tools/list` response, captured at install time. `None` means
311    /// the install path skipped the description probe (e.g. the MCP
312    /// uses a non-stdio transport or the binary couldn't be reached)
313    /// or the entry pre-dates M9. (B0 rule 6 / M9.1)
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub description_hash: Option<String>,
316
317    /// Display-only publisher metadata captured at install time so
318    /// the user can recall what they consented to. `None` for older
319    /// entries. (B0 rule 6 / M9.1)
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub publisher: Option<McpPublisherInfo>,
322
323    /// RFC3339 timestamp of when the entry was added or last
324    /// re-approved by the user via `mur agent mcp pin`. Used by the
325    /// rug-pull dialog UX. `None` for older entries. (B0 rule 6 / M9.1)
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
328
329    /// Per-tool-call timeout for this server, in seconds. `None` uses the
330    /// runtime default. Slow tools (e.g. `video_analyze`: transcript fetch
331    /// + local-model map-reduce) need a longer budget than the default.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub timeout_secs: Option<u32>,
334
335    /// Per-server outbound egress override. `None` = inherit the agent-level
336    /// policy (default; unchanged behavior). `Restricted` routes this server's
337    /// child through the runtime egress proxy with `allow_hosts` (advisory).
338    /// See `docs/superpowers/plans/2026-06-26-mcp-per-server-egress.md`.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub network: Option<McpServerNetwork>,
341
342    /// HTTP(S) base URL for a remote (Streamable-HTTP or SSE) MCP server.
343    /// Mutually exclusive with `command` in practice; `None` = stdio transport.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub url: Option<String>,
346
347    /// Authentication credentials for a remote MCP server.
348    /// `None` = no auth (or stdio transport).
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub auth: Option<McpAuth>,
351
352    /// External programs this artifact needs at runtime (portable-deps spec).
353    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
354    #[serde(default, skip_serializing_if = "Vec::is_empty")]
355    pub requires_programs: Vec<ProgramDep>,
356
357    /// Paths this server writes state into at runtime, declared at install
358    /// time so the sandbox can be told about them (issue #1161).
359    ///
360    /// Distinct from everything above: `command`, `args` and `package`
361    /// describe how the server is *launched*, and #1158 already syncs what the
362    /// rewritten launch line needs. These are what the server touches once it
363    /// is running — a property of the server, not of the command MUR rewrote.
364    /// `@wonderwhy-er/desktop-commander` wants three of them under `$HOME` and
365    /// exits 1 before answering `initialize` without them.
366    ///
367    /// Granted read+write, and **created if missing** at install time. The
368    /// sandbox drops entitlement paths that do not exist when the profile is
369    /// sealed, so granting a directory the server has not created yet would be
370    /// accepted and still denied by the kernel — see `reject_dead_grant`.
371    #[serde(default, skip_serializing_if = "Vec::is_empty")]
372    pub state_paths: Vec<String>,
373
374    /// Vendored package this entry launches, when MUR installed it itself.
375    ///
376    /// Present only for entries moved off a package runner by
377    /// `mur agent mcp vendor`. Its existence is what makes the contents of an
378    /// interpreter-launched server verifiable at all: `npx @scope/pkg` resolves
379    /// on every spawn and pins nothing, whereas a vendored install lives in a
380    /// directory MUR owns and can be checked before the agent comes up.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub package: Option<McpPackagePin>,
383}
384
385/// A package MUR installed itself, and the fingerprint that proves the
386/// installed tree hasn't changed.
387///
388/// `lockfile_sha256` hashes the install's `package-lock.json`, which already
389/// records an integrity hash for every package in the dependency tree — so one
390/// small file covers the whole tree, and startup verification stays cheap no
391/// matter how large `node_modules` grows.
392///
393/// The lockfile pins what was *installed*. Editing a file inside
394/// `node_modules` afterwards would not change it; catching that needs a full
395/// tree hash, which is deliberately not done here — see the module docs on
396/// `mur-core::cmd::agent_mcp_vendor` for where that line is drawn.
397#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default)]
398pub struct McpPackagePin {
399    /// Package ecosystem — `npm` today.
400    pub runner: String,
401    /// Package name, including any `@scope/` prefix.
402    pub name: String,
403    /// Exact installed version.
404    pub version: String,
405    /// Directory MUR installed into, absolute.
406    pub install_dir: String,
407    /// SHA-256 (lowercase hex) of `<install_dir>/package-lock.json`.
408    pub lockfile_sha256: String,
409
410    /// How many packages in the installed tree published no registry
411    /// signature, as reported by `npm audit signatures` at vendor time.
412    ///
413    /// `None` — the audit did not run (npm too old, or offline).
414    /// `Some(0)` — every package in the tree carried a verified signature.
415    /// `Some(n)` — `n` packages are unsigned; the rest verified.
416    ///
417    /// A signature that verifies proves the bytes came from the registry, which
418    /// the content hash cannot: it would faithfully pin a poisoned cache. An
419    /// *invalid* signature is not recorded here because it blocks the vendor
420    /// outright — that is an integrity failure, not a property to note.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub signatures_missing: Option<u32>,
423
424    /// SLSA predicate type of the package's build provenance, when it
425    /// publishes one — e.g. `https://slsa.dev/provenance/v1`. `None` means no
426    /// attestation was published (still the common case).
427    ///
428    /// Provenance ties a release back to a source repository and CI run, and
429    /// is the only signal here that can catch a **malicious publish**: a
430    /// content hash pins whatever was released, faithfully preserving a
431    /// poisoned version rather than detecting it. Recorded and shown, never
432    /// required — ecosystem coverage is far too thin to gate on.
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub provenance: Option<String>,
435}
436
437impl McpPackagePin {
438    /// Name of the lockfile whose hash is `lockfile_sha256`.
439    ///
440    /// npm writes `package-lock.json` itself; for PyPI, MUR generates one with
441    /// `uv pip compile --generate-hashes`, which records a sha256 for every
442    /// package in the resolved tree — the same property that lets one small
443    /// file stand in for the whole install.
444    pub fn lockfile_name(&self) -> &'static str {
445        match self.runner.as_str() {
446            "pypi" => "requirements.lock",
447            _ => "package-lock.json",
448        }
449    }
450
451    /// Absolute path of the lockfile this pin covers.
452    ///
453    /// The startup check, `inspect`, and the deep audit all resolve it through
454    /// here, so a newly supported ecosystem cannot end up verified against the
455    /// wrong file in one of them and silently pass.
456    pub fn lockfile_path(&self) -> std::path::PathBuf {
457        std::path::Path::new(&self.install_dir).join(self.lockfile_name())
458    }
459}
460
461/// Authentication scheme for a remote (HTTP) MCP server.
462#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
463#[serde(rename_all = "snake_case", tag = "kind")]
464pub enum McpAuth {
465    /// Static bearer token stored as a secret reference.
466    Bearer { token: crate::secret::SecretRef },
467    /// OAuth 2.1 token, with dynamic client registration state.
468    Oauth(OauthAuth),
469}
470
471/// OAuth 2.1 state persisted alongside remote MCP entry.
472#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
473pub struct OauthAuth {
474    /// Authorization-server token endpoint (from discovery).
475    pub token_endpoint: String,
476    /// Client id from dynamic client registration.
477    pub client_id: String,
478    /// Keychain ref to access token.
479    pub access_token: crate::secret::SecretRef,
480    /// Keychain ref refresh token, if server issued one.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub refresh_token: Option<crate::secret::SecretRef>,
483    /// Unix-epoch seconds access token expires (0 = unknown).
484    #[serde(default)]
485    pub expires_at: u64,
486}
487
488/// How an MCP server's outbound network is scoped.
489#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
490#[serde(rename_all = "snake_case")]
491pub enum McpNetMode {
492    /// No per-server policy and no proxy — the default.
493    ///
494    /// NOT "inherits `entitlements.network.outbound.allow_hosts`", despite the
495    /// name. That list is enforced in-process (a DNS guard on the runtime's own
496    /// HTTP client, plus the B0 gate on the agent's `network.*` tools), and a
497    /// spawned server never runs either. What a server here actually inherits
498    /// is the OS sandbox — which restricts by PORT, with the host left open.
499    ///
500    /// So an agent whose `allow_hosts` names one API still lets an `Inherit`
501    /// server reach any host on an allowed port. Use `Restricted` to bound a
502    /// server by host. The variant keeps its name because it is a serialized
503    /// wire value; the lie was the doc, and it is fixed here rather than
504    /// migrated.
505    #[default]
506    Inherit,
507    /// Allow only `allow_hosts`, routed through the runtime egress proxy.
508    Restricted,
509    /// Allow ALL hosts EXCEPT `deny_hosts`, routed through the runtime egress
510    /// proxy, with every CONNECT audited. For trusted-but-broad tools (e.g. a
511    /// web-research browser) that cannot enumerate their destinations. Requires
512    /// explicit operator consent (records `authorization`); downgraded to
513    /// `Inherit` on import (lowest trust). Advisory enforcement (see egress_proxy).
514    BroadAudited,
515    /// No outbound for this server at all.
516    Off,
517}
518
519/// Env var name a sandboxed MCP child reads to self-enforce the operator's
520/// `deny_hosts` overlay on connections the egress proxy cannot observe (e.g.
521/// `mur-research-gateway`'s tier-2/3 browser subprocesses — the proxy only
522/// sees tier-1 `reqwest` traffic). `mur-agent-runtime`'s `proxy_env_for` sets
523/// this on the child's env alongside the proxy vars; a cooperating child
524/// (currently `mur-research-gateway`, via `config::load`) reads it to source
525/// its own deny list. Single definition shared by both crates (CLAUDE.md
526/// rule 1: no duplicated literal).
527pub const ENV_MCP_DENY_HOSTS: &str = "MUR_RESEARCH_DENY_HOSTS";
528
529/// Per-MCP-server outbound egress policy.
530#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
531pub struct McpServerNetwork {
532    #[serde(default)]
533    pub mode: McpNetMode,
534    #[serde(default)]
535    pub allow_hosts: Vec<String>,
536    /// Deny overlay for `BroadAudited` mode: hosts blocked even though all
537    /// others are allowed. Ignored by `Restricted`/`Inherit`/`Off`.
538    #[serde(default)]
539    pub deny_hosts: Vec<String>,
540    /// Who authorized a `BroadAudited` grant, and when. `None` for other modes.
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub authorization: Option<EgressAuthorization>,
543}
544
545/// A plugin-group imported by one agent (add-on Phase 2). Self-contained:
546/// members are installed PER-AGENT (skills under
547/// `~/.mur/agents/<a>/skills/`, mcp appended to this profile's
548/// `mcp_servers`). No global library, no refcounting.
549///
550/// Fail-closed: `enabled` defaults to `false`. Only an explicit user
551/// toggle (CLI/Hub) or a trusted native installer flips it true — the
552/// importer always constructs it `false`.
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
554pub struct AddonRef {
555    /// e.g. "superpowers" (local) or "superpowers@claude-plugins-official".
556    pub id: String,
557    /// Provenance, free-text. e.g. "claude-local:superpowers@6.0.3".
558    pub source: String,
559    #[serde(default)]
560    pub enabled: bool,
561    #[serde(default, skip_serializing_if = "Vec::is_empty")]
562    pub skills: Vec<String>,
563    #[serde(default, skip_serializing_if = "Vec::is_empty")]
564    pub mcp: Vec<String>,
565    #[serde(default, skip_serializing_if = "Vec::is_empty")]
566    pub commands: Vec<String>,
567    /// Content-hash pin over the imported skill/command manifests, recorded
568    /// at import. `None` on legacy refs. Enables drift detection + refresh.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub content_hash: Option<String>,
571    /// The re-fetchable source (the original `import` argument: a local path
572    /// or `owner/repo`), distinct from the free-text provenance `source`.
573    /// `None` on legacy refs. Used by `reimport`.
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub fetch_ref: Option<String>,
576    /// The `--plugin <name>` selector used at import time to pick one plugin
577    /// out of a multi-plugin marketplace `fetch_ref`. `None` when the source
578    /// was a single-plugin dir/repo, or on legacy refs. Used by `reimport` so
579    /// a marketplace add-on can be re-fetched without re-specifying it.
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub fetch_plugin: Option<String>,
582}
583
584/// Display-only publisher metadata captured at install time. None of
585/// the fields are validated against any external authority — they're
586/// shown to the user during the install confirm prompt and reproduced
587/// in `mur agent mcp inspect` output so the user can audit who they
588/// thought they were trusting. (B0 rule 6 / M9.1)
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
590pub struct McpPublisherInfo {
591    /// Free-form publisher identifier — e.g. `"Anthropic"`,
592    /// `"@github-user-alice"`, or whatever `serverInfo.name` returned.
593    pub name: String,
594
595    /// Optional homepage / docs URL. Best-effort: extracted from the
596    /// MCP's `serverInfo.metadata.homepage` or registry entry when
597    /// available; otherwise left unset.
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub homepage: Option<String>,
600
601    /// Optional registry coordinate — e.g. `"@anthropic-mcp/weather@1.2.3"`.
602    /// Used purely for display; not consumed by any verification path.
603    #[serde(default, skip_serializing_if = "Option::is_none")]
604    pub registry_id: Option<String>,
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
608pub struct TransportConfig {
609    pub stdio: bool,
610    pub socket: SocketTransportConfig,
611    #[serde(default)]
612    pub tcp: TcpTransportConfig,
613    /// Track C5 — HTTP webhook receiver. Default off; enabling
614    /// requires an HMAC secret in the OS keychain (`SecretRef`).
615    /// See `docs/superpowers/specs/2026-05-05-mur-agent-c5-webhook-design.md`.
616    #[serde(default)]
617    pub webhook: WebhookTransportConfig,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
621pub struct TcpTransportConfig {
622    #[serde(default)]
623    pub enabled: bool,
624    #[serde(default)]
625    pub bind: String,
626    #[serde(default)]
627    pub noise: NoiseConfig,
628}
629
630/// HTTP webhook receiver — Track C5.
631///
632/// External systems POST `SharePayload`-shaped JSON to
633/// `http://<bind>:<port>/agents/<slug>/webhook` with an
634/// `X-Mur-Signature: sha256=<hex>` header carrying an HMAC-SHA256
635/// over the raw body. The HMAC secret is stored in the OS keychain
636/// via `SecretRef` (same pattern as Telegram bot tokens in C2);
637/// `hmac_secret_ref` is the `service:account` lookup key.
638///
639/// `bind` defaults to `127.0.0.1` so a fresh enable doesn't
640/// inadvertently expose the agent to the local network. Users who
641/// want VPN / Tailscale reachability override to `0.0.0.0` or the
642/// VPN interface address explicitly.
643#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
644pub struct WebhookTransportConfig {
645    #[serde(default)]
646    pub enabled: bool,
647    #[serde(default = "default_webhook_bind")]
648    pub bind: String,
649    #[serde(default = "default_webhook_port")]
650    pub port: u16,
651    /// `service:account` key into the OS keychain. Empty string
652    /// when `enabled = false`; required (and validated) at startup
653    /// when enabled.
654    #[serde(default)]
655    pub hmac_secret_ref: String,
656}
657
658fn default_webhook_bind() -> String {
659    "127.0.0.1".to_string()
660}
661
662fn default_webhook_port() -> u16 {
663    6789
664}
665
666impl Default for WebhookTransportConfig {
667    fn default() -> Self {
668        Self {
669            enabled: false,
670            bind: default_webhook_bind(),
671            port: default_webhook_port(),
672            hmac_secret_ref: String::new(),
673        }
674    }
675}
676
677#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
678pub struct NoiseConfig {
679    pub pattern: String,
680}
681
682impl Default for NoiseConfig {
683    fn default() -> Self {
684        Self {
685            pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
686        }
687    }
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
691pub struct SocketTransportConfig {
692    pub enabled: bool,
693    pub bind: String, // "unix:///path" or "tcp://host:port" (P0b)
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub auth: Option<AuthConfig>,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
699pub struct AuthConfig {
700    pub scheme: String,
701    pub token_file: String,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
705pub struct CommunicationConfig {
706    #[serde(default = "default_accepts_all")]
707    pub accepts_from: Vec<String>,
708    #[serde(default)]
709    pub sends_to: Vec<String>,
710}
711fn default_accepts_all() -> Vec<String> {
712    vec!["*".to_string()]
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
716pub struct Entitlements {
717    pub network: NetworkEntitlement,
718    pub filesystem: FilesystemEntitlement,
719    pub processes: ProcessesEntitlement,
720    #[serde(default)]
721    pub syscalls: SyscallsEntitlement,
722    #[serde(default)]
723    pub limits: LimitsEntitlement,
724    /// LLM call permission. Default = Allowed (back-compat). Bridges set to Off
725    /// so the supervisor refuses to construct an LLM client.
726    #[serde(default)]
727    pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
728    /// Per-tool allow/ask/deny policy. Empty = all tools use default (Ask).
729    #[serde(default, skip_serializing_if = "Vec::is_empty")]
730    pub tools: Vec<ToolRule>,
731    /// When `true` (the default), a sandbox apply failure is fatal: the agent
732    /// refuses to start rather than running advisory-only (unconfined).
733    /// Set to `false` only for development or trusted-workstation agents that
734    /// intentionally run without kernel sandbox enforcement.
735    #[serde(default = "default_true")]
736    pub fail_closed_on_sandbox_error: bool,
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
740pub struct NetworkEntitlement {
741    pub inbound: InboundNetwork,
742    pub outbound: OutboundNetwork,
743}
744
745#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
746pub struct InboundNetwork {
747    #[serde(default)]
748    pub ports: Vec<u16>,
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
752pub struct OutboundNetwork {
753    pub mode: NetworkOutboundMode,
754    #[serde(default)]
755    pub allow_hosts: Vec<String>,
756    /// Extra outbound TCP ports granted on top of the built-in web set
757    /// (`RESTRICTED_GENERAL_PORTS`: 80/443/8080/8443). Issue #006: without
758    /// this, a non-web port (ssh 2222, vite 5173, ollama 11434) was
759    /// unreachable under `restricted` and the only escape was
760    /// `unrestricted`, which opens EVERY port.
761    ///
762    /// Honored under `Restricted` ONLY. `Off` stays air-gapped and
763    /// `ProxyOnly` keeps denying general TCP — a stale entry in a profile
764    /// whose mode was later tightened must never silently reopen it.
765    ///
766    /// This is a PORT grant, not a host grant: like the base set, the port
767    /// opens to host `*`, because macOS SBPL's `remote tcp` accepts only
768    /// `*` or `localhost` as the host. Bounding WHICH host is reached on
769    /// that port remains HostGuard's job via `allow_hosts`.
770    #[serde(default, skip_serializing_if = "Vec::is_empty")]
771    pub allow_ports: Vec<u16>,
772    #[serde(default = "default_protocols")]
773    pub protocols: Vec<String>,
774    #[serde(default)]
775    pub resolve_dns: ResolveDnsConfig,
776}
777fn default_protocols() -> Vec<String> {
778    vec!["tcp".to_string()]
779}
780
781/// Record of who authorized a broad egress grant, and when. Attached to a
782/// per-MCP-server `McpServerNetwork` when its mode is `BroadAudited`, so the
783/// grant is persisted, portable, and re-approvable on import.
784#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
785pub struct EgressAuthorization {
786    pub authorized_by: String,
787    pub authorized_at_ms: u64,
788}
789
790#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
791#[serde(rename_all = "lowercase")]
792pub enum NetworkOutboundMode {
793    Unrestricted,
794    Restricted,
795    /// Deny all general outbound TCP; egress is ONLY via loopback proxies
796    /// (the agent's cc-proxy LLM port + the egress proxy). Hostnames are still
797    /// governed by `allow_hosts` (HostGuard) — unlike `Off`, which blocks all.
798    ProxyOnly,
799    Off,
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
803pub struct ResolveDnsConfig {
804    #[serde(default = "default_dns_mode")]
805    pub mode: String,
806    #[serde(default)]
807    pub servers: Vec<String>,
808}
809impl Default for ResolveDnsConfig {
810    fn default() -> Self {
811        Self {
812            mode: default_dns_mode(),
813            servers: vec![],
814        }
815    }
816}
817fn default_dns_mode() -> String {
818    "system".to_string()
819}
820
821/// Dirs under `<mur_home>` where MUR objects are authored.
822///
823/// The seeded concierge gets read+write on these; without them the one agent a
824/// fresh host has can describe a skill or workflow but cannot create one, and
825/// every answer ends in "run this command yourself".
826///
827/// Deliberately excludes `agents/`: `self_protected()` only covers an agent's
828/// OWN `profile.yaml` + `identity.key`, so write access there would let an
829/// agent author a sibling with unrestricted entitlements and start it, and
830/// read access would expose every other agent's Ed25519 signing key.
831///
832/// Deliberately excludes [`crate::paths::FLEETS`] for the same reason one
833/// level up: `fleet.yaml` names a fleet's members, limits and HITL
834/// pre-approvals, and `.stopped` is the operator's kill-switch. An agent that
835/// can write there can widen what a `fleet_run` it triggers is allowed to do,
836/// or clear the stop on it. Fleets are created with `mur fleet create`; the
837/// runtime already reads `fleets/` on its own (sandbox policy), so dropping
838/// the grant costs the concierge nothing it needs to *use* a fleet.
839pub const AUTHORING_DIRS: [&str; 3] = ["skills", "workflows", "artifacts"];
840
841#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
842pub struct FilesystemEntitlement {
843    #[serde(default)]
844    pub read: Vec<String>,
845    #[serde(default)]
846    pub write: Vec<String>,
847    #[serde(default)]
848    pub deny: Vec<String>,
849}
850
851#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
852pub struct ProcessesEntitlement {
853    pub spawn: SpawnEntitlement,
854}
855
856#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
857pub struct SpawnEntitlement {
858    pub mode: SpawnMode,
859    #[serde(default)]
860    pub allowed: Vec<String>,
861    /// Directories whose entire subtree may be exec'd — the "build lane".
862    ///
863    /// `allowed` cannot express a toolchain that compiles its own
864    /// executables: a Rust build execs `target/debug/build/<crate>-<hash>/
865    /// build-script-build`, proc-macro shims, and freshly linked test
866    /// binaries, all at paths that do not exist until the build creates them
867    /// and change on every dependency bump. Without this an agent granted
868    /// `cargo` could compile nothing and could never verify its own work.
869    ///
870    /// Grant narrowly — a build-output directory, not a source tree or a
871    /// home directory. Everything under it becomes exec'able, so the tree
872    /// should be one the agent already has write access to and nothing else
873    /// depends on. Filesystem and network entitlements still bound what the
874    /// executed code can reach.
875    #[serde(default)]
876    pub allowed_dirs: Vec<String>,
877}
878
879#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
880#[serde(rename_all = "lowercase")]
881pub enum SpawnMode {
882    Allowlist,
883    Any,
884    None,
885    /// Shell-only: fences the system exec paths (`/bin`, `/usr/bin`,
886    /// `/usr/lib`) that `Allowlist` mode exempts by default, so only the
887    /// resolved shell binary the `bash` tool itself spawns plus the
888    /// profile's own `spawn_allowed_paths`/`spawn_allowed_prefixes` may be
889    /// exec'd -- no other system binary (coreutils, `git`, etc.) is implied.
890    Strict,
891}
892
893#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
894pub struct SyscallsEntitlement {
895    #[serde(default = "default_syscalls_mode")]
896    pub mode: String,
897    #[serde(default)]
898    pub extra_deny: Vec<String>,
899}
900fn default_syscalls_mode() -> String {
901    "default".to_string()
902}
903
904#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
905pub struct LimitsEntitlement {
906    #[serde(default)]
907    pub cpu_seconds: Option<u64>,
908    #[serde(default = "default_memory_mb")]
909    pub memory_mb: u64,
910    #[serde(default = "default_fds")]
911    pub file_descriptors: u32,
912    #[serde(default = "default_procs")]
913    pub processes: u32,
914}
915fn default_memory_mb() -> u64 {
916    512
917}
918fn default_fds() -> u32 {
919    1024
920}
921fn default_procs() -> u32 {
922    32
923}
924
925#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
926#[serde(rename_all = "lowercase")]
927pub enum ToolPolicy {
928    Allow,
929    #[default]
930    Ask,
931    Deny,
932}
933
934#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
935pub struct ToolRule {
936    pub pattern: String,
937    pub policy: ToolPolicy,
938    /// Intrinsic risk tier of this tool (v3c). Resolved most-restrictive-wins
939    /// against per-step risk + channel policy; gates pre-execution when not Read.
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    pub risk: Option<crate::hitl::RiskTier>,
942}
943
944/// Resolve the effective policy for `tool_name` against an ordered rule list.
945///
946/// Precedence: exact-name match > longest-prefix glob (trailing `*`) > default (`Ask`).
947pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
948    resolve_tool_policy_opt(rules, tool_name).unwrap_or_default()
949}
950
951/// Like [`resolve_tool_policy`] but distinguishes "no rule matched" (`None`)
952/// from an explicit rule — for tools whose registration is already gated
953/// elsewhere (e.g. `fleet_run`'s config allowlist) and that therefore want a
954/// different default than `Ask` while still honoring explicit rules.
955pub fn resolve_tool_policy_opt(rules: &[ToolRule], tool_name: &str) -> Option<ToolPolicy> {
956    for rule in rules {
957        if rule.pattern == tool_name {
958            return Some(rule.policy);
959        }
960    }
961    let mut best: Option<(&ToolRule, usize)> = None;
962    for rule in rules {
963        if let Some(prefix) = rule.pattern.strip_suffix('*')
964            && tool_name.starts_with(prefix)
965        {
966            let len = prefix.len();
967            if best.is_none_or(|(_, best_len)| len > best_len) {
968                best = Some((rule, len));
969            }
970        }
971    }
972    best.map(|(rule, _)| rule.policy)
973}
974
975#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
976pub struct NotificationsConfig {
977    #[serde(default)]
978    pub on_task_complete: Vec<NotificationTarget>,
979    #[serde(default)]
980    pub on_error: Vec<NotificationTarget>,
981    #[serde(default)]
982    pub on_shutdown: Vec<NotificationTarget>,
983}
984
985#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
986#[serde(tag = "target", rename_all = "lowercase")]
987pub enum NotificationTarget {
988    Agent {
989        name: String,
990    },
991    Commander,
992    Email {
993        address: String,
994        #[serde(default)]
995        smtp_config_file: Option<String>,
996    },
997    Slack {
998        #[serde(default)]
999        channel: Option<String>,
1000        #[serde(default)]
1001        webhook_url_env: Option<String>,
1002    },
1003    Webpush {
1004        url: String,
1005    },
1006    Webhook {
1007        url: String,
1008        #[serde(default = "default_post")]
1009        method: String,
1010        #[serde(default)]
1011        auth: Option<String>,
1012    },
1013}
1014fn default_post() -> String {
1015    "POST".to_string()
1016}
1017
1018#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1019pub struct RetryConfig {
1020    pub llm: RetryPolicy,
1021    pub tool: RetryPolicy,
1022}
1023
1024#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1025pub struct RetryPolicy {
1026    pub max_retries: u32,
1027    pub backoff: BackoffStrategy,
1028    pub initial_delay_ms: u64,
1029    #[serde(default)]
1030    pub max_delay_ms: Option<u64>,
1031    #[serde(default)]
1032    pub retry_on: Vec<String>,
1033}
1034
1035#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1036#[serde(rename_all = "lowercase")]
1037pub enum BackoffStrategy {
1038    Linear,
1039    Exponential,
1040    Fixed,
1041}
1042
1043#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1044pub struct LifecycleConfig {
1045    pub restart: RestartPolicy,
1046    #[serde(default = "default_max_restarts")]
1047    pub max_restarts: u32,
1048    #[serde(default = "default_window")]
1049    pub restart_window_secs: u64,
1050    #[serde(default = "default_stop_timeout")]
1051    pub stop_timeout_secs: u64,
1052    #[serde(default = "default_mcp_required")]
1053    pub mcp_required: bool,
1054    #[serde(default)]
1055    pub execution: ExecutionMode,
1056    #[serde(default)]
1057    pub schedule: Vec<ScheduleEntry>,
1058    #[serde(default)]
1059    pub idle_triggers: Vec<IdleTrigger>,
1060}
1061fn default_max_restarts() -> u32 {
1062    3
1063}
1064fn default_window() -> u64 {
1065    600
1066}
1067fn default_stop_timeout() -> u64 {
1068    15
1069}
1070fn default_mcp_required() -> bool {
1071    true
1072}
1073
1074#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1075#[serde(rename_all = "snake_case")]
1076pub enum RestartPolicy {
1077    Never,
1078    OnFailure,
1079    Always,
1080}
1081
1082#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1083#[serde(rename_all = "snake_case")]
1084pub enum ExecutionMode {
1085    #[default]
1086    Daemon,
1087    OnDemand,
1088}
1089
1090/// Where an agent leaves a schedule it wants but cannot create.
1091///
1092/// An agent's schedules live in `lifecycle.schedule` inside its own
1093/// `profile.yaml`, and an agent may not write that file — the sandbox denies it
1094/// unconditionally so a running agent cannot widen its own entitlements and
1095/// restart into them. So "remind me at 10 tomorrow" cannot become a schedule
1096/// from the inside, however much the agent understands the request.
1097///
1098/// It becomes a proposal instead: a file in the agent's own home, which it may
1099/// write, that `mur agent schedule accept` turns into the real entry.
1100///
1101/// Public and shared because both halves must name the same directory. Two
1102/// spellings would not fail loudly — the agent would write proposals nobody
1103/// lists, which is the shape of failure this whole area keeps producing.
1104pub const SCHEDULE_PROPOSAL_DIR: &str = "schedule-proposals";
1105
1106/// File in the agent's home holding the id of the channel a fired schedule
1107/// leaves its reply in. One stable channel per agent, remembered rather than
1108/// re-derived (#1125).
1109pub const SCHEDULE_CHANNEL_FILE: &str = "schedule-channel";
1110
1111/// Marker file in the agent's home naming the channel that records chat-gate
1112/// decisions (`HitlResponse` events keyed by `action_hash`). Same shape as
1113/// `SCHEDULE_CHANNEL_FILE`: created on first use, replaced if it names a
1114/// channel that no longer loads.
1115pub const HITL_CHANNEL_FILE: &str = "hitl-channel";
1116
1117/// A schedule an agent asked for and a person has not yet granted.
1118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1119pub struct ScheduleProposal {
1120    pub cron: String,
1121    pub message: String,
1122    /// What the user actually said, kept verbatim: a cron expression is not
1123    /// reviewable on its own, and the reviewer is being asked whether this is
1124    /// what they meant.
1125    #[serde(default, skip_serializing_if = "Option::is_none")]
1126    pub asked_for: Option<String>,
1127    /// Proposed bound, carried verbatim onto the accepted [`ScheduleEntry`].
1128    /// Present exactly when the agent judged the request to name one occasion
1129    /// rather than a recurrence.
1130    #[serde(default, skip_serializing_if = "Option::is_none")]
1131    pub not_after: Option<String>,
1132    pub proposed_at: String,
1133}
1134
1135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1136pub struct ScheduleEntry {
1137    pub cron: String,
1138    pub message: String,
1139    #[serde(default, skip_serializing_if = "Option::is_none")]
1140    pub sends_to: Option<String>,
1141    /// Retire the entry once its next firing would fall after this instant
1142    /// (RFC3339 with offset). How a one-shot reminder is expressed: cron has no
1143    /// year field, so "tomorrow at 10:00" can only be written as an annual
1144    /// recurrence, and unbounded it turns a request for one morning into a
1145    /// perpetual commitment (#1119).
1146    ///
1147    /// A bound rather than a fired-yet flag, because the scheduler runs inside
1148    /// the agent's own sandbox where `profile.yaml` is denied
1149    /// (`SELF_PROTECTED_AGENT_FILES`, #712) — it cannot record that an entry has
1150    /// fired. Comparing the next firing against a stored instant needs no write
1151    /// at all, so the bound works where a flag structurally could not.
1152    #[serde(default, skip_serializing_if = "Option::is_none")]
1153    pub not_after: Option<String>,
1154}
1155
1156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1157pub struct IdleTrigger {
1158    /// Idle threshold in seconds. Fires when (now - last_activity) >= after_secs.
1159    pub after_secs: u64,
1160    /// Message body injected into the task runner when this trigger fires.
1161    pub message: String,
1162    /// Optional A2A peer to route the resulting reply to. None means the agent itself.
1163    #[serde(default, skip_serializing_if = "Option::is_none")]
1164    pub sends_to: Option<String>,
1165    /// Per-trigger refire cooldown in seconds. Prevents tight loops when the
1166    /// idle threshold is short and the runner finishes quickly. Default 600.
1167    #[serde(default = "default_idle_cooldown")]
1168    pub cooldown_secs: u64,
1169    /// When true, suppress firing during the agent's quiet-hours window.
1170    /// Default true — idle pings should not wake the user at 3 a.m.
1171    #[serde(default = "default_true")]
1172    pub respect_quiet_hours: bool,
1173}
1174
1175fn default_idle_cooldown() -> u64 {
1176    600
1177}
1178/// True if `name` is not present in a denylist (i.e. enabled).
1179pub fn name_enabled(denylist: &[String], name: &str) -> bool {
1180    !denylist.iter().any(|n| n == name)
1181}
1182
1183/// Add/remove `name` in a denylist. `enabled=true` removes it (idempotent),
1184/// `enabled=false` adds it once (idempotent).
1185pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
1186    if enabled {
1187        list.retain(|n| n != name);
1188    } else if !list.iter().any(|n| n == name) {
1189        list.push(name.to_string());
1190    }
1191}
1192
1193fn default_true() -> bool {
1194    true
1195}
1196
1197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1198pub struct FileTransferConfig {
1199    #[serde(default = "default_accept_max")]
1200    pub accept_incoming_file_max_bytes: u64,
1201    #[serde(default = "default_accept_total")]
1202    pub accept_incoming_total_per_hour: u64,
1203    #[serde(default = "default_approval_threshold")]
1204    pub require_approval_above_bytes: u64,
1205    #[serde(default = "default_reject_paths")]
1206    pub reject_paths: Vec<String>,
1207    #[serde(default = "default_allowed_mime")]
1208    pub allowed_mime_types: Vec<String>,
1209}
1210
1211impl Default for FileTransferConfig {
1212    fn default() -> Self {
1213        Self {
1214            accept_incoming_file_max_bytes: default_accept_max(),
1215            accept_incoming_total_per_hour: default_accept_total(),
1216            require_approval_above_bytes: default_approval_threshold(),
1217            reject_paths: default_reject_paths(),
1218            allowed_mime_types: default_allowed_mime(),
1219        }
1220    }
1221}
1222
1223fn default_accept_max() -> u64 {
1224    10_485_760
1225}
1226fn default_accept_total() -> u64 {
1227    104_857_600
1228}
1229fn default_approval_threshold() -> u64 {
1230    10_485_760
1231}
1232fn default_reject_paths() -> Vec<String> {
1233    vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
1234}
1235fn default_allowed_mime() -> Vec<String> {
1236    vec!["*".into()]
1237}
1238
1239#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1240#[serde(rename_all = "snake_case")]
1241pub enum DeploymentType {
1242    #[default]
1243    Laptop,
1244    Vm,
1245    Docker,
1246    K8s,
1247    Lambda,
1248}
1249
1250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1251pub struct DeploymentConfig {
1252    #[serde(rename = "type", default)]
1253    pub deployment_type: DeploymentType,
1254    #[serde(default, skip_serializing_if = "Option::is_none")]
1255    pub region: Option<String>,
1256    #[serde(default = "default_env")]
1257    pub environment: Option<String>,
1258}
1259
1260impl Default for DeploymentConfig {
1261    fn default() -> Self {
1262        Self {
1263            deployment_type: DeploymentType::default(),
1264            region: None,
1265            environment: default_env(),
1266        }
1267    }
1268}
1269
1270fn default_env() -> Option<String> {
1271    Some("dev".into())
1272}
1273
1274/// One filesystem grant the sandbox refused to install, and why.
1275///
1276/// The grant stays in `profile.yaml` — this records that it did not reach the
1277/// kernel, which is otherwise knowable only from a WARN line in a log nobody
1278/// queries.
1279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1280pub struct DroppedGrant {
1281    pub path: String,
1282    /// `"read"` or `"write"`.
1283    pub verb: String,
1284    pub reason: String,
1285}
1286
1287/// Digest of the filesystem half of a profile's entitlements.
1288///
1289/// Narrower than `card_digest` on purpose: that one moves whenever any profile
1290/// field does, so using it to flag "grants changed since this agent started"
1291/// would raise a false alarm on an unrelated edit — and a status line that
1292/// cries wolf is one people stop reading.
1293pub fn filesystem_grants_digest(fs: &FilesystemEntitlement) -> String {
1294    use sha2::{Digest, Sha256};
1295    let mut h = Sha256::new();
1296    for (label, list) in [("r", &fs.read), ("w", &fs.write), ("d", &fs.deny)] {
1297        let mut sorted = list.clone();
1298        sorted.sort();
1299        for p in sorted {
1300            h.update(label.as_bytes());
1301            h.update(b"\0");
1302            h.update(p.as_bytes());
1303            h.update(b"\0");
1304        }
1305    }
1306    format!("sha256:{:x}", h.finalize())
1307}
1308
1309/// What the sandbox actually installed, recorded at the moment it sealed.
1310///
1311/// A seatbelt profile cannot be widened after `sandbox_init`, so this is fixed
1312/// for the process's lifetime — the same lifetime as the lock file it rides in.
1313/// Without it, `profile.yaml` is the only readable account of an agent's
1314/// permissions, and it describes what was asked for rather than what took
1315/// effect.
1316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1317pub struct SandboxRecord {
1318    /// False means the kernel sandbox is NOT installed and only advisory hooks
1319    /// remain — the agent then has MORE access than its profile grants, which
1320    /// is the opposite of every other failure here and the one worth shouting.
1321    pub enforcing: bool,
1322    /// `"macos-sbpl"`, `"linux-landlock"`, `"advisory-only"`, …
1323    pub mode: String,
1324    /// Digest of `entitlements.filesystem` as sealed. Comparing it against the
1325    /// profile on disk answers "were grants changed since this agent started"
1326    /// without anyone tracking that — and unlike `card_digest` it does not move
1327    /// when an unrelated field does, so it cannot raise a false alarm.
1328    pub granted_digest: String,
1329    /// Grants that did not reach the kernel. Empty is the normal case.
1330    #[serde(default)]
1331    pub dropped: Vec<DroppedGrant>,
1332}
1333
1334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1335pub struct LockFile {
1336    pub schema: u32,
1337    pub uuid: String,
1338    pub name: String,
1339    pub pid: u32,
1340    pub ppid: u32,
1341    pub started_at: String,
1342    pub binary_version: String,
1343    pub transports: LockTransports,
1344    pub card_digest: String,
1345    pub capabilities: Vec<String>,
1346    /// Git sha the running binary was built from (mur_common::build::SHORT_SHA).
1347    /// Empty = an old lock predating this field. Drives stale detection.
1348    #[serde(default)]
1349    pub build_sha: String,
1350    /// A2A method-surface version this runtime supports (A2A_PROTO_VERSION).
1351    /// 0 = an old lock; the dial gates versioned methods on it.
1352    #[serde(default)]
1353    pub proto_version: u32,
1354    /// What the sandbox installed at seal time. `None` = a lock written before
1355    /// this field existed, or a platform that installs no sandbox.
1356    #[serde(default, skip_serializing_if = "Option::is_none")]
1357    pub sandbox: Option<SandboxRecord>,
1358}
1359
1360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1361pub struct LockTransports {
1362    pub stdio: bool,
1363    #[serde(default)]
1364    pub unix_socket: Option<String>,
1365    #[serde(default)]
1366    pub tcp: Option<String>,
1367    /// C5 / M5.3 — webhook listener URL (e.g. `http://127.0.0.1:6789`).
1368    /// Populated by the supervisor when `transport.webhook.enabled =
1369    /// true` so peers and the commander can discover the live
1370    /// endpoint without re-reading `profile.yaml`.
1371    #[serde(default)]
1372    pub webhook: Option<String>,
1373}
1374
1375// ──────────────────────────────────────────────────────────────────────────
1376// Voice I/O configuration (D1 — Kokoro 82M TTS + whisper.cpp STT)
1377// ──────────────────────────────────────────────────────────────────────────
1378
1379/// Kokoro 82M voice identity. Maps to the per-voice style vector
1380/// embedded in the Kokoro ONNX model.
1381#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1382#[serde(rename_all = "snake_case")]
1383pub enum VoiceId {
1384    /// Default: Kokoro af_heart voice.
1385    #[default]
1386    AfHeart,
1387    AfBella,
1388    AfNicole,
1389    AmAdam,
1390    AmMichael,
1391}
1392
1393impl VoiceId {
1394    /// Index into the Kokoro voices.bin style matrix (row index).
1395    pub fn style_index(&self) -> usize {
1396        match self {
1397            VoiceId::AfHeart => 0,
1398            VoiceId::AfBella => 1,
1399            VoiceId::AfNicole => 2,
1400            VoiceId::AmAdam => 3,
1401            VoiceId::AmMichael => 4,
1402        }
1403    }
1404
1405    /// Canonical lowercase string representation (matches `FromStr` inputs).
1406    pub fn as_str(&self) -> &'static str {
1407        match self {
1408            VoiceId::AfHeart => "af_heart",
1409            VoiceId::AfBella => "af_bella",
1410            VoiceId::AfNicole => "af_nicole",
1411            VoiceId::AmAdam => "am_adam",
1412            VoiceId::AmMichael => "am_michael",
1413        }
1414    }
1415}
1416
1417impl std::str::FromStr for VoiceId {
1418    type Err = anyhow::Error;
1419
1420    fn from_str(s: &str) -> anyhow::Result<Self> {
1421        match s {
1422            "af_heart" => Ok(VoiceId::AfHeart),
1423            "af_bella" => Ok(VoiceId::AfBella),
1424            "af_nicole" => Ok(VoiceId::AfNicole),
1425            "am_adam" => Ok(VoiceId::AmAdam),
1426            "am_michael" => Ok(VoiceId::AmMichael),
1427            other => anyhow::bail!(
1428                "unknown voice ID '{other}' \
1429                 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
1430            ),
1431        }
1432    }
1433}
1434
1435/// Per-agent voice I/O configuration (D1).
1436/// Default = disabled so existing profiles continue to load unchanged.
1437#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1438pub struct VoiceConfig {
1439    /// Whether TTS (Kokoro) + STT (whisper.cpp) are enabled.
1440    #[serde(default)]
1441    pub enabled: bool,
1442    /// Kokoro voice identity for TTS output. Default: af_heart.
1443    #[serde(default)]
1444    pub voice_id: VoiceId,
1445    /// Optional cpal input device name for mic capture.
1446    /// None means the OS default input device.
1447    #[serde(default, skip_serializing_if = "Option::is_none")]
1448    pub input_device: Option<String>,
1449}
1450
1451// ──────────────────────────────────────────────────────────────────────────
1452// Human-in-the-loop configuration (Phase 2)
1453// ──────────────────────────────────────────────────────────────────────────
1454
1455#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1456pub struct HitlConfig {
1457    #[serde(default = "default_hitl_timeout_secs")]
1458    pub timeout_secs: u32,
1459    /// IGNORED since 2.79 (kept so old profiles load; warned at agent start).
1460    /// Bounds live in `limits:` — see `mur limits <agent>`.
1461    #[serde(default)]
1462    pub max_iterations: Option<u32>,
1463    /// IGNORED since 2.79 (kept so old profiles load; warned at agent start).
1464    /// Bounds live in `limits:` — see `mur limits <agent>`.
1465    #[serde(default)]
1466    pub max_tokens: Option<u64>,
1467    /// How far this agent carries a turn before handing back (issue #001):
1468    /// `continue` / `review` / `ask`. `None` = inherit the built-in default,
1469    /// which is the strictest (`ask`) — turning an agent loose is a thing you
1470    /// write down, never a thing you get by leaving a key out.
1471    ///
1472    /// Lives here, beside `timeout_secs`, because it is human-in-the-loop
1473    /// vocabulary; it does NOT live in `limits:`, which is budgets. The two
1474    /// are enforced at different seams and must not be confusable.
1475    #[serde(default, skip_serializing_if = "Option::is_none")]
1476    pub autonomy: Option<crate::hitl::Autonomy>,
1477}
1478
1479fn default_hitl_timeout_secs() -> u32 {
1480    300
1481}
1482
1483impl Default for HitlConfig {
1484    fn default() -> Self {
1485        Self {
1486            timeout_secs: default_hitl_timeout_secs(),
1487            max_iterations: None,
1488            max_tokens: None,
1489            autonomy: None,
1490        }
1491    }
1492}
1493
1494#[cfg(test)]
1495mod hitl_tests {
1496    use super::*;
1497
1498    /// #001: an agent profile that says nothing about autonomy inherits the
1499    /// strict default. Absent must never read as "turn it loose".
1500    #[test]
1501    fn hitl_config_autonomy_absent_means_inherit_not_continue() {
1502        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60").unwrap();
1503        assert_eq!(cfg.autonomy, None);
1504        assert_eq!(cfg.autonomy.unwrap_or_default(), crate::hitl::Autonomy::Ask);
1505    }
1506
1507    #[test]
1508    fn hitl_config_autonomy_parses_all_three_modes() {
1509        for (yaml, want) in [
1510            ("continue", crate::hitl::Autonomy::Continue),
1511            ("review", crate::hitl::Autonomy::Review),
1512            ("ask", crate::hitl::Autonomy::Ask),
1513        ] {
1514            let cfg: HitlConfig =
1515                serde_yaml::from_str(&format!("timeout_secs: 60\nautonomy: {yaml}")).unwrap();
1516            assert_eq!(cfg.autonomy, Some(want), "yaml={yaml}");
1517        }
1518    }
1519
1520    #[test]
1521    fn hitl_config_default_max_iterations_is_none() {
1522        let cfg = HitlConfig::default();
1523        assert!(cfg.max_iterations.is_none());
1524    }
1525
1526    #[test]
1527    fn hitl_config_max_iterations_explicit() {
1528        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1529        assert_eq!(cfg.max_iterations, Some(5));
1530    }
1531
1532    #[test]
1533    fn hitl_config_default_max_tokens_is_none() {
1534        let cfg = HitlConfig::default();
1535        assert!(cfg.max_tokens.is_none());
1536    }
1537
1538    #[test]
1539    fn hitl_config_max_tokens_explicit() {
1540        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1541        assert_eq!(cfg.max_tokens, Some(250_000));
1542    }
1543}
1544
1545// ──────────────────────────────────────────────────────────────────────────
1546// Companion subsystem (Phase 1.1+) — see
1547// docs/superpowers/specs/2026-04-29-mur-companion-phase-1-1-design.md §3.1
1548// ──────────────────────────────────────────────────────────────────────────
1549
1550#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1551pub struct CompanionConfig {
1552    #[serde(default)]
1553    pub enabled: bool,
1554    #[serde(default = "default_locale")]
1555    pub locale: String,
1556    #[serde(default)]
1557    pub relationship: Relationship,
1558    #[serde(default)]
1559    pub voice_overrides: VoiceOverrides,
1560    #[serde(default)]
1561    pub onboarding: OnboardingState,
1562    #[serde(default)]
1563    pub rhythm: RhythmConfig,
1564    #[serde(default)]
1565    pub proactive: ProactiveConfig,
1566}
1567
1568/// Resolve a default BCP-47 locale: the OS locale first (sys-locale already
1569/// returns BCP-47, e.g. `zh-Hant-TW`), then the `LANG` environment variable
1570/// (POSIX form `zh_TW.UTF-8` → `zh-TW`), then `en-US`.
1571///
1572/// OS-first matters because this is also the serde default for
1573/// `AgentProfile.locale`: under launchd there is no `LANG`, so the old
1574/// LANG-only resolution silently defaulted every headless agent to `en-US`
1575/// even on a non-English system.
1576pub fn default_locale() -> String {
1577    sys_locale::get_locale()
1578        .filter(|l| !l.is_empty())
1579        .or_else(|| std::env::var("LANG").ok().and_then(|v| normalize_lang(&v)))
1580        .unwrap_or_else(|| "en-US".into())
1581}
1582
1583/// Parse a POSIX-style `LANG` value into BCP-47 (`zh_TW.UTF-8` → `zh-TW`).
1584fn normalize_lang(v: &str) -> Option<String> {
1585    v.split('.')
1586        .next()
1587        .map(|s| s.replace('_', "-"))
1588        .filter(|s| !s.is_empty())
1589}
1590
1591#[cfg(test)]
1592mod locale_tests {
1593    use super::normalize_lang;
1594
1595    #[test]
1596    fn lang_with_encoding_and_region_normalizes() {
1597        assert_eq!(normalize_lang("zh_TW.UTF-8").as_deref(), Some("zh-TW"));
1598    }
1599
1600    #[test]
1601    fn lang_without_encoding_normalizes() {
1602        assert_eq!(normalize_lang("en_US").as_deref(), Some("en-US"));
1603    }
1604
1605    #[test]
1606    fn lang_with_script_keeps_script() {
1607        assert_eq!(
1608            normalize_lang("zh_Hant_TW.UTF-8").as_deref(),
1609            Some("zh-Hant-TW")
1610        );
1611    }
1612
1613    #[test]
1614    fn empty_lang_yields_none() {
1615        assert_eq!(normalize_lang(""), None);
1616    }
1617}
1618
1619#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1620pub struct VoiceOverrides {
1621    #[serde(default, skip_serializing_if = "Option::is_none")]
1622    pub name_for_user: Option<String>,
1623    #[serde(default, skip_serializing_if = "Option::is_none")]
1624    pub formality: Option<Formality>,
1625    #[serde(default, skip_serializing_if = "Option::is_none")]
1626    pub extra_instructions: Option<String>,
1627}
1628
1629#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1630pub struct FirstMemory {
1631    pub text: String,
1632    pub established_at: chrono::DateTime<chrono::Utc>,
1633}
1634
1635#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1636pub struct OnboardingState {
1637    #[serde(default, skip_serializing_if = "Option::is_none")]
1638    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1639    #[serde(default)]
1640    pub version: u32,
1641    #[serde(default, skip_serializing_if = "Option::is_none")]
1642    pub agent_display_name: Option<String>,
1643    #[serde(default, skip_serializing_if = "Option::is_none")]
1644    pub first_memory: Option<FirstMemory>,
1645}
1646
1647/// Phase 1.2 reservation. 1.1 keeps `enabled = false` (rhythm collection is
1648/// out of 1.1 scope).
1649#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1650pub struct RhythmConfig {
1651    #[serde(default)]
1652    pub enabled: bool,
1653}
1654
1655#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1656pub struct ProactiveConfig {
1657    #[serde(default)]
1658    pub enabled: bool,
1659    /// 1.1 reserves the field; 1.2 will write `now + 7d` at rhythm-enable.
1660    #[serde(default, skip_serializing_if = "Option::is_none")]
1661    pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1662    #[serde(default, skip_serializing_if = "Option::is_none")]
1663    pub quiet_hours: Option<QuietHours>,
1664    #[serde(default, skip_serializing_if = "Option::is_none")]
1665    pub active_hours: Option<ActiveHours>,
1666    #[serde(default = "default_daily_cap")]
1667    pub daily_cap: u8,
1668    #[serde(default = "default_channels")]
1669    pub channels: Vec<String>,
1670    #[serde(default, skip_serializing_if = "Option::is_none")]
1671    pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1672}
1673
1674impl Default for ProactiveConfig {
1675    fn default() -> Self {
1676        Self {
1677            enabled: false,
1678            learning_until: None,
1679            quiet_hours: None,
1680            active_hours: None,
1681            daily_cap: default_daily_cap(),
1682            channels: default_channels(),
1683            paused_until: None,
1684        }
1685    }
1686}
1687
1688fn default_daily_cap() -> u8 {
1689    3
1690}
1691fn default_channels() -> Vec<String> {
1692    vec!["stdout".into()]
1693}
1694
1695#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1696pub struct QuietHours {
1697    pub start: String,
1698    pub end: String,
1699}
1700
1701#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1702pub struct ActiveHours {
1703    pub start: String,
1704    pub end: String,
1705}
1706
1707// ──────────────────────────────────────────────────────────────────────────
1708// Hub companion appearance (M-h3)
1709// ──────────────────────────────────────────────────────────────────────────
1710
1711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1712pub struct AgentAppearance {
1713    /// ID of the active style preset (e.g. "chiikawa", "default-blob").
1714    #[serde(default = "default_style_preset")]
1715    pub style_preset: String,
1716    #[serde(default)]
1717    pub behavior_preset: BehaviorPreset,
1718    /// Required for the polaroid family; none for all others.
1719    #[serde(default, skip_serializing_if = "Option::is_none")]
1720    pub source_image_path: Option<std::path::PathBuf>,
1721    /// Local dir where rendered .webp expression frames are stored.
1722    #[serde(default = "default_expressions_dir")]
1723    pub expressions_dir: std::path::PathBuf,
1724    #[serde(default, skip_serializing_if = "Option::is_none")]
1725    pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1726    #[serde(default)]
1727    pub render_status: RenderStatus,
1728}
1729
1730fn default_style_preset() -> String {
1731    "default-blob".into()
1732}
1733
1734fn default_expressions_dir() -> std::path::PathBuf {
1735    std::path::PathBuf::from("expressions")
1736}
1737
1738impl Default for AgentAppearance {
1739    fn default() -> Self {
1740        Self {
1741            style_preset: default_style_preset(),
1742            behavior_preset: BehaviorPreset::Normal,
1743            source_image_path: None,
1744            expressions_dir: default_expressions_dir(),
1745            last_rendered_at: None,
1746            render_status: RenderStatus::Pending,
1747        }
1748    }
1749}
1750
1751#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1752#[serde(rename_all = "snake_case")]
1753pub enum BehaviorPreset {
1754    Quiet,
1755    #[default]
1756    Normal,
1757    Lively,
1758}
1759
1760#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1761#[serde(tag = "status", rename_all = "snake_case")]
1762pub enum RenderStatus {
1763    #[default]
1764    Pending,
1765    Rendering {
1766        done: u8,
1767        total: u8,
1768    },
1769    Ready,
1770    Failed {
1771        reason: String,
1772    },
1773}
1774
1775// ──────────────────────────────────────────────────────────────────────────
1776// E6 — Agent Pattern Federation types
1777// ──────────────────────────────────────────────────────────────────────────
1778
1779/// When the agent pulls an updated pattern snapshot from the daemon.
1780#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1781#[serde(rename_all = "kebab-case")]
1782pub enum SnapshotPolicy {
1783    #[default]
1784    PullOnStart,
1785    PullPeriodic,
1786    Manual,
1787}
1788
1789/// Filter criteria for the pattern snapshot written to the agent's patterns_cache.
1790#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1791pub struct PatternFilter {
1792    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1793    pub applies_in: Vec<String>,
1794    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1795    pub tier: Vec<String>,
1796    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1797    pub maturity: Vec<String>,
1798    #[serde(default)]
1799    pub importance_min: f64,
1800    #[serde(default = "default_max_snapshot_count")]
1801    pub max_count: usize,
1802    #[serde(default)]
1803    pub snapshot_policy: SnapshotPolicy,
1804}
1805
1806fn default_max_snapshot_count() -> usize {
1807    200
1808}
1809
1810impl Default for PatternFilter {
1811    fn default() -> Self {
1812        Self {
1813            applies_in: vec![],
1814            tier: vec![],
1815            maturity: vec![],
1816            importance_min: 0.0,
1817            max_count: 200,
1818            snapshot_policy: SnapshotPolicy::default(),
1819        }
1820    }
1821}
1822
1823/// Points to the knowledge-layer commit this agent's patterns_cache was built from.
1824#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1825pub struct SnapshotRef {
1826    pub knowledge_commit: String,
1827    pub taken_at: String,
1828    pub filter: PatternFilter,
1829}
1830
1831/// Federation configuration embedded in AgentProfile (E6).
1832#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1833pub struct FederationConfig {
1834    #[serde(default)]
1835    pub filter: PatternFilter,
1836    #[serde(default, skip_serializing_if = "Option::is_none")]
1837    pub snapshot_ref: Option<SnapshotRef>,
1838    #[serde(default)]
1839    pub evidence_flush_interval_minutes: u32,
1840}
1841
1842impl AgentProfile {
1843    /// Minimal valid profile for tests — no voice, no MCP, no skills.
1844    ///
1845    /// Available in all compilation modes so integration tests in
1846    /// dependent crates can call it (unlike `#[cfg(test)]` items which
1847    /// are invisible to downstream test binaries).
1848    #[doc(hidden)]
1849    pub fn default_for_tests() -> Self {
1850        serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1851            .expect("minimal profile fixture")
1852    }
1853
1854    /// This agent's Smart override, wherever it lives: the promoted `smart`
1855    /// field, else the legacy `routing.smart` nesting that older profiles and
1856    /// exported `.muragent` bundles still carry. `None` = follow the global
1857    /// setting.
1858    ///
1859    /// Every reader goes through here. A surface that checked only the
1860    /// promoted field would report "follows global" for an agent whose legacy
1861    /// override is actually in force — one fact, two answers.
1862    pub fn smart_override(&self) -> Option<&crate::config::SmartOverride> {
1863        self.smart
1864            .as_ref()
1865            .or_else(|| self.routing.as_ref().and_then(|r| r.smart.as_ref()))
1866    }
1867
1868    /// This agent's effective Smart config: the global values with the agent's
1869    /// override layered on.
1870    pub fn effective_smart(
1871        &self,
1872        cfg: &crate::config::ModelSwitchConfig,
1873    ) -> crate::config::SmartConfig {
1874        cfg.smart.merged(self.smart_override())
1875    }
1876
1877    /// This agent's effective difficulty-routing config.
1878    pub fn effective_routing(
1879        &self,
1880        cfg: &crate::config::ModelSwitchConfig,
1881    ) -> crate::config::RoutingConfig {
1882        cfg.routing.merged(self.routing.as_ref())
1883    }
1884
1885    /// Load an agent's profile from `<mur_home>/agents/<name>/profile.yaml`.
1886    ///
1887    /// Canonical read-path counterpart to the atomic-write path used by
1888    /// `mur agent create`/`mur agent mcp add` (`write_atomic` in
1889    /// `mur-core::cmd::agent`) — callers that already have `mur_home` in
1890    /// hand (e.g. provisioning flows, tests) can load a profile without
1891    /// going through the `MUR_HOME`-env-var-based `resolve_mur_home`.
1892    pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
1893        let path = mur_home.join("agents").join(name).join("profile.yaml");
1894        let yaml = std::fs::read_to_string(&path)
1895            .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
1896        serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
1897    }
1898
1899    /// The imported add-on group a skill/mcp/command name belongs to.
1900    pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1901        self.addons.iter().find(|g| {
1902            g.skills.iter().any(|n| n == name)
1903                || g.mcp.iter().any(|n| n == name)
1904                || g.commands.iter().any(|n| n == name)
1905        })
1906    }
1907
1908    /// Whether `skill_name` is enabled (§3.3): not denied AND, if it
1909    /// belongs to an imported group, that group is enabled.
1910    pub fn skill_enabled(&self, skill_name: &str) -> bool {
1911        name_enabled(&self.disabled_skills, skill_name)
1912            && self.group_of(skill_name).is_none_or(|g| g.enabled)
1913    }
1914
1915    /// Whether MCP server `server_id` is enabled (§3.3).
1916    pub fn mcp_enabled(&self, server_id: &str) -> bool {
1917        name_enabled(&self.disabled_mcp, server_id)
1918            && self.group_of(server_id).is_none_or(|g| g.enabled)
1919    }
1920
1921    /// Toggle a skill for this agent without uninstalling it.
1922    pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1923        set_denylist(&mut self.disabled_skills, skill_name, enabled);
1924    }
1925
1926    /// Toggle an MCP server for this agent without removing it.
1927    pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1928        set_denylist(&mut self.disabled_mcp, server_id, enabled);
1929    }
1930
1931    /// Toggle an imported plugin-group as a unit. Returns false if no
1932    /// add-on has that id.
1933    pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1934        match self.addons.iter_mut().find(|g| g.id == addon_id) {
1935            Some(g) => {
1936                g.enabled = enabled;
1937                true
1938            }
1939            None => false,
1940        }
1941    }
1942
1943    /// Emergency kill-switch (§7): clears every add-on group's `enabled` flag.
1944    /// Members are already forced off by the group AND-gate in `skill_enabled` /
1945    /// `mcp_enabled`, so no denylist push is needed — and avoiding it means
1946    /// `set_addon_enabled(id, true)` fully restores the group without leftover
1947    /// per-member denials.
1948    pub fn disable_all_addons(&mut self) {
1949        for g in &mut self.addons {
1950            g.enabled = false;
1951        }
1952    }
1953
1954    /// This agent's MCP servers minus any disabled for it.
1955    pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1956        self.mcp_servers
1957            .iter()
1958            .filter(|m| self.mcp_enabled(&m.name))
1959            .cloned()
1960            .collect()
1961    }
1962}
1963
1964#[cfg(test)]
1965mod tests {
1966    /// Order must not matter: the same grants written in a different order are
1967    /// the same grants, and a digest that disagreed would report "restart to
1968    /// apply" after a cosmetic profile edit.
1969    #[test]
1970    fn grants_digest_ignores_order_but_not_content() {
1971        let a = FilesystemEntitlement {
1972            read: vec!["/a".into(), "/b".into()],
1973            write: vec!["/w".into()],
1974            deny: vec![],
1975        };
1976        let reordered = FilesystemEntitlement {
1977            read: vec!["/b".into(), "/a".into()],
1978            ..a.clone()
1979        };
1980        let changed = FilesystemEntitlement {
1981            write: vec!["/w".into(), "/x".into()],
1982            ..a.clone()
1983        };
1984        assert_eq!(
1985            filesystem_grants_digest(&a),
1986            filesystem_grants_digest(&reordered)
1987        );
1988        assert_ne!(
1989            filesystem_grants_digest(&a),
1990            filesystem_grants_digest(&changed)
1991        );
1992    }
1993
1994    /// A read grant and a write grant for the same path are different grants.
1995    #[test]
1996    fn grants_digest_separates_the_verbs() {
1997        let r = FilesystemEntitlement {
1998            read: vec!["/p".into()],
1999            write: vec![],
2000            deny: vec![],
2001        };
2002        let w = FilesystemEntitlement {
2003            read: vec![],
2004            write: vec!["/p".into()],
2005            deny: vec![],
2006        };
2007        assert_ne!(filesystem_grants_digest(&r), filesystem_grants_digest(&w));
2008    }
2009
2010    /// A lock written before this field existed must still load — every agent
2011    /// running at upgrade time wrote one.
2012    #[test]
2013    fn a_lock_without_the_sandbox_block_still_deserialises() {
2014        let old = r#"{"schema":1,"uuid":"u","name":"n","pid":1,"ppid":0,
2015            "started_at":"t","binary_version":"v",
2016            "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2017        let lf: LockFile = serde_json::from_str(old).expect("old lock must load");
2018        assert!(lf.sandbox.is_none());
2019    }
2020
2021    #[test]
2022    fn the_sandbox_block_round_trips() {
2023        let rec = SandboxRecord {
2024            enforcing: false,
2025            mode: "advisory-only".into(),
2026            granted_digest: "sha256:x".into(),
2027            dropped: vec![DroppedGrant {
2028                path: "/gone".into(),
2029                verb: "write".into(),
2030                reason: "path does not exist on disk".into(),
2031            }],
2032        };
2033        let back: SandboxRecord =
2034            serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
2035        assert_eq!(back, rec);
2036    }
2037
2038    use super::*;
2039
2040    #[test]
2041    fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
2042        let net = McpServerNetwork {
2043            mode: McpNetMode::BroadAudited,
2044            allow_hosts: vec![],
2045            deny_hosts: vec!["evil.example".into()],
2046            authorization: Some(EgressAuthorization {
2047                authorized_by: "david".into(),
2048                authorized_at_ms: 1_750_000_000_000,
2049            }),
2050        };
2051        let y = serde_yaml::to_string(&net).unwrap();
2052        assert!(y.contains("broad_audited"));
2053        let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
2054        assert_eq!(back, net);
2055        // legacy per-server policy without the new fields still parses (serde default)
2056        let legacy: McpServerNetwork =
2057            serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
2058        assert_eq!(legacy.deny_hosts, Vec::<String>::new());
2059        assert!(legacy.authorization.is_none());
2060    }
2061
2062    #[test]
2063    fn mcp_entry_network_is_optional_and_round_trips() {
2064        // Absent in YAML → None (every existing profile keeps working).
2065        let bare = "name: x\ncommand: npx\n";
2066        let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
2067        assert!(e.network.is_none());
2068
2069        // Present → parsed.
2070        let with = "name: browser\ncommand: npx\nnetwork:\n  mode: restricted\n  allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
2071        let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
2072        let net = e2.network.expect("network present");
2073        assert_eq!(net.mode, McpNetMode::Restricted);
2074        assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
2075
2076        // Round-trip keeps None out of the serialized form.
2077        let out = serde_yaml_ng::to_string(&e).unwrap();
2078        assert!(!out.contains("network"));
2079    }
2080
2081    #[test]
2082    fn profile_round_trip_yaml() {
2083        let yaml = r#"
2084schema: 1
2085id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
2086name: agent_a
2087display_name: "Price Hunter"
2088version: "0.1.0"
2089persona:
2090  category: research
2091  description: "Finds prices"
2092  traits: { tone: concise, risk: cautious, verbosity: low }
2093sys_prompt_file: "sys_prompt.md"
2094model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
2095mcp_servers: []
2096skills: []
2097transport:
2098  stdio: true
2099  socket: { enabled: true, bind: "unix:///tmp/a.sock" }
2100communication: { accepts_from: ["*"], sends_to: [] }
2101capabilities: ["a2a.message.send", "a2a.tasks"]
2102entitlements:
2103  network:
2104    inbound: { ports: [] }
2105    outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
2106  filesystem: { read: [], write: [], deny: [] }
2107  processes: { spawn: { mode: allowlist, allowed: [] } }
2108  syscalls: { mode: default }
2109  limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
2110notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
2111retry:
2112  llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
2113  tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
2114lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
2115created_at: "2026-04-22T10:00:00+08:00"
2116updated_at: "2026-04-22T10:00:00+08:00"
2117"#;
2118        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
2119        assert_eq!(profile.name, "agent_a");
2120        assert_eq!(profile.persona.category, PersonaCategory::Research);
2121        assert_eq!(
2122            profile.entitlements.network.outbound.mode,
2123            NetworkOutboundMode::Restricted
2124        );
2125        let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
2126        let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
2127        assert_eq!(profile.id, round_tripped.id);
2128    }
2129
2130    #[test]
2131    fn requires_capabilities_defaults_empty_and_round_trips() {
2132        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2133        let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
2134        assert!(p.requires_capabilities.is_empty());
2135        let with = format!("{base}\nrequires_capabilities:\n  - media\n");
2136        let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
2137        assert_eq!(p2.requires_capabilities, vec!["media"]);
2138    }
2139}
2140
2141#[cfg(test)]
2142mod model_ref_tests {
2143    use super::*;
2144
2145    #[test]
2146    fn legacy_profile_without_model_ref_still_parses() {
2147        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2148        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2149        assert!(
2150            p.model_ref.is_none(),
2151            "legacy profile must not have model_ref"
2152        );
2153    }
2154
2155    #[test]
2156    fn round_trip_with_model_ref_preserves_field() {
2157        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2158        let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2159        p.model_ref = Some("anthropic_opus_4_7".into());
2160        let s = serde_yaml_ng::to_string(&p).unwrap();
2161        assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
2162        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2163        assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
2164    }
2165
2166    #[test]
2167    fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
2168        // Load fixture (no fallback_chain / routing) — legacy safe.
2169        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2170        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2171        assert!(
2172            p.fallback_chain.is_empty(),
2173            "legacy profile must have empty fallback_chain"
2174        );
2175        assert!(
2176            p.routing.is_none(),
2177            "legacy profile must have no routing override"
2178        );
2179
2180        // Round-trip with fallback_chain and routing.
2181        let mut p = p.clone();
2182        p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
2183        p.routing = Some(crate::config::RoutingOverride {
2184            enabled: Some(true),
2185            ..Default::default()
2186        });
2187        let s = serde_yaml_ng::to_string(&p).unwrap();
2188        assert!(
2189            s.contains("fallback_chain:"),
2190            "yaml must contain fallback_chain"
2191        );
2192        assert!(s.contains("routing:"), "yaml must contain routing");
2193        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
2194        assert_eq!(
2195            p2.fallback_chain,
2196            vec!["claude_opus", "claude_sonnet"],
2197            "fallback_chain must round-trip"
2198        );
2199        assert_eq!(
2200            p2.routing.as_ref().unwrap().enabled,
2201            Some(true),
2202            "routing.enabled must round-trip"
2203        );
2204    }
2205
2206    #[test]
2207    fn effective_smart_prefers_the_promoted_field_then_the_legacy_nesting() {
2208        use crate::config::{ModelSwitchConfig, SmartConfig, SmartOverride};
2209        let cfg = ModelSwitchConfig {
2210            smart: SmartConfig {
2211                enabled: false,
2212                cheap: Some("g".into()),
2213                max_escalations: 2,
2214            },
2215            ..Default::default()
2216        };
2217        // No override at all → the global values, untouched.
2218        let p = AgentProfile::default_for_tests();
2219        assert_eq!(p.effective_smart(&cfg), cfg.smart);
2220
2221        // Legacy profiles carry the override nested under `routing`.
2222        let mut legacy = AgentProfile::default_for_tests();
2223        legacy.routing = Some(crate::config::RoutingOverride {
2224            smart: Some(SmartOverride {
2225                enabled: Some(true),
2226                ..Default::default()
2227            }),
2228            ..Default::default()
2229        });
2230        assert!(
2231            legacy.effective_smart(&cfg).enabled,
2232            "legacy nesting is read"
2233        );
2234        assert_eq!(
2235            legacy.effective_smart(&cfg).cheap.as_deref(),
2236            Some("g"),
2237            "unset fields still inherit"
2238        );
2239
2240        // The promoted field wins when both are present.
2241        let mut both = legacy.clone();
2242        both.smart = Some(SmartOverride {
2243            enabled: Some(false),
2244            ..Default::default()
2245        });
2246        assert!(!both.effective_smart(&cfg).enabled);
2247    }
2248}
2249
2250/// GUI-facing reification of the companion's three-layer permission toggle.
2251///
2252/// On-disk schema doesn't change — this helper just maps between the
2253/// three independent booleans (`enabled`, `rhythm.enabled`,
2254/// `proactive.enabled`) and a single ordered tier. Use
2255/// [`ProactiveTier::from_config`] to read and [`ProactiveTier::apply`]
2256/// to write.
2257#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2258#[serde(rename_all = "snake_case")]
2259pub enum ProactiveTier {
2260    Off,
2261    WarmOnly,
2262    WarmAndBehavior,
2263    All,
2264}
2265
2266impl ProactiveTier {
2267    pub fn from_config(c: &CompanionConfig) -> Self {
2268        match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
2269            (false, _, _) => Self::Off,
2270            (true, false, false) => Self::WarmOnly,
2271            (true, true, false) => Self::WarmAndBehavior,
2272            (true, _, true) => Self::All,
2273        }
2274    }
2275
2276    pub fn apply(&self, c: &mut CompanionConfig) {
2277        match self {
2278            Self::Off => {
2279                c.enabled = false;
2280                c.rhythm.enabled = false;
2281                c.proactive.enabled = false;
2282            }
2283            Self::WarmOnly => {
2284                c.enabled = true;
2285                c.rhythm.enabled = false;
2286                c.proactive.enabled = false;
2287            }
2288            Self::WarmAndBehavior => {
2289                c.enabled = true;
2290                c.rhythm.enabled = true;
2291                c.proactive.enabled = false;
2292            }
2293            Self::All => {
2294                c.enabled = true;
2295                c.rhythm.enabled = true;
2296                c.proactive.enabled = true;
2297            }
2298        }
2299    }
2300}
2301
2302#[cfg(test)]
2303mod mcp_pin_tests {
2304    use super::*;
2305
2306    /// Pre-M9 profiles must continue to deserialize with the new
2307    /// optional fields absent. Round-trip: serialize back out and
2308    /// confirm the optional fields don't leak into the YAML.
2309    #[test]
2310    fn pre_m9_entry_roundtrips_without_pin_fields() {
2311        let yaml = r#"
2312name: weather
2313command: /opt/mcp/weather
2314args: ["--port", "0"]
2315"#;
2316        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2317        assert_eq!(entry.name, "weather");
2318        assert_eq!(entry.binary_sha256, None);
2319        assert_eq!(entry.description_hash, None);
2320        assert_eq!(entry.publisher, None);
2321        assert_eq!(entry.installed_at, None);
2322
2323        // skip_serializing_if = "Option::is_none" must keep the YAML
2324        // free of empty pin fields when the entry is pre-M9.
2325        let out = serde_yaml_ng::to_string(&entry).unwrap();
2326        assert!(!out.contains("binary_sha256"), "got {out}");
2327        assert!(!out.contains("description_hash"), "got {out}");
2328        assert!(!out.contains("publisher"), "got {out}");
2329        assert!(!out.contains("installed_at"), "got {out}");
2330    }
2331
2332    /// Full M9 entry with all fields set round-trips losslessly.
2333    #[test]
2334    fn full_m9_entry_roundtrips_all_fields() {
2335        let yaml = r#"
2336name: weather
2337command: /opt/mcp/weather
2338args: []
2339binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
2340description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
2341publisher:
2342  name: "@anthropic-mcp/weather"
2343  homepage: "https://github.com/anthropic-mcp/weather"
2344  registry_id: "@anthropic-mcp/weather@1.2.3"
2345installed_at: "2026-05-06T08:00:00Z"
2346"#;
2347        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2348        assert!(
2349            entry
2350                .binary_sha256
2351                .as_deref()
2352                .unwrap()
2353                .starts_with("3f4abca8")
2354        );
2355        assert!(
2356            entry
2357                .description_hash
2358                .as_deref()
2359                .unwrap()
2360                .starts_with("9a01b2c3")
2361        );
2362        let pub_info = entry.publisher.clone().unwrap();
2363        assert_eq!(pub_info.name, "@anthropic-mcp/weather");
2364        assert_eq!(
2365            pub_info.homepage.as_deref(),
2366            Some("https://github.com/anthropic-mcp/weather"),
2367        );
2368        assert_eq!(
2369            pub_info.registry_id.as_deref(),
2370            Some("@anthropic-mcp/weather@1.2.3"),
2371        );
2372        let installed = entry.installed_at.unwrap();
2373        assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
2374    }
2375
2376    /// Partial — only the binary hash is set (e.g. probe failed but
2377    /// install proceeded). The supervisor still needs to be able to
2378    /// deserialize this without panicking.
2379    #[test]
2380    fn partial_pin_only_binary_sha_roundtrips() {
2381        let yaml = r#"
2382name: weather
2383command: /opt/mcp/weather
2384args: []
2385binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
2386"#;
2387        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2388        assert_eq!(
2389            entry.binary_sha256.as_deref(),
2390            Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
2391        );
2392        assert_eq!(entry.description_hash, None);
2393        assert_eq!(entry.publisher, None);
2394    }
2395
2396    /// Publisher with only the required `name` field — homepage and
2397    /// registry_id are optional.
2398    #[test]
2399    fn publisher_minimal_just_name() {
2400        let yaml = r#"
2401name: weather
2402command: /opt/mcp/weather
2403args: []
2404publisher:
2405  name: "alice"
2406"#;
2407        let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
2408        let p = entry.publisher.as_ref().unwrap();
2409        assert_eq!(p.name, "alice");
2410        assert_eq!(p.homepage, None);
2411        assert_eq!(p.registry_id, None);
2412
2413        // skip_serializing_if must omit the optional sub-fields too.
2414        let out = serde_yaml_ng::to_string(&entry).unwrap();
2415        assert!(!out.contains("homepage:"), "got {out}");
2416        assert!(!out.contains("registry_id:"), "got {out}");
2417    }
2418}
2419
2420#[cfg(test)]
2421mod voice_tests {
2422    use super::*;
2423    use std::str::FromStr;
2424
2425    #[test]
2426    fn voice_config_round_trips() {
2427        // Base: use the canonical minimal fixture and append a voice: block.
2428        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2429        let yaml = format!("{base}voice:\n  enabled: true\n  voice_id: af_bella\n");
2430
2431        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
2432        assert!(profile.voice.enabled);
2433        assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
2434
2435        // Legacy profiles (no voice: block) must still load.
2436        let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
2437        assert!(!legacy.voice.enabled);
2438        assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
2439    }
2440
2441    #[test]
2442    fn voice_id_from_str_roundtrips() {
2443        let cases = [
2444            ("af_heart", VoiceId::AfHeart),
2445            ("af_bella", VoiceId::AfBella),
2446            ("af_nicole", VoiceId::AfNicole),
2447            ("am_adam", VoiceId::AmAdam),
2448            ("am_michael", VoiceId::AmMichael),
2449        ];
2450        for (s, expected) in cases {
2451            assert_eq!(VoiceId::from_str(s).unwrap(), expected);
2452            assert_eq!(expected.as_str(), s);
2453        }
2454    }
2455
2456    #[test]
2457    fn voice_id_from_str_rejects_unknown() {
2458        assert!(VoiceId::from_str("bogus").is_err());
2459    }
2460}
2461
2462#[cfg(test)]
2463mod idle_trigger_tests {
2464    use super::*;
2465
2466    #[test]
2467    fn idle_trigger_yaml_round_trip() {
2468        let yaml = r#"
2469restart: on_failure
2470idle_triggers:
2471  - after_secs: 3600
2472    message: "still there?"
2473    sends_to: other_agent
2474    cooldown_secs: 1800
2475    respect_quiet_hours: true
2476"#;
2477        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2478        assert_eq!(cfg.idle_triggers.len(), 1);
2479        assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
2480        assert_eq!(cfg.idle_triggers[0].message, "still there?");
2481        assert_eq!(
2482            cfg.idle_triggers[0].sends_to.as_deref(),
2483            Some("other_agent")
2484        );
2485        assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
2486        assert!(cfg.idle_triggers[0].respect_quiet_hours);
2487    }
2488
2489    #[test]
2490    fn idle_trigger_defaults_when_omitted() {
2491        let yaml = "restart: on_failure\n";
2492        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
2493        assert!(cfg.idle_triggers.is_empty());
2494    }
2495}
2496
2497#[cfg(test)]
2498mod appearance_tests {
2499    use super::*;
2500
2501    #[test]
2502    fn appearance_default_style_preset_is_default_blob() {
2503        assert_eq!(AgentAppearance::default().style_preset, "default-blob");
2504    }
2505
2506    #[test]
2507    fn appearance_default_behavior_is_normal() {
2508        assert_eq!(
2509            AgentAppearance::default().behavior_preset,
2510            BehaviorPreset::Normal
2511        );
2512    }
2513
2514    #[test]
2515    fn appearance_default_render_status_is_pending() {
2516        assert_eq!(
2517            AgentAppearance::default().render_status,
2518            RenderStatus::Pending
2519        );
2520    }
2521
2522    #[test]
2523    fn render_status_serde_round_trip() {
2524        let cases = [
2525            RenderStatus::Pending,
2526            RenderStatus::Rendering { done: 3, total: 12 },
2527            RenderStatus::Ready,
2528            RenderStatus::Failed {
2529                reason: "out of quota".into(),
2530            },
2531        ];
2532        for status in cases {
2533            let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
2534            let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
2535            assert_eq!(status, back);
2536        }
2537    }
2538
2539    #[test]
2540    fn agent_profile_with_appearance_round_trips() {
2541        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2542        let yaml = format!(
2543            "{base}appearance:\n  style_preset: chiikawa\n  render_status:\n    status: ready\n"
2544        );
2545        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
2546        assert_eq!(profile.appearance.style_preset, "chiikawa");
2547        assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
2548
2549        let out = serde_yaml_ng::to_string(&profile).expect("serialize");
2550        let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
2551        assert_eq!(profile.appearance, back.appearance);
2552    }
2553
2554    #[test]
2555    fn legacy_profile_without_appearance_uses_default() {
2556        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2557        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
2558        assert_eq!(profile.appearance.style_preset, "default-blob");
2559        assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
2560        assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
2561    }
2562
2563    #[test]
2564    fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
2565        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2566        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2567        assert!(p.file_actions.is_empty());
2568        assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
2569        assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
2570    }
2571}
2572
2573#[cfg(test)]
2574mod federation_tests {
2575    use super::*;
2576
2577    #[test]
2578    fn test_pattern_filter_default() {
2579        let f = PatternFilter::default();
2580        assert_eq!(f.max_count, 200);
2581        assert_eq!(f.importance_min, 0.0);
2582        assert!(f.tier.is_empty());
2583    }
2584
2585    #[test]
2586    fn test_federation_config_roundtrip() {
2587        let cfg = FederationConfig {
2588            filter: PatternFilter {
2589                tier: vec!["core".into()],
2590                max_count: 50,
2591                ..Default::default()
2592            },
2593            snapshot_ref: Some(SnapshotRef {
2594                knowledge_commit: "abc123def456".into(),
2595                taken_at: "2026-05-19T00:00:00Z".into(),
2596                filter: PatternFilter::default(),
2597            }),
2598            evidence_flush_interval_minutes: 15,
2599        };
2600        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2601        let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2602        assert_eq!(cfg, back);
2603    }
2604
2605    #[test]
2606    fn test_agent_profile_federation_defaults() {
2607        // AgentProfile without a federation block deserializes with FederationConfig::default().
2608        // Use the minimal YAML that passes validation — just the required fields.
2609        // (We check only that the field has its zero value, not full profile parse.)
2610        let cfg = FederationConfig::default();
2611        assert_eq!(cfg.evidence_flush_interval_minutes, 0);
2612        assert!(cfg.snapshot_ref.is_none());
2613    }
2614}
2615
2616#[cfg(test)]
2617mod skill_card_tests {
2618    use super::*;
2619
2620    #[test]
2621    fn installed_skills_default_to_empty_when_absent() {
2622        let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2623        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
2624        assert!(p.installed_skills.is_empty());
2625    }
2626
2627    #[test]
2628    fn installed_skills_roundtrip_preserves_entries() {
2629        let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
2630        let yaml = format!(
2631            "{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"
2632        );
2633        let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
2634        assert_eq!(p.installed_skills.len(), 1);
2635        assert_eq!(p.installed_skills[0].name, "s1");
2636        assert_eq!(p.installed_skills[0].abstract_text, "does things");
2637        assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
2638
2639        let out = serde_yaml_ng::to_string(&p).unwrap();
2640        assert!(out.contains("abstract: does things"));
2641        assert!(out.contains("pattern: /find"));
2642
2643        let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
2644        assert_eq!(p.installed_skills, back.installed_skills);
2645    }
2646
2647    #[test]
2648    fn installed_skills_minimal_entry_serializes_compactly() {
2649        // A name-only entry must NOT emit empty string fields.
2650        let entry = SkillCardEntry {
2651            name: "minimal".into(),
2652            ..Default::default()
2653        };
2654        let yaml = serde_yaml_ng::to_string(&entry).unwrap();
2655        assert!(yaml.contains("name: minimal"));
2656        assert!(
2657            !yaml.contains("version:"),
2658            "empty version must be skipped: {yaml}"
2659        );
2660        assert!(
2661            !yaml.contains("publisher:"),
2662            "empty publisher must be skipped: {yaml}"
2663        );
2664        assert!(
2665            !yaml.contains("abstract:"),
2666            "empty abstract must be skipped: {yaml}"
2667        );
2668    }
2669}
2670
2671#[cfg(test)]
2672mod tool_policy_tests {
2673    use super::*;
2674
2675    fn rules() -> Vec<ToolRule> {
2676        vec![
2677            ToolRule {
2678                pattern: "mcp__github__merge_pr".into(),
2679                policy: ToolPolicy::Ask,
2680                risk: None,
2681            },
2682            ToolRule {
2683                pattern: "mcp__github__*".into(),
2684                policy: ToolPolicy::Allow,
2685                risk: None,
2686            },
2687            ToolRule {
2688                pattern: "mcp__*".into(),
2689                policy: ToolPolicy::Deny,
2690                risk: None,
2691            },
2692            ToolRule {
2693                pattern: "bash".into(),
2694                policy: ToolPolicy::Allow,
2695                risk: None,
2696            },
2697        ]
2698    }
2699
2700    #[test]
2701    fn exact_beats_glob() {
2702        assert_eq!(
2703            resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
2704            ToolPolicy::Ask
2705        );
2706    }
2707
2708    #[test]
2709    fn longer_glob_wins() {
2710        assert_eq!(
2711            resolve_tool_policy(&rules(), "mcp__github__create_issue"),
2712            ToolPolicy::Allow
2713        );
2714    }
2715
2716    #[test]
2717    fn shorter_glob_fallback() {
2718        assert_eq!(
2719            resolve_tool_policy(&rules(), "mcp__slack__send"),
2720            ToolPolicy::Deny
2721        );
2722    }
2723
2724    #[test]
2725    fn exact_bash() {
2726        assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
2727    }
2728
2729    #[test]
2730    fn unknown_tool_defaults_ask() {
2731        assert_eq!(
2732            resolve_tool_policy(&rules(), "unknown_tool"),
2733            ToolPolicy::Ask
2734        );
2735    }
2736
2737    #[test]
2738    fn empty_rules_defaults_ask() {
2739        assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
2740    }
2741
2742    fn minimal_entitlements_yaml() -> &'static str {
2743        "network:\n  inbound: {}\n  outbound:\n    mode: off\nfilesystem: {}\nprocesses:\n  spawn:\n    mode: none\n"
2744    }
2745
2746    #[test]
2747    fn entitlements_tools_defaults_empty() {
2748        let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
2749        assert!(e.tools.is_empty());
2750    }
2751
2752    #[test]
2753    fn entitlements_tools_roundtrip() {
2754        let base = minimal_entitlements_yaml();
2755        let yaml = format!("{base}tools:\n  - pattern: \"mcp__github__*\"\n    policy: allow\n");
2756        let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
2757        assert_eq!(e.tools.len(), 1);
2758        assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
2759        let y = serde_yaml_ng::to_string(&e).unwrap();
2760        let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
2761        assert_eq!(back.tools.len(), 1);
2762        assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
2763    }
2764    #[test]
2765    fn denylist_membership_and_mutation() {
2766        let mut list: Vec<String> = vec![];
2767        assert!(name_enabled(&list, "a"), "empty denylist => enabled");
2768
2769        set_denylist(&mut list, "a", false); // disable
2770        assert!(!name_enabled(&list, "a"));
2771        assert_eq!(list, ["a"]);
2772
2773        set_denylist(&mut list, "a", false); // idempotent disable
2774        assert_eq!(list, ["a"], "no duplicate entries");
2775
2776        set_denylist(&mut list, "a", true); // enable removes
2777        assert!(name_enabled(&list, "a"));
2778        assert!(list.is_empty());
2779
2780        set_denylist(&mut list, "b", true); // enabling an absent name is a no-op
2781        assert!(list.is_empty());
2782    }
2783
2784    #[test]
2785    fn addon_group_rule_truth_table() {
2786        let mut p = crate::agent::AgentProfile::default_for_tests();
2787        p.addons.push(AddonRef {
2788            id: "grp".into(),
2789            source: "claude-local:grp@1.0.0".into(),
2790            enabled: false,
2791            skills: vec!["g_skill".into()],
2792            mcp: vec!["g_mcp".into()],
2793            commands: vec!["g_cmd".into()],
2794            content_hash: None,
2795            fetch_ref: None,
2796            fetch_plugin: None,
2797        });
2798
2799        // 1. standalone item, no entry anywhere => enabled (back-compat)
2800        assert!(p.skill_enabled("standalone"));
2801        assert!(p.mcp_enabled("standalone_mcp"));
2802
2803        // 2. grouped item, group disabled => off (cannot enable one member of a disabled group)
2804        assert!(!p.skill_enabled("g_skill"));
2805        assert!(!p.mcp_enabled("g_mcp"));
2806
2807        // 3. grouped item, group enabled, name not denied => on
2808        assert!(p.set_addon_enabled("grp", true));
2809        assert!(p.skill_enabled("g_skill"));
2810        assert!(p.mcp_enabled("g_mcp"));
2811
2812        // 4. name in denylist overrides an enabled group => off (silence one member)
2813        p.set_skill_enabled("g_skill", false);
2814        assert!(!p.skill_enabled("g_skill"));
2815
2816        // set_addon_enabled on a missing id reports false
2817        assert!(!p.set_addon_enabled("nope", true));
2818
2819        // kill-switch: only flips group flags — no denylist push
2820        p.disable_all_addons();
2821        assert!(p.addons.iter().all(|g| !g.enabled));
2822        assert!(!p.skill_enabled("g_skill"));
2823        assert!(!p.skill_enabled("g_cmd"));
2824        assert!(!p.mcp_enabled("g_mcp")); // mcp kill-switch asserted
2825
2826        // re-enable restores members — kill-switch is NOT sticky
2827        // (g_skill was individually denied in step 4 above and stays off;
2828        //  g_cmd and g_mcp were never individually denied so they come back on)
2829        assert!(p.set_addon_enabled("grp", true));
2830        assert!(!p.skill_enabled("g_skill")); // still individually denied from step 4
2831        assert!(p.skill_enabled("g_cmd")); // restored: never individually denied
2832        assert!(p.mcp_enabled("g_mcp")); // restored: never individually denied
2833
2834        // clearing the individual deny fully restores g_skill too
2835        p.set_skill_enabled("g_skill", true);
2836        assert!(p.skill_enabled("g_skill"));
2837    }
2838
2839    #[test]
2840    fn addon_ref_content_hash_and_fetch_ref_default_none_and_round_trip() {
2841        // legacy AddonRef (no new fields) → None
2842        let legacy = "id: a\nsource: claude-local:a@1\nenabled: false\n";
2843        let r: AddonRef = serde_yaml_ng::from_str(legacy).unwrap();
2844        assert_eq!(r.content_hash, None);
2845        assert_eq!(r.fetch_ref, None);
2846
2847        // with the new fields → round-trips
2848        let full = "id: a\nsource: claude-local:a@1\nenabled: true\ncontent_hash: abc123\nfetch_ref: owner/repo\n";
2849        let r2: AddonRef = serde_yaml_ng::from_str(full).unwrap();
2850        assert_eq!(r2.content_hash.as_deref(), Some("abc123"));
2851        assert_eq!(r2.fetch_ref.as_deref(), Some("owner/repo"));
2852        let back = serde_yaml_ng::to_string(&r2).unwrap();
2853        let r3: AddonRef = serde_yaml_ng::from_str(&back).unwrap();
2854        assert_eq!(r2, r3);
2855    }
2856}
2857
2858#[cfg(test)]
2859mod lockfile_compat_tests {
2860    use super::*;
2861
2862    #[test]
2863    fn lockfile_new_fields_default_for_old_locks() {
2864        // An old lock JSON without build_sha/proto_version must still parse,
2865        // defaulting to "" / 0 (= "predates this feature → stale/unsupported").
2866        let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2867          "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2868          "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2869        let lock: LockFile = serde_json::from_str(old).unwrap();
2870        assert_eq!(lock.build_sha, "");
2871        assert_eq!(lock.proto_version, 0);
2872    }
2873}
2874
2875#[cfg(test)]
2876mod remote_mcp_tests {
2877    use super::*;
2878
2879    #[test]
2880    fn mcp_entry_roundtrips_remote_bearer() {
2881        let e = McpServerEntry {
2882            name: "gh".into(),
2883            command: String::new(),
2884            url: Some("https://api.example.com/mcp".into()),
2885            auth: Some(McpAuth::Bearer {
2886                token: crate::secret::SecretRef::Env("GH_TOKEN".into()),
2887            }),
2888            ..Default::default()
2889        };
2890        let y = serde_yaml_ng::to_string(&e).unwrap();
2891        let back: McpServerEntry = serde_yaml_ng::from_str(&y).unwrap();
2892        assert_eq!(back.url.as_deref(), Some("https://api.example.com/mcp"));
2893        assert!(matches!(
2894            back.auth,
2895            Some(McpAuth::Bearer { ref token }) if *token == crate::secret::SecretRef::Env("GH_TOKEN".into())
2896        ));
2897        // A legacy stdio entry (no url/auth) still parses.
2898        let legacy: McpServerEntry =
2899            serde_yaml_ng::from_str("name: fs\ncommand: npx\nargs: [\"-y\",\"fs\"]\n").unwrap();
2900        assert!(legacy.url.is_none());
2901        assert!(legacy.auth.is_none());
2902    }
2903}
2904
2905#[cfg(test)]
2906mod requires_programs_tests {
2907    #[test]
2908    fn mcp_entry_parses_requires_programs_and_defaults_empty() {
2909        let with = r#"
2910name: research-gateway
2911command: mur-research-gateway
2912requires_programs:
2913  - name: lightpanda
2914    detect: { file: "~/.mur/aura/lightpanda" }
2915    reason: "render tier"
2916    registry: lightpanda
2917"#;
2918        let e: crate::agent::McpServerEntry = serde_yaml::from_str(with).unwrap();
2919        assert_eq!(e.requires_programs.len(), 1);
2920        assert_eq!(e.requires_programs[0].name, "lightpanda");
2921
2922        // Absent block → empty (back-compat).
2923        let without = "name: x\ncommand: y\n";
2924        let e2: crate::agent::McpServerEntry = serde_yaml::from_str(without).unwrap();
2925        assert!(e2.requires_programs.is_empty());
2926    }
2927}
2928
2929#[cfg(test)]
2930mod secrets_field_tests {
2931    /// The list is NAMES only and must stay absent from the YAML when empty:
2932    /// every existing profile on disk is rewritten by unrelated edits, and a
2933    /// new always-present key would churn all of them.
2934    #[test]
2935    fn secrets_names_round_trip_and_are_absent_when_empty() {
2936        let mut p = crate::agent::AgentProfile::default_for_tests();
2937        let yaml = serde_yaml::to_string(&p).unwrap();
2938        assert!(
2939            !yaml.contains("secrets:"),
2940            "empty list must not be written: {yaml}"
2941        );
2942        p.secrets = vec!["GITEA_TOKEN".into()];
2943        let yaml = serde_yaml::to_string(&p).unwrap();
2944        let back: crate::agent::AgentProfile = serde_yaml::from_str(&yaml).unwrap();
2945        assert_eq!(back.secrets, vec!["GITEA_TOKEN".to_string()]);
2946    }
2947}