Skip to main content

mur_common/config/
ops.rs

1use super::*;
2
3/// Pre-dispatch triage settings (`triage:` in `~/.mur/config.yaml`).
4///
5/// Both knobs default to the passive posture, and `enforce` in particular is
6/// off for a reason that is not caution: `triage_calibration` cannot score a
7/// decision that prevented a run. Enforcing from the start produces only
8/// unfalsifiable `held_back` records, so there would never be evidence that
9/// enforcing was correct. Shadow mode records the same predictions AND lets
10/// the run happen, which is what makes `Calibration::shadow_precision`
11/// computable — turn `enforce` on once that number says triage is right often
12/// enough to be worth the refusals.
13#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
14pub struct TriageConfig {
15    /// Run triage before dispatch at all. Off = not even the free prefilter
16    /// runs, and no verdicts are recorded.
17    #[serde(default)]
18    pub enabled: bool,
19    /// Let a triage decision actually stop a dispatch. Requires `enabled`.
20    /// See the type docs before turning this on.
21    #[serde(default)]
22    pub enforce: bool,
23}
24
25impl TriageConfig {
26    /// Does triage actually bind here? `enforce` alone is inert — with
27    /// `enabled: false` nothing runs to enforce — so the two are resolved in
28    /// one place rather than at each call site, where the pair would
29    /// eventually be got wrong in one of them.
30    pub fn enforces(&self) -> bool {
31        self.enabled && self.enforce
32    }
33}
34
35/// Rotation for `~/.mur/queue/events.jsonl`, in the shape FreeBSD's
36/// `newsyslog(8)` uses: rotate past a size, keep a bounded number of
37/// generations, compress all but the newest, drop the oldest.
38///
39/// The point of generations is that nobody has to decide to delete anything.
40/// A 934 MB queue becomes `.0`, then `.1.gz`, and ages out on a policy the
41/// user set rather than on a judgement call someone makes once.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct CaptureConfig {
44    /// Rotate once the live file passes this. 64 MB keeps `mur hook stats`
45    /// responsive — parsing is O(file), and 934 MB took minutes.
46    #[serde(default = "default_rotate_at_mb")]
47    pub rotate_at_mb: u64,
48    /// How many rotated generations to keep. `.0` stays uncompressed like
49    /// newsyslog's, the rest are gzipped.
50    #[serde(default = "default_keep_generations")]
51    pub keep_generations: u32,
52}
53
54pub(super) fn default_rotate_at_mb() -> u64 {
55    64
56}
57
58fn default_keep_generations() -> u32 {
59    5
60}
61
62impl Default for CaptureConfig {
63    fn default() -> Self {
64        Self {
65            rotate_at_mb: default_rotate_at_mb(),
66            keep_generations: default_keep_generations(),
67        }
68    }
69}
70
71/// Post-upgrade settings for `mur update`. Stored under `update:` in
72/// `~/.mur/config.yaml`.
73#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
74pub struct UpdateConfig {
75    /// macOS code-signing identity used to re-sign installed binaries after an
76    /// upgrade. Keychain grants bind to the signing identity; fresh installs
77    /// are ad-hoc (new CDHash per build), so without a stable identity every
78    /// upgrade kills the grants and service-launched agents fail silently
79    /// (#849/#866). Fallback: the `MUR_CODESIGN_IDENTITY` env var.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub codesign_identity: Option<String>,
82    /// Agent names `mur update --restart-agents` must never touch.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub restart_exclude: Vec<String>,
85}
86
87/// Which notification channels the durable monitor may use. `log` is not
88/// listed: it is always on and cannot be disabled, because a notable event
89/// must leave a trace somewhere even when a user has turned everything off.
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct NotificationsConfig {
92    /// OS desktop notification. Opt-in: a background daemon that starts
93    /// popping banners on upgrade is a hostile default.
94    #[serde(default)]
95    pub desktop: bool,
96}
97
98/// Whether the durable monitor may ask a model what to do about a terminal
99/// failure the structured rules did not settle.
100///
101/// Off by default, and not merely as a courtesy: enabling it lets a
102/// background daemon send monitor context to a model on its own schedule,
103/// with no one watching. Nobody watching is not permission, so this is a
104/// decision a user makes once, explicitly, rather than something an upgrade
105/// makes for them.
106///
107/// The bound on how often it may ask is NOT here: one proposal per
108/// observation cycle is an invariant of the design, not a knob (see
109/// `mur_core::monitor::resolver`). A tunable would let a user turn a bounded
110/// feature into an unbounded one.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct MonitorResolverConfig {
113    /// Opt-in. While false, nothing in the resolver path runs and no request
114    /// leaves the machine.
115    #[serde(default)]
116    pub enabled: bool,
117
118    /// Which model to ask. `None` uses the same backend `mur chat` resolves,
119    /// so a user who has already configured one does not configure it twice.
120    #[serde(default)]
121    pub model: Option<String>,
122}
123
124/// Run-status heartbeat tuning. Both values are config, never literals at a
125/// call site: the right interval depends on how long the machine's steps take.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct RunsConfig {
128    /// How often `execute_dag` stamps `last_heartbeat_at`.
129    #[serde(default = "default_heartbeat_interval_secs")]
130    pub heartbeat_interval_secs: u64,
131    /// How many missed intervals before a live process counts as `stalled`.
132    /// Three tolerates one lost tick plus scheduling jitter without calling a
133    /// healthy run dead.
134    #[serde(default = "default_heartbeat_stale_after_intervals")]
135    pub heartbeat_stale_after_intervals: u32,
136}
137
138pub(super) fn default_heartbeat_interval_secs() -> u64 {
139    10
140}
141
142pub(super) fn default_heartbeat_stale_after_intervals() -> u32 {
143    3
144}
145
146impl Default for RunsConfig {
147    fn default() -> Self {
148        Self {
149            heartbeat_interval_secs: default_heartbeat_interval_secs(),
150            heartbeat_stale_after_intervals: default_heartbeat_stale_after_intervals(),
151        }
152    }
153}
154
155/// Authorization gate for the `parallel_jobs` MCP tool. Stored under `parallel_jobs:`
156/// in `~/.mur/config.yaml`. Deny-by-default: an empty `targets` list means the
157/// tool cannot delegate to ANY agent (inert until the user opts specific
158/// agents in). This is a deterministic, out-of-model gate that a
159/// prompt-injected concierge cannot widen (OWASP Agentic ASI02/03/04).
160#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
161pub struct ParallelJobsConfig {
162    /// Canonical agent names the `parallel_jobs` tool is allowed to delegate to.
163    /// Empty = deny all.
164    #[serde(default)]
165    pub targets: Vec<String>,
166}
167
168// --- memory-federation snapshot (spec 2026-08-04-unified-memory-federation) ---
169
170/// Daemon-side settings for the signed snapshot pull. Stored under
171/// `federation_snapshot:` in `~/.mur/config.yaml`; every field has a default
172/// so an absent block means "defaults", never "off".
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174#[serde(default)]
175pub struct SnapshotConfig {
176    /// How often the daemon sweeps `inbox/snapshot-requests/`, in seconds.
177    pub poll_secs: u64,
178    /// Reject requests older than this (replay blunting), in seconds.
179    pub request_max_age_secs: u64,
180    /// Minimum lifecycle state a global skill needs to enter a snapshot.
181    pub min_lifecycle: crate::skill::stats::LifecycleState,
182}
183
184impl Default for SnapshotConfig {
185    fn default() -> Self {
186        Self {
187            poll_secs: 30,
188            request_max_age_secs: 600,
189            min_lifecycle: crate::skill::stats::LifecycleState::Stable,
190        }
191    }
192}
193
194/// Proactive memory capture (memory federation P2): gates the runtime's
195/// built-in `remember` tool and its system-prompt directive. Stored under
196/// `memory:` in `~/.mur/config.yaml`.
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198#[serde(default)]
199pub struct MemoryConfig {
200    pub capture: CaptureMode,
201    /// How many memories may enter the system prompt per turn. Deliberately
202    /// NOT `skills.max_skills_in_prompt`: sharing that budget means saving a
203    /// memory silently evicts a skill, and five is a plausible number of
204    /// standing preferences for one user to have.
205    #[serde(default = "default_memory_in_prompt")]
206    pub max_in_prompt: usize,
207    /// Character ceiling for the whole memory block, spent before skills.
208    #[serde(default = "default_memory_chars")]
209    pub max_chars: usize,
210}
211
212fn default_memory_in_prompt() -> usize {
213    20
214}
215
216fn default_memory_chars() -> usize {
217    1500
218}
219
220impl Default for MemoryConfig {
221    fn default() -> Self {
222        Self {
223            capture: CaptureMode::AutoAnnounce,
224            max_in_prompt: default_memory_in_prompt(),
225            max_chars: default_memory_chars(),
226        }
227    }
228}
229
230/// How agents capture memories mid-conversation.
231#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "snake_case")]
233pub enum CaptureMode {
234    /// Agent asks for a one-line confirmation before saving.
235    Ask,
236    /// Agent saves immediately and announces in the same reply (default).
237    AutoAnnounce,
238    /// The `remember` tool is not registered at all.
239    Off,
240}
241
242/// Authorization gate for the runtime's built-in `fleet_run` tool. Stored under
243/// `fleet_run:` in `~/.mur/config.yaml`. Deny-by-default on BOTH axes: an agent
244/// not named in `agents` never even sees the tool, and a fleet not named in
245/// `fleets` cannot be run. Lives in the global config (not the agent profile)
246/// because the profile is writable by the concierge itself — this gate must be
247/// out of reach of a prompt-injected agent (same rationale as
248/// [`ParallelJobsConfig`]).
249#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
250pub struct FleetRunConfig {
251    /// Canonical agent names allowed to call `fleet_run`. Empty = deny all.
252    #[serde(default)]
253    pub agents: Vec<String>,
254    /// Fleet names those agents may run. Empty = deny all.
255    #[serde(default)]
256    pub fleets: Vec<String>,
257}
258
259/// Display policy for `mur open`.
260///
261/// Lives in `config.yaml` rather than in `open-items.jsonl` because that log
262/// is append-only and agent-writable via the `open_item` tool. A user's
263/// decision to stop looking at a source must not be overturnable by an agent
264/// appending a record. Same reasoning as `fleet_run.agents`.
265#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
266pub struct OpenItemsConfig {
267    /// Exact `origin` strings to collapse out of `mur open`. Exact match
268    /// only — `fleet` never matches `fleet:acme`.
269    #[serde(default)]
270    pub muted: Vec<String>,
271}
272
273/// Daemon-wide gate for unattended fleet auto-run (`mur-daemon`'s `fleet_tick`).
274/// Stored under `fleet:` in `~/.mur/config.yaml`. Either this flag OR the
275/// `MUR_FLEET_AUTORUN` env var satisfies the gate — both are equally explicit,
276/// off-by-default opt-ins; the env var remains for ops/CI use, this flag is
277/// what the Hub's Settings toggle controls. Per-fleet `budget_usd > 0` and the
278/// `.stopped` kill-switch are unaffected — see `mur-daemon/src/fleet_tick.rs`.
279#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
280pub struct FleetConfig {
281    /// Allow fleets with a trigger + budget configured to auto-run unattended.
282    #[serde(default)]
283    pub autorun: bool,
284}
285
286/// Routing for Anthropic subscription-OAuth (`sk-ant-oat*`) tokens through a
287/// local bridge — cc-proxy — that swaps `x-api-key` for the Bearer +
288/// claude-code betas disguise the upstream requires.
289///
290/// The Hub injects `ANTHROPIC_BASE_URL` pointing at [`url`](Self::url) when it
291/// spawns an agent runtime, but only when [`enabled`](Self::enabled) is set and
292/// the bridge is actually listening; otherwise it leaves the runtime on the
293/// direct `api.anthropic.com` path (where an oat token would 401). A runtime
294/// launched with `ANTHROPIC_BASE_URL` already in its environment is never
295/// overridden.
296#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct CcProxyConfig {
298    /// Bridge base URL. Defaults to cc-proxy's default bind.
299    #[serde(default = "default_cc_proxy_url")]
300    pub url: String,
301
302    /// Master switch. When false the Hub never routes runtimes through the
303    /// bridge, regardless of reachability.
304    #[serde(default = "default_true")]
305    pub enabled: bool,
306}
307
308fn default_cc_proxy_url() -> String {
309    "http://127.0.0.1:8088".to_string()
310}
311
312fn default_true() -> bool {
313    true
314}
315
316impl Default for CcProxyConfig {
317    fn default() -> Self {
318        Self {
319            url: default_cc_proxy_url(),
320            enabled: true,
321        }
322    }
323}
324
325/// Configuration for the agent CLI TUI.
326/// Stored in ~/.mur/config.yaml under the `cli:` key.
327#[derive(Debug, Clone, Serialize, Deserialize, Default)]
328pub struct CliConfig {
329    /// Default visual skin for `mur agent cli`. Overridable with --skin.
330    /// Valid values: "ansi" (default; "dark" is an alias), "light", "mur".
331    pub skin: Option<String>,
332
333    /// True once the one-time "permanent instructions" notice has been shown
334    /// (memories P1 §10).
335    ///
336    /// The notice is informational and must appear at most once — repeating a
337    /// feature announcement every session reads as a warning about something
338    /// wrong. Absent (false) in existing configs, which is correct: a user who
339    /// has never seen it should.
340    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
341    pub seen_permanent_instructions_notice: bool,
342}
343
344/// Configuration for the mobile relay (P4).
345/// Stored in ~/.mur/config.yaml under the `mobile_relay:` key.
346#[derive(Debug, Clone, Serialize, Deserialize, Default)]
347pub struct MobileRelayConfig {
348    /// Base URL of the mur-server relay, e.g. "wss://relay.mur.run".
349    /// Leave blank to disable relay forwarding on the Mac daemon side.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub relay_url: Option<String>,
352
353    /// API key or JWT used by the Mac daemon to authenticate with the relay.
354    /// The value is typically a `mur_...` API key from app.mur.run.
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub api_key: Option<String>,
357}
358
359#[cfg(test)]
360mod fleet_config_tests {
361    use super::*;
362
363    #[test]
364    fn fleet_config_defaults_off_and_roundtrips() {
365        assert!(!FleetConfig::default().autorun);
366
367        let cfg: Config = serde_yaml_ng::from_str("fleet:\n  autorun: true\n").unwrap();
368        assert!(cfg.fleet.autorun);
369
370        // `fleet:` key entirely absent → defaults to off
371        let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
372        assert!(!cfg2.fleet.autorun);
373    }
374
375    #[test]
376    fn fleet_run_config_defaults_deny_all_and_roundtrips() {
377        // Absent section → both allowlists empty → deny all.
378        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
379        assert!(cfg.fleet_run.agents.is_empty());
380        assert!(cfg.fleet_run.fleets.is_empty());
381
382        let cfg2: Config =
383            serde_yaml_ng::from_str("fleet_run:\n  agents: [mur]\n  fleets: [deep-research]\n")
384                .unwrap();
385        assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
386        assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
387    }
388}
389
390#[cfg(test)]
391mod runs_config_tests {
392    use super::*;
393
394    /// A zero `heartbeat_interval_secs` is legal YAML but illegal at runtime:
395    /// it zeroes the stale threshold (every live run instantly reads STALLED)
396    /// and `tokio::time::interval(Duration::ZERO)` panics in the executor's
397    /// ticker. The loader must clamp zeroes to the defaults so one
398    /// user-edited line can neither lie nor crash.
399    #[test]
400    fn zero_heartbeat_values_clamp_to_defaults_at_load() {
401        let tmp = tempfile::tempdir().unwrap();
402        let path = tmp.path().join("config.yaml");
403        std::fs::write(
404            &path,
405            "runs:\n  heartbeat_interval_secs: 0\n  heartbeat_stale_after_intervals: 0\n",
406        )
407        .unwrap();
408
409        let cfg = Config::load_or_default(&path);
410        assert_eq!(
411            cfg.runs.heartbeat_interval_secs, 10,
412            "a zero interval must load as the default, not 0"
413        );
414        assert_eq!(
415            cfg.runs.heartbeat_stale_after_intervals, 3,
416            "a zero interval count must load as the default, not 0"
417        );
418    }
419
420    /// The clamp must not rewrite legitimate tuning: positive values survive.
421    #[test]
422    fn positive_heartbeat_values_survive_the_load() {
423        let tmp = tempfile::tempdir().unwrap();
424        let path = tmp.path().join("config.yaml");
425        std::fs::write(
426            &path,
427            "runs:\n  heartbeat_interval_secs: 60\n  heartbeat_stale_after_intervals: 2\n",
428        )
429        .unwrap();
430
431        let cfg = Config::load_or_default(&path);
432        assert_eq!(cfg.runs.heartbeat_interval_secs, 60);
433        assert_eq!(cfg.runs.heartbeat_stale_after_intervals, 2);
434    }
435}
436
437#[cfg(test)]
438mod ambient_capture_cfg_tests {
439    use super::*;
440
441    #[test]
442    fn session_and_harvest_defaults() {
443        let cfg: Config = serde_yaml::from_str("{}").unwrap();
444        assert_eq!(cfg.session.capture, "ambient");
445        assert_eq!(cfg.session.retention_days, 14);
446        assert!(cfg.harvest.auto_gate);
447        assert_eq!(cfg.harvest.llm, "local-first");
448        assert_eq!(cfg.harvest.min_events, 5);
449        assert_eq!(cfg.harvest.min_user_turns, 2);
450        assert_eq!(cfg.harvest.min_duration_secs, 120);
451        assert_eq!(cfg.harvest.idle_minutes, 30);
452        assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
453        assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
454        assert!(cfg.harvest.session_start_hint);
455        assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
456    }
457
458    #[test]
459    fn session_capture_override_parses() {
460        let cfg: Config =
461            serde_yaml::from_str("session:\n  capture: off\n  retention_days: 3\n").unwrap();
462        assert_eq!(cfg.session.capture, "off");
463        assert_eq!(cfg.session.retention_days, 3);
464    }
465}
466
467#[cfg(test)]
468mod cc_proxy_cfg_tests {
469    use super::*;
470
471    #[test]
472    fn defaults_to_local_cc_proxy_enabled() {
473        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
474        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
475        assert!(cfg.cc_proxy.enabled);
476    }
477
478    #[test]
479    fn url_and_enabled_override_parse() {
480        let cfg: Config =
481            serde_yaml_ng::from_str("cc_proxy:\n  url: http://127.0.0.1:9999\n  enabled: false\n")
482                .unwrap();
483        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
484        assert!(!cfg.cc_proxy.enabled);
485    }
486
487    #[test]
488    fn partial_section_keeps_other_default() {
489        // Only `enabled` given → url stays at the default.
490        let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n  enabled: false\n").unwrap();
491        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
492        assert!(!cfg.cc_proxy.enabled);
493    }
494}
495
496#[cfg(test)]
497mod notifications_config_tests {
498    use super::*;
499
500    #[test]
501    fn notifications_default_to_log_only() {
502        let c: Config = serde_yaml::from_str("{}").unwrap();
503        assert!(!c.notifications.desktop, "desktop must be opt-in");
504    }
505
506    #[test]
507    fn an_existing_config_without_the_block_still_parses() {
508        // Every user upgrading has a config.yaml with no `notifications:` key.
509        let c: Config = serde_yaml::from_str("retrieval:\n  min_score: 0.42\n").unwrap();
510        assert!(!c.notifications.desktop);
511    }
512
513    /// The whole safety argument for the resolver rests on this one bit: an
514    /// upgrade must never start letting the daemon talk to a model. Asserted
515    /// from both an empty config and a realistic existing one, because the
516    /// failure that matters is an upgrade, not a fresh install.
517    #[test]
518    fn the_monitor_resolver_is_off_until_a_user_turns_it_on() {
519        let empty: Config = serde_yaml::from_str("{}").unwrap();
520        assert!(
521            !empty.monitor_resolver.enabled,
522            "asking a model must be opt-in"
523        );
524        assert!(empty.monitor_resolver.model.is_none());
525
526        let upgraded: Config = serde_yaml::from_str(
527            "retrieval:\n  min_score: 0.42\nnotifications:\n  desktop: true\n",
528        )
529        .unwrap();
530        assert!(
531            !upgraded.monitor_resolver.enabled,
532            "a config written before this feature existed must not enable it"
533        );
534    }
535
536    #[test]
537    fn the_monitor_resolver_reads_back_what_a_user_wrote() {
538        let c: Config =
539            serde_yaml::from_str("monitor_resolver:\n  enabled: true\n  model: claude_haiku\n")
540                .unwrap();
541        assert!(c.monitor_resolver.enabled);
542        assert_eq!(c.monitor_resolver.model.as_deref(), Some("claude_haiku"));
543    }
544
545    /// Triage must be inert for everyone who never asked for it — including
546    /// every config file written before it existed.
547    #[test]
548    fn triage_is_off_and_non_binding_by_default() {
549        let c: Config = serde_yaml::from_str("retrieval:\n  min_score: 0.42\n").unwrap();
550        assert!(!c.triage.enabled, "triage must not run unasked");
551        assert!(!c.triage.enforce);
552        assert!(!c.triage.enforces());
553    }
554
555    /// The trap this guards: `enforce: true` alone looks like it turned
556    /// something on, but there is nothing running to enforce.
557    #[test]
558    fn enforce_without_enabled_binds_nothing() {
559        let c: Config = serde_yaml::from_str("triage:\n  enforce: true\n").unwrap();
560        assert!(c.triage.enforce, "the user's word is kept verbatim");
561        assert!(
562            !c.triage.enforces(),
563            "but it binds nothing while triage does not run"
564        );
565    }
566
567    #[test]
568    fn triage_binds_only_when_both_knobs_are_set() {
569        let c: Config =
570            serde_yaml::from_str("triage:\n  enabled: true\n  enforce: true\n").unwrap();
571        assert!(c.triage.enforces());
572    }
573
574    /// Enabled without enforce is shadow mode: it runs, it records, it never
575    /// stops anything.
576    #[test]
577    fn enabled_alone_is_shadow_mode() {
578        let c: Config = serde_yaml::from_str("triage:\n  enabled: true\n").unwrap();
579        assert!(c.triage.enabled);
580        assert!(!c.triage.enforces());
581    }
582}