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
118pub const SCHEDULE_PROPOSAL_DIR: &str = "schedule-proposals";
133
134pub const SCHEDULE_CHANNEL_FILE: &str = "schedule-channel";
138
139pub const HITL_CHANNEL_FILE: &str = "hitl-channel";
144
145pub const HITL_HEAD_DIR: &str = "hitl-heads";
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct ScheduleProposal {
153 pub cron: String,
154 pub message: String,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub asked_for: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub not_after: Option<String>,
165 pub proposed_at: String,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169pub struct ScheduleEntry {
170 pub cron: String,
171 pub message: String,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub sends_to: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub not_after: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct IdleTrigger {
191 pub after_secs: u64,
193 pub message: String,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub sends_to: Option<String>,
198 #[serde(default = "default_idle_cooldown")]
201 pub cooldown_secs: u64,
202 #[serde(default = "default_true")]
205 pub respect_quiet_hours: bool,
206}
207
208fn default_idle_cooldown() -> u64 {
209 600
210}
211pub fn name_enabled(denylist: &[String], name: &str) -> bool {
213 !denylist.iter().any(|n| n == name)
214}
215
216pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
219 if enabled {
220 list.retain(|n| n != name);
221 } else if !list.iter().any(|n| n == name) {
222 list.push(name.to_string());
223 }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
227pub struct FileTransferConfig {
228 #[serde(default = "default_accept_max")]
229 pub accept_incoming_file_max_bytes: u64,
230 #[serde(default = "default_accept_total")]
231 pub accept_incoming_total_per_hour: u64,
232 #[serde(default = "default_approval_threshold")]
233 pub require_approval_above_bytes: u64,
234 #[serde(default = "default_reject_paths")]
235 pub reject_paths: Vec<String>,
236 #[serde(default = "default_allowed_mime")]
237 pub allowed_mime_types: Vec<String>,
238}
239
240impl Default for FileTransferConfig {
241 fn default() -> Self {
242 Self {
243 accept_incoming_file_max_bytes: default_accept_max(),
244 accept_incoming_total_per_hour: default_accept_total(),
245 require_approval_above_bytes: default_approval_threshold(),
246 reject_paths: default_reject_paths(),
247 allowed_mime_types: default_allowed_mime(),
248 }
249 }
250}
251
252fn default_accept_max() -> u64 {
253 10_485_760
254}
255fn default_accept_total() -> u64 {
256 104_857_600
257}
258fn default_approval_threshold() -> u64 {
259 10_485_760
260}
261fn default_reject_paths() -> Vec<String> {
262 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
263}
264fn default_allowed_mime() -> Vec<String> {
265 vec!["*".into()]
266}
267
268#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
269#[serde(rename_all = "snake_case")]
270pub enum DeploymentType {
271 #[default]
272 Laptop,
273 Vm,
274 Docker,
275 K8s,
276 Lambda,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280pub struct DeploymentConfig {
281 #[serde(rename = "type", default)]
282 pub deployment_type: DeploymentType,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub region: Option<String>,
285 #[serde(default = "default_env")]
286 pub environment: Option<String>,
287}
288
289impl Default for DeploymentConfig {
290 fn default() -> Self {
291 Self {
292 deployment_type: DeploymentType::default(),
293 region: None,
294 environment: default_env(),
295 }
296 }
297}
298
299fn default_env() -> Option<String> {
300 Some("dev".into())
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309pub struct DroppedGrant {
310 pub path: String,
311 pub verb: String,
313 pub reason: String,
314}
315
316pub fn filesystem_grants_digest(fs: &FilesystemEntitlement) -> String {
323 use sha2::{Digest, Sha256};
324 let mut h = Sha256::new();
325 for (label, list) in [("r", &fs.read), ("w", &fs.write), ("d", &fs.deny)] {
326 let mut sorted = list.clone();
327 sorted.sort();
328 for p in sorted {
329 h.update(label.as_bytes());
330 h.update(b"\0");
331 h.update(p.as_bytes());
332 h.update(b"\0");
333 }
334 }
335 format!("sha256:{:x}", h.finalize())
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
346pub struct SandboxRecord {
347 pub enforcing: bool,
351 pub mode: String,
353 pub granted_digest: String,
358 #[serde(default)]
360 pub dropped: Vec<DroppedGrant>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
364pub struct LockFile {
365 pub schema: u32,
366 pub uuid: String,
367 pub name: String,
368 pub pid: u32,
369 pub ppid: u32,
370 pub started_at: String,
371 pub binary_version: String,
372 pub transports: LockTransports,
373 pub card_digest: String,
374 pub capabilities: Vec<String>,
375 #[serde(default)]
378 pub build_sha: String,
379 #[serde(default)]
382 pub proto_version: u32,
383 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub sandbox: Option<SandboxRecord>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
390pub struct LockTransports {
391 pub stdio: bool,
392 #[serde(default)]
393 pub unix_socket: Option<String>,
394 #[serde(default)]
395 pub tcp: Option<String>,
396 #[serde(default)]
401 pub webhook: Option<String>,
402}
403
404#[cfg(test)]
405mod tests {
406 #[test]
410 fn grants_digest_ignores_order_but_not_content() {
411 let a = FilesystemEntitlement {
412 read: vec!["/a".into(), "/b".into()],
413 write: vec!["/w".into()],
414 deny: vec![],
415 };
416 let reordered = FilesystemEntitlement {
417 read: vec!["/b".into(), "/a".into()],
418 ..a.clone()
419 };
420 let changed = FilesystemEntitlement {
421 write: vec!["/w".into(), "/x".into()],
422 ..a.clone()
423 };
424 assert_eq!(
425 filesystem_grants_digest(&a),
426 filesystem_grants_digest(&reordered)
427 );
428 assert_ne!(
429 filesystem_grants_digest(&a),
430 filesystem_grants_digest(&changed)
431 );
432 }
433
434 #[test]
436 fn grants_digest_separates_the_verbs() {
437 let r = FilesystemEntitlement {
438 read: vec!["/p".into()],
439 write: vec![],
440 deny: vec![],
441 };
442 let w = FilesystemEntitlement {
443 read: vec![],
444 write: vec!["/p".into()],
445 deny: vec![],
446 };
447 assert_ne!(filesystem_grants_digest(&r), filesystem_grants_digest(&w));
448 }
449
450 #[test]
453 fn a_lock_without_the_sandbox_block_still_deserialises() {
454 let old = r#"{"schema":1,"uuid":"u","name":"n","pid":1,"ppid":0,
455 "started_at":"t","binary_version":"v",
456 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
457 let lf: LockFile = serde_json::from_str(old).expect("old lock must load");
458 assert!(lf.sandbox.is_none());
459 }
460
461 #[test]
462 fn the_sandbox_block_round_trips() {
463 let rec = SandboxRecord {
464 enforcing: false,
465 mode: "advisory-only".into(),
466 granted_digest: "sha256:x".into(),
467 dropped: vec![DroppedGrant {
468 path: "/gone".into(),
469 verb: "write".into(),
470 reason: "path does not exist on disk".into(),
471 }],
472 };
473 let back: SandboxRecord =
474 serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
475 assert_eq!(back, rec);
476 }
477
478 use super::*;
479
480 #[test]
481 fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
482 let net = McpServerNetwork {
483 mode: McpNetMode::BroadAudited,
484 allow_hosts: vec![],
485 deny_hosts: vec!["evil.example".into()],
486 authorization: Some(EgressAuthorization {
487 authorized_by: "david".into(),
488 authorized_at_ms: 1_750_000_000_000,
489 }),
490 };
491 let y = serde_yaml::to_string(&net).unwrap();
492 assert!(y.contains("broad_audited"));
493 let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
494 assert_eq!(back, net);
495 let legacy: McpServerNetwork =
497 serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
498 assert_eq!(legacy.deny_hosts, Vec::<String>::new());
499 assert!(legacy.authorization.is_none());
500 }
501
502 #[test]
503 fn mcp_entry_network_is_optional_and_round_trips() {
504 let bare = "name: x\ncommand: npx\n";
506 let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
507 assert!(e.network.is_none());
508
509 let with = "name: browser\ncommand: npx\nnetwork:\n mode: restricted\n allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
511 let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
512 let net = e2.network.expect("network present");
513 assert_eq!(net.mode, McpNetMode::Restricted);
514 assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
515
516 let out = serde_yaml_ng::to_string(&e).unwrap();
518 assert!(!out.contains("network"));
519 }
520
521 #[test]
522 fn profile_round_trip_yaml() {
523 let yaml = r#"
524schema: 1
525id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
526name: agent_a
527display_name: "Price Hunter"
528version: "0.1.0"
529persona:
530 category: research
531 description: "Finds prices"
532 traits: { tone: concise, risk: cautious, verbosity: low }
533sys_prompt_file: "sys_prompt.md"
534model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
535mcp_servers: []
536skills: []
537transport:
538 stdio: true
539 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
540communication: { accepts_from: ["*"], sends_to: [] }
541capabilities: ["a2a.message.send", "a2a.tasks"]
542entitlements:
543 network:
544 inbound: { ports: [] }
545 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
546 filesystem: { read: [], write: [], deny: [] }
547 processes: { spawn: { mode: allowlist, allowed: [] } }
548 syscalls: { mode: default }
549 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
550notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
551retry:
552 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
553 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
554lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
555created_at: "2026-04-22T10:00:00+08:00"
556updated_at: "2026-04-22T10:00:00+08:00"
557"#;
558 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
559 assert_eq!(profile.name, "agent_a");
560 assert_eq!(profile.persona.category, PersonaCategory::Research);
561 assert_eq!(
562 profile.entitlements.network.outbound.mode,
563 NetworkOutboundMode::Restricted
564 );
565 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
566 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
567 assert_eq!(profile.id, round_tripped.id);
568 }
569
570 #[test]
571 fn requires_capabilities_defaults_empty_and_round_trips() {
572 let base = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
573 let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
574 assert!(p.requires_capabilities.is_empty());
575 let with = format!("{base}\nrequires_capabilities:\n - media\n");
576 let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
577 assert_eq!(p2.requires_capabilities, vec!["media"]);
578 }
579}
580
581#[cfg(test)]
582mod idle_trigger_tests {
583 use super::*;
584
585 #[test]
586 fn idle_trigger_yaml_round_trip() {
587 let yaml = r#"
588restart: on_failure
589idle_triggers:
590 - after_secs: 3600
591 message: "still there?"
592 sends_to: other_agent
593 cooldown_secs: 1800
594 respect_quiet_hours: true
595"#;
596 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
597 assert_eq!(cfg.idle_triggers.len(), 1);
598 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
599 assert_eq!(cfg.idle_triggers[0].message, "still there?");
600 assert_eq!(
601 cfg.idle_triggers[0].sends_to.as_deref(),
602 Some("other_agent")
603 );
604 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
605 assert!(cfg.idle_triggers[0].respect_quiet_hours);
606 }
607
608 #[test]
609 fn idle_trigger_defaults_when_omitted() {
610 let yaml = "restart: on_failure\n";
611 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
612 assert!(cfg.idle_triggers.is_empty());
613 }
614}
615
616#[cfg(test)]
617mod lockfile_compat_tests {
618 use super::*;
619
620 #[test]
621 fn lockfile_new_fields_default_for_old_locks() {
622 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
625 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
626 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
627 let lock: LockFile = serde_json::from_str(old).unwrap();
628 assert_eq!(lock.build_sha, "");
629 assert_eq!(lock.proto_version, 0);
630 }
631}