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}