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
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct ScheduleProposal {
148 pub cron: String,
149 pub message: String,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub asked_for: Option<String>,
155 #[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 #[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 pub after_secs: u64,
188 pub message: String,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub sends_to: Option<String>,
193 #[serde(default = "default_idle_cooldown")]
196 pub cooldown_secs: u64,
197 #[serde(default = "default_true")]
200 pub respect_quiet_hours: bool,
201}
202
203fn default_idle_cooldown() -> u64 {
204 600
205}
206pub fn name_enabled(denylist: &[String], name: &str) -> bool {
208 !denylist.iter().any(|n| n == name)
209}
210
211pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
304pub struct DroppedGrant {
305 pub path: String,
306 pub verb: String,
308 pub reason: String,
309}
310
311pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
341pub struct SandboxRecord {
342 pub enforcing: bool,
346 pub mode: String,
348 pub granted_digest: String,
353 #[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 #[serde(default)]
373 pub build_sha: String,
374 #[serde(default)]
377 pub proto_version: u32,
378 #[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 #[serde(default)]
396 pub webhook: Option<String>,
397}
398
399#[cfg(test)]
400mod tests {
401 #[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 #[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 #[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 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 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 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 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 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}