Skip to main content

mur_common/agent/
lifecycle.rs

1use super::*;
2
3#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
4pub struct NotificationsConfig {
5    #[serde(default)]
6    pub on_task_complete: Vec<NotificationTarget>,
7    #[serde(default)]
8    pub on_error: Vec<NotificationTarget>,
9    #[serde(default)]
10    pub on_shutdown: Vec<NotificationTarget>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(tag = "target", rename_all = "lowercase")]
15pub enum NotificationTarget {
16    Agent {
17        name: String,
18    },
19    Commander,
20    Email {
21        address: String,
22        #[serde(default)]
23        smtp_config_file: Option<String>,
24    },
25    Slack {
26        #[serde(default)]
27        channel: Option<String>,
28        #[serde(default)]
29        webhook_url_env: Option<String>,
30    },
31    Webpush {
32        url: String,
33    },
34    Webhook {
35        url: String,
36        #[serde(default = "default_post")]
37        method: String,
38        #[serde(default)]
39        auth: Option<String>,
40    },
41}
42fn default_post() -> String {
43    "POST".to_string()
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct RetryConfig {
48    pub llm: RetryPolicy,
49    pub tool: RetryPolicy,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct RetryPolicy {
54    pub max_retries: u32,
55    pub backoff: BackoffStrategy,
56    pub initial_delay_ms: u64,
57    #[serde(default)]
58    pub max_delay_ms: Option<u64>,
59    #[serde(default)]
60    pub retry_on: Vec<String>,
61}
62
63#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "lowercase")]
65pub enum BackoffStrategy {
66    Linear,
67    Exponential,
68    Fixed,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub struct LifecycleConfig {
73    pub restart: RestartPolicy,
74    #[serde(default = "default_max_restarts")]
75    pub max_restarts: u32,
76    #[serde(default = "default_window")]
77    pub restart_window_secs: u64,
78    #[serde(default = "default_stop_timeout")]
79    pub stop_timeout_secs: u64,
80    #[serde(default = "default_mcp_required")]
81    pub mcp_required: bool,
82    #[serde(default)]
83    pub execution: ExecutionMode,
84    #[serde(default)]
85    pub schedule: Vec<ScheduleEntry>,
86    #[serde(default)]
87    pub idle_triggers: Vec<IdleTrigger>,
88}
89fn default_max_restarts() -> u32 {
90    3
91}
92fn default_window() -> u64 {
93    600
94}
95fn default_stop_timeout() -> u64 {
96    15
97}
98fn default_mcp_required() -> bool {
99    true
100}
101
102#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(rename_all = "snake_case")]
104pub enum RestartPolicy {
105    Never,
106    OnFailure,
107    Always,
108}
109
110#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
111#[serde(rename_all = "snake_case")]
112pub enum ExecutionMode {
113    #[default]
114    Daemon,
115    OnDemand,
116}
117
118/// Where an agent leaves a schedule it wants but cannot create.
119///
120/// An agent's schedules live in `lifecycle.schedule` inside its own
121/// `profile.yaml`, and an agent may not write that file — the sandbox denies it
122/// unconditionally so a running agent cannot widen its own entitlements and
123/// restart into them. So "remind me at 10 tomorrow" cannot become a schedule
124/// from the inside, however much the agent understands the request.
125///
126/// It becomes a proposal instead: a file in the agent's own home, which it may
127/// write, that `mur agent schedule accept` turns into the real entry.
128///
129/// Public and shared because both halves must name the same directory. Two
130/// spellings would not fail loudly — the agent would write proposals nobody
131/// lists, which is the shape of failure this whole area keeps producing.
132pub const SCHEDULE_PROPOSAL_DIR: &str = "schedule-proposals";
133
134/// File in the agent's home holding the id of the channel a fired schedule
135/// leaves its reply in. One stable channel per agent, remembered rather than
136/// re-derived (#1125).
137pub const SCHEDULE_CHANNEL_FILE: &str = "schedule-channel";
138
139/// Marker file in the agent's home naming the channel that records chat-gate
140/// decisions (`HitlResponse` events keyed by `action_hash`). Same shape as
141/// `SCHEDULE_CHANNEL_FILE`: created on first use, replaced if it names a
142/// channel that no longer loads.
143pub const HITL_CHANNEL_FILE: &str = "hitl-channel";
144
145/// A schedule an agent asked for and a person has not yet granted.
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct ScheduleProposal {
148    pub cron: String,
149    pub message: String,
150    /// What the user actually said, kept verbatim: a cron expression is not
151    /// reviewable on its own, and the reviewer is being asked whether this is
152    /// what they meant.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub asked_for: Option<String>,
155    /// Proposed bound, carried verbatim onto the accepted [`ScheduleEntry`].
156    /// Present exactly when the agent judged the request to name one occasion
157    /// rather than a recurrence.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub not_after: Option<String>,
160    pub proposed_at: String,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
164pub struct ScheduleEntry {
165    pub cron: String,
166    pub message: String,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub sends_to: Option<String>,
169    /// Retire the entry once its next firing would fall after this instant
170    /// (RFC3339 with offset). How a one-shot reminder is expressed: cron has no
171    /// year field, so "tomorrow at 10:00" can only be written as an annual
172    /// recurrence, and unbounded it turns a request for one morning into a
173    /// perpetual commitment (#1119).
174    ///
175    /// A bound rather than a fired-yet flag, because the scheduler runs inside
176    /// the agent's own sandbox where `profile.yaml` is denied
177    /// (`SELF_PROTECTED_AGENT_FILES`, #712) — it cannot record that an entry has
178    /// fired. Comparing the next firing against a stored instant needs no write
179    /// at all, so the bound works where a flag structurally could not.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub not_after: Option<String>,
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185pub struct IdleTrigger {
186    /// Idle threshold in seconds. Fires when (now - last_activity) >= after_secs.
187    pub after_secs: u64,
188    /// Message body injected into the task runner when this trigger fires.
189    pub message: String,
190    /// Optional A2A peer to route the resulting reply to. None means the agent itself.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub sends_to: Option<String>,
193    /// Per-trigger refire cooldown in seconds. Prevents tight loops when the
194    /// idle threshold is short and the runner finishes quickly. Default 600.
195    #[serde(default = "default_idle_cooldown")]
196    pub cooldown_secs: u64,
197    /// When true, suppress firing during the agent's quiet-hours window.
198    /// Default true — idle pings should not wake the user at 3 a.m.
199    #[serde(default = "default_true")]
200    pub respect_quiet_hours: bool,
201}
202
203fn default_idle_cooldown() -> u64 {
204    600
205}
206/// True if `name` is not present in a denylist (i.e. enabled).
207pub fn name_enabled(denylist: &[String], name: &str) -> bool {
208    !denylist.iter().any(|n| n == name)
209}
210
211/// Add/remove `name` in a denylist. `enabled=true` removes it (idempotent),
212/// `enabled=false` adds it once (idempotent).
213pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
214    if enabled {
215        list.retain(|n| n != name);
216    } else if !list.iter().any(|n| n == name) {
217        list.push(name.to_string());
218    }
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
222pub struct FileTransferConfig {
223    #[serde(default = "default_accept_max")]
224    pub accept_incoming_file_max_bytes: u64,
225    #[serde(default = "default_accept_total")]
226    pub accept_incoming_total_per_hour: u64,
227    #[serde(default = "default_approval_threshold")]
228    pub require_approval_above_bytes: u64,
229    #[serde(default = "default_reject_paths")]
230    pub reject_paths: Vec<String>,
231    #[serde(default = "default_allowed_mime")]
232    pub allowed_mime_types: Vec<String>,
233}
234
235impl Default for FileTransferConfig {
236    fn default() -> Self {
237        Self {
238            accept_incoming_file_max_bytes: default_accept_max(),
239            accept_incoming_total_per_hour: default_accept_total(),
240            require_approval_above_bytes: default_approval_threshold(),
241            reject_paths: default_reject_paths(),
242            allowed_mime_types: default_allowed_mime(),
243        }
244    }
245}
246
247fn default_accept_max() -> u64 {
248    10_485_760
249}
250fn default_accept_total() -> u64 {
251    104_857_600
252}
253fn default_approval_threshold() -> u64 {
254    10_485_760
255}
256fn default_reject_paths() -> Vec<String> {
257    vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
258}
259fn default_allowed_mime() -> Vec<String> {
260    vec!["*".into()]
261}
262
263#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
264#[serde(rename_all = "snake_case")]
265pub enum DeploymentType {
266    #[default]
267    Laptop,
268    Vm,
269    Docker,
270    K8s,
271    Lambda,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
275pub struct DeploymentConfig {
276    #[serde(rename = "type", default)]
277    pub deployment_type: DeploymentType,
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub region: Option<String>,
280    #[serde(default = "default_env")]
281    pub environment: Option<String>,
282}
283
284impl Default for DeploymentConfig {
285    fn default() -> Self {
286        Self {
287            deployment_type: DeploymentType::default(),
288            region: None,
289            environment: default_env(),
290        }
291    }
292}
293
294fn default_env() -> Option<String> {
295    Some("dev".into())
296}
297
298/// One filesystem grant the sandbox refused to install, and why.
299///
300/// The grant stays in `profile.yaml` — this records that it did not reach the
301/// kernel, which is otherwise knowable only from a WARN line in a log nobody
302/// queries.
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
304pub struct DroppedGrant {
305    pub path: String,
306    /// `"read"` or `"write"`.
307    pub verb: String,
308    pub reason: String,
309}
310
311/// Digest of the filesystem half of a profile's entitlements.
312///
313/// Narrower than `card_digest` on purpose: that one moves whenever any profile
314/// field does, so using it to flag "grants changed since this agent started"
315/// would raise a false alarm on an unrelated edit — and a status line that
316/// cries wolf is one people stop reading.
317pub fn filesystem_grants_digest(fs: &FilesystemEntitlement) -> String {
318    use sha2::{Digest, Sha256};
319    let mut h = Sha256::new();
320    for (label, list) in [("r", &fs.read), ("w", &fs.write), ("d", &fs.deny)] {
321        let mut sorted = list.clone();
322        sorted.sort();
323        for p in sorted {
324            h.update(label.as_bytes());
325            h.update(b"\0");
326            h.update(p.as_bytes());
327            h.update(b"\0");
328        }
329    }
330    format!("sha256:{:x}", h.finalize())
331}
332
333/// What the sandbox actually installed, recorded at the moment it sealed.
334///
335/// A seatbelt profile cannot be widened after `sandbox_init`, so this is fixed
336/// for the process's lifetime — the same lifetime as the lock file it rides in.
337/// Without it, `profile.yaml` is the only readable account of an agent's
338/// permissions, and it describes what was asked for rather than what took
339/// effect.
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
341pub struct SandboxRecord {
342    /// False means the kernel sandbox is NOT installed and only advisory hooks
343    /// remain — the agent then has MORE access than its profile grants, which
344    /// is the opposite of every other failure here and the one worth shouting.
345    pub enforcing: bool,
346    /// `"macos-sbpl"`, `"linux-landlock"`, `"advisory-only"`, …
347    pub mode: String,
348    /// Digest of `entitlements.filesystem` as sealed. Comparing it against the
349    /// profile on disk answers "were grants changed since this agent started"
350    /// without anyone tracking that — and unlike `card_digest` it does not move
351    /// when an unrelated field does, so it cannot raise a false alarm.
352    pub granted_digest: String,
353    /// Grants that did not reach the kernel. Empty is the normal case.
354    #[serde(default)]
355    pub dropped: Vec<DroppedGrant>,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
359pub struct LockFile {
360    pub schema: u32,
361    pub uuid: String,
362    pub name: String,
363    pub pid: u32,
364    pub ppid: u32,
365    pub started_at: String,
366    pub binary_version: String,
367    pub transports: LockTransports,
368    pub card_digest: String,
369    pub capabilities: Vec<String>,
370    /// Git sha the running binary was built from (mur_common::build::SHORT_SHA).
371    /// Empty = an old lock predating this field. Drives stale detection.
372    #[serde(default)]
373    pub build_sha: String,
374    /// A2A method-surface version this runtime supports (A2A_PROTO_VERSION).
375    /// 0 = an old lock; the dial gates versioned methods on it.
376    #[serde(default)]
377    pub proto_version: u32,
378    /// What the sandbox installed at seal time. `None` = a lock written before
379    /// this field existed, or a platform that installs no sandbox.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub sandbox: Option<SandboxRecord>,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
385pub struct LockTransports {
386    pub stdio: bool,
387    #[serde(default)]
388    pub unix_socket: Option<String>,
389    #[serde(default)]
390    pub tcp: Option<String>,
391    /// C5 / M5.3 — webhook listener URL (e.g. `http://127.0.0.1:6789`).
392    /// Populated by the supervisor when `transport.webhook.enabled =
393    /// true` so peers and the commander can discover the live
394    /// endpoint without re-reading `profile.yaml`.
395    #[serde(default)]
396    pub webhook: Option<String>,
397}
398
399#[cfg(test)]
400mod tests {
401    /// Order must not matter: the same grants written in a different order are
402    /// the same grants, and a digest that disagreed would report "restart to
403    /// apply" after a cosmetic profile edit.
404    #[test]
405    fn grants_digest_ignores_order_but_not_content() {
406        let a = FilesystemEntitlement {
407            read: vec!["/a".into(), "/b".into()],
408            write: vec!["/w".into()],
409            deny: vec![],
410        };
411        let reordered = FilesystemEntitlement {
412            read: vec!["/b".into(), "/a".into()],
413            ..a.clone()
414        };
415        let changed = FilesystemEntitlement {
416            write: vec!["/w".into(), "/x".into()],
417            ..a.clone()
418        };
419        assert_eq!(
420            filesystem_grants_digest(&a),
421            filesystem_grants_digest(&reordered)
422        );
423        assert_ne!(
424            filesystem_grants_digest(&a),
425            filesystem_grants_digest(&changed)
426        );
427    }
428
429    /// A read grant and a write grant for the same path are different grants.
430    #[test]
431    fn grants_digest_separates_the_verbs() {
432        let r = FilesystemEntitlement {
433            read: vec!["/p".into()],
434            write: vec![],
435            deny: vec![],
436        };
437        let w = FilesystemEntitlement {
438            read: vec![],
439            write: vec!["/p".into()],
440            deny: vec![],
441        };
442        assert_ne!(filesystem_grants_digest(&r), filesystem_grants_digest(&w));
443    }
444
445    /// A lock written before this field existed must still load — every agent
446    /// running at upgrade time wrote one.
447    #[test]
448    fn a_lock_without_the_sandbox_block_still_deserialises() {
449        let old = r#"{"schema":1,"uuid":"u","name":"n","pid":1,"ppid":0,
450            "started_at":"t","binary_version":"v",
451            "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
452        let lf: LockFile = serde_json::from_str(old).expect("old lock must load");
453        assert!(lf.sandbox.is_none());
454    }
455
456    #[test]
457    fn the_sandbox_block_round_trips() {
458        let rec = SandboxRecord {
459            enforcing: false,
460            mode: "advisory-only".into(),
461            granted_digest: "sha256:x".into(),
462            dropped: vec![DroppedGrant {
463                path: "/gone".into(),
464                verb: "write".into(),
465                reason: "path does not exist on disk".into(),
466            }],
467        };
468        let back: SandboxRecord =
469            serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
470        assert_eq!(back, rec);
471    }
472
473    use super::*;
474
475    #[test]
476    fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
477        let net = McpServerNetwork {
478            mode: McpNetMode::BroadAudited,
479            allow_hosts: vec![],
480            deny_hosts: vec!["evil.example".into()],
481            authorization: Some(EgressAuthorization {
482                authorized_by: "david".into(),
483                authorized_at_ms: 1_750_000_000_000,
484            }),
485        };
486        let y = serde_yaml::to_string(&net).unwrap();
487        assert!(y.contains("broad_audited"));
488        let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
489        assert_eq!(back, net);
490        // legacy per-server policy without the new fields still parses (serde default)
491        let legacy: McpServerNetwork =
492            serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
493        assert_eq!(legacy.deny_hosts, Vec::<String>::new());
494        assert!(legacy.authorization.is_none());
495    }
496
497    #[test]
498    fn mcp_entry_network_is_optional_and_round_trips() {
499        // Absent in YAML → None (every existing profile keeps working).
500        let bare = "name: x\ncommand: npx\n";
501        let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
502        assert!(e.network.is_none());
503
504        // Present → parsed.
505        let with = "name: browser\ncommand: npx\nnetwork:\n  mode: restricted\n  allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
506        let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
507        let net = e2.network.expect("network present");
508        assert_eq!(net.mode, McpNetMode::Restricted);
509        assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
510
511        // Round-trip keeps None out of the serialized form.
512        let out = serde_yaml_ng::to_string(&e).unwrap();
513        assert!(!out.contains("network"));
514    }
515
516    #[test]
517    fn profile_round_trip_yaml() {
518        let yaml = r#"
519schema: 1
520id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
521name: agent_a
522display_name: "Price Hunter"
523version: "0.1.0"
524persona:
525  category: research
526  description: "Finds prices"
527  traits: { tone: concise, risk: cautious, verbosity: low }
528sys_prompt_file: "sys_prompt.md"
529model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
530mcp_servers: []
531skills: []
532transport:
533  stdio: true
534  socket: { enabled: true, bind: "unix:///tmp/a.sock" }
535communication: { accepts_from: ["*"], sends_to: [] }
536capabilities: ["a2a.message.send", "a2a.tasks"]
537entitlements:
538  network:
539    inbound: { ports: [] }
540    outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
541  filesystem: { read: [], write: [], deny: [] }
542  processes: { spawn: { mode: allowlist, allowed: [] } }
543  syscalls: { mode: default }
544  limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
545notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
546retry:
547  llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
548  tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
549lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
550created_at: "2026-04-22T10:00:00+08:00"
551updated_at: "2026-04-22T10:00:00+08:00"
552"#;
553        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
554        assert_eq!(profile.name, "agent_a");
555        assert_eq!(profile.persona.category, PersonaCategory::Research);
556        assert_eq!(
557            profile.entitlements.network.outbound.mode,
558            NetworkOutboundMode::Restricted
559        );
560        let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
561        let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
562        assert_eq!(profile.id, round_tripped.id);
563    }
564
565    #[test]
566    fn requires_capabilities_defaults_empty_and_round_trips() {
567        let base = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
568        let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
569        assert!(p.requires_capabilities.is_empty());
570        let with = format!("{base}\nrequires_capabilities:\n  - media\n");
571        let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
572        assert_eq!(p2.requires_capabilities, vec!["media"]);
573    }
574}
575
576#[cfg(test)]
577mod idle_trigger_tests {
578    use super::*;
579
580    #[test]
581    fn idle_trigger_yaml_round_trip() {
582        let yaml = r#"
583restart: on_failure
584idle_triggers:
585  - after_secs: 3600
586    message: "still there?"
587    sends_to: other_agent
588    cooldown_secs: 1800
589    respect_quiet_hours: true
590"#;
591        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
592        assert_eq!(cfg.idle_triggers.len(), 1);
593        assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
594        assert_eq!(cfg.idle_triggers[0].message, "still there?");
595        assert_eq!(
596            cfg.idle_triggers[0].sends_to.as_deref(),
597            Some("other_agent")
598        );
599        assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
600        assert!(cfg.idle_triggers[0].respect_quiet_hours);
601    }
602
603    #[test]
604    fn idle_trigger_defaults_when_omitted() {
605        let yaml = "restart: on_failure\n";
606        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
607        assert!(cfg.idle_triggers.is_empty());
608    }
609}
610
611#[cfg(test)]
612mod lockfile_compat_tests {
613    use super::*;
614
615    #[test]
616    fn lockfile_new_fields_default_for_old_locks() {
617        // An old lock JSON without build_sha/proto_version must still parse,
618        // defaulting to "" / 0 (= "predates this feature → stale/unsupported").
619        let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
620          "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
621          "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
622        let lock: LockFile = serde_json::from_str(old).unwrap();
623        assert_eq!(lock.build_sha, "");
624        assert_eq!(lock.proto_version, 0);
625    }
626}