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