1use std::collections::BTreeSet;
7
8use thiserror::Error;
9
10use crate::compose::{ChannelMembers, Compose};
11
12#[derive(Debug, Error, PartialEq, Eq)]
13pub enum ValidationError {
14 #[error("project `{0}`: duplicate agent id `{1}` in managers and workers")]
15 DuplicateAgent(String, String),
16
17 #[error(
18 "project `{project}`: unknown agent `{agent}` referenced in channel `{channel}` members"
19 )]
20 ChannelUnknownMember {
21 project: String,
22 channel: String,
23 agent: String,
24 },
25
26 #[error("project `{project}`: agent `{agent}` `can_dm` lists unknown agent `{target}`")]
27 DmUnknownTarget {
28 project: String,
29 agent: String,
30 target: String,
31 },
32
33 #[error(
34 "project `{project}`: agent `{agent}` `can_broadcast` lists unknown channel `{channel}`"
35 )]
36 BroadcastUnknownChannel {
37 project: String,
38 agent: String,
39 channel: String,
40 },
41
42 #[error(
43 "project `{project}`: agent `{agent}` has an `interfaces.telegram` block but is not a manager"
44 )]
45 TelegramInboxOnWorker { project: String, agent: String },
46
47 #[error(
48 "worker `{project}:{agent}` declares `reports_to: {target}` but no such manager exists"
49 )]
50 UnknownManager {
51 project: String,
52 agent: String,
53 target: String,
54 },
55
56 #[error("broker type `{0}` not supported (known: sqlite)")]
57 UnknownBroker(String),
58
59 #[error("supervisor type `{0}` not supported (known: tmux, systemd, launchd)")]
60 UnknownSupervisor(String),
61
62 #[error("duplicate project id `{0}`")]
63 DuplicateProject(String),
64
65 #[error(
66 "project id `{0}` has disallowed characters; allowed: ASCII letters, digits, and `.` `_` `-` (no whitespace, shell metacharacters, or control chars; `:` is reserved as the project:agent separator)"
67 )]
68 InvalidProjectId(String),
69
70 #[error(
71 "project `{project}`: agent id `{agent}` has disallowed characters; allowed: ASCII letters, digits, and `.` `_` `-` (no whitespace, shell metacharacters, or control chars; `:` is reserved as the project:agent separator)"
72 )]
73 InvalidAgentId { project: String, agent: String },
74
75 #[error("project `{project}`: agent `{agent}` uses runtime `{runtime}`, which is not built in and not declared in `<root>/runtimes/{runtime}.yaml`")]
76 UnknownRuntime {
77 project: String,
78 agent: String,
79 runtime: String,
80 },
81
82 #[error("supervisor.drain_timeout_secs={0} is unreasonable; expected 0..=600")]
83 DrainTimeoutOutOfRange(u64),
84
85 #[error(
86 "compose schema `version: {got}` is not a valid semver string (expected e.g. `\"2.0.0\"`)"
87 )]
88 SchemaVersionInvalid { got: String },
89
90 #[error(
91 "project `{project}`: agent `{agent}` has a blank `role_prompt` (empty string or empty list)"
92 )]
93 BlankRolePrompt { project: String, agent: String },
94
95 #[error("project `{project}`: agent `{agent}` has a blank `display_name`")]
96 BlankDisplayName { project: String, agent: String },
97
98 #[error("project `{project}`: agent `{agent}` `display_name` is {got} chars (max {max})")]
99 DisplayNameTooLong {
100 project: String,
101 agent: String,
102 got: usize,
103 max: usize,
104 },
105}
106
107pub const DISPLAY_NAME_MAX_CHARS: usize = 64;
115
116pub fn is_valid_id(s: &str) -> bool {
131 !s.is_empty()
132 && s.chars()
133 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
134}
135
136pub fn validate(compose: &Compose) -> Vec<ValidationError> {
137 let mut errs = Vec::new();
138
139 let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
143 let check_runtime = !runtimes.is_empty();
144
145 match compose.global.broker.r#type.as_str() {
146 "sqlite" => {}
147 other => errs.push(ValidationError::UnknownBroker(other.into())),
148 }
149 match compose.global.supervisor.r#type.as_str() {
150 "tmux" | "systemd" | "launchd" => {}
151 other => errs.push(ValidationError::UnknownSupervisor(other.into())),
152 }
153 if compose.global.supervisor.drain_timeout_secs > 600 {
154 errs.push(ValidationError::DrainTimeoutOutOfRange(
155 compose.global.supervisor.drain_timeout_secs,
156 ));
157 }
158
159 if semver::Version::parse(&compose.global.version.value).is_err() {
168 errs.push(ValidationError::SchemaVersionInvalid {
169 got: compose.global.version.value.clone(),
170 });
171 }
172
173 let mut seen_projects = BTreeSet::new();
174 for p in &compose.projects {
175 if !seen_projects.insert(p.project.id.clone()) {
176 errs.push(ValidationError::DuplicateProject(p.project.id.clone()));
177 }
178 if !is_valid_id(&p.project.id) {
183 errs.push(ValidationError::InvalidProjectId(p.project.id.clone()));
184 }
185 for id in p.managers.keys().chain(p.workers.keys()) {
186 if !is_valid_id(id) {
187 errs.push(ValidationError::InvalidAgentId {
188 project: p.project.id.clone(),
189 agent: id.clone(),
190 });
191 }
192 }
193
194 let mgr_ids: BTreeSet<&str> = p.managers.keys().map(|s| s.as_str()).collect();
195 let wrk_ids: BTreeSet<&str> = p.workers.keys().map(|s| s.as_str()).collect();
196 for dup in mgr_ids.intersection(&wrk_ids) {
197 errs.push(ValidationError::DuplicateAgent(
198 p.project.id.clone(),
199 (*dup).to_string(),
200 ));
201 }
202 let all_agents: BTreeSet<&str> = mgr_ids.union(&wrk_ids).copied().collect();
203
204 let channel_names: BTreeSet<&str> = p.channels.iter().map(|c| c.name.as_str()).collect();
206 for ch in &p.channels {
207 if let ChannelMembers::Explicit(members) = &ch.members {
208 for m in members {
209 if !all_agents.contains(m.as_str()) {
210 errs.push(ValidationError::ChannelUnknownMember {
211 project: p.project.id.clone(),
212 channel: ch.name.clone(),
213 agent: m.clone(),
214 });
215 }
216 }
217 }
218 }
219
220 let check_agent = |errs: &mut Vec<ValidationError>,
222 id: &str,
223 a: &crate::compose::Agent,
224 is_manager: bool| {
225 if a.telegram().is_some() && !is_manager {
226 errs.push(ValidationError::TelegramInboxOnWorker {
227 project: p.project.id.clone(),
228 agent: id.into(),
229 });
230 }
231 for t in &a.can_dm {
232 if !all_agents.contains(t.as_str()) {
233 errs.push(ValidationError::DmUnknownTarget {
234 project: p.project.id.clone(),
235 agent: id.into(),
236 target: t.clone(),
237 });
238 }
239 }
240 for c in &a.can_broadcast {
241 if !channel_names.contains(c.as_str()) {
242 errs.push(ValidationError::BroadcastUnknownChannel {
243 project: p.project.id.clone(),
244 agent: id.into(),
245 channel: c.clone(),
246 });
247 }
248 }
249 if let Some(t) = &a.reports_to {
250 if !mgr_ids.contains(t.as_str()) {
251 errs.push(ValidationError::UnknownManager {
252 project: p.project.id.clone(),
253 agent: id.into(),
254 target: t.clone(),
255 });
256 }
257 }
258 if check_runtime && !runtimes.contains_key(a.runtime.as_str()) {
259 errs.push(ValidationError::UnknownRuntime {
260 project: p.project.id.clone(),
261 agent: id.into(),
262 runtime: a.runtime.clone(),
263 });
264 }
265 if let Some(rp) = &a.role_prompt {
266 if rp.is_blank() {
267 errs.push(ValidationError::BlankRolePrompt {
268 project: p.project.id.clone(),
269 agent: id.into(),
270 });
271 }
272 }
273 if let Some(dn) = &a.display_name {
274 let trimmed_len = dn.trim().chars().count();
279 if trimmed_len == 0 {
280 errs.push(ValidationError::BlankDisplayName {
281 project: p.project.id.clone(),
282 agent: id.into(),
283 });
284 } else if dn.chars().count() > DISPLAY_NAME_MAX_CHARS {
285 errs.push(ValidationError::DisplayNameTooLong {
286 project: p.project.id.clone(),
287 agent: id.into(),
288 got: dn.chars().count(),
289 max: DISPLAY_NAME_MAX_CHARS,
290 });
291 }
292 }
293 };
294
295 for (id, a) in &p.managers {
296 check_agent(&mut errs, id, a, true);
297 }
298 for (id, a) in &p.workers {
299 check_agent(&mut errs, id, a, false);
300 }
301 }
302
303 errs
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::compose::*;
310 use std::collections::BTreeMap;
311 use std::path::PathBuf;
312
313 fn toy_compose(agent_dm_target: &str) -> Compose {
314 let mut managers = BTreeMap::new();
315 managers.insert(
316 "mgr".into(),
317 Agent {
318 runtime: "claude-code".into(),
319 model: Some("claude-opus-4-8".into()),
320 role_prompt: None,
321 permission_mode: None,
322 autonomy: "low_risk_only".into(),
323 can_dm: vec![agent_dm_target.into()],
324 can_broadcast: vec!["team".into()],
325 reports_to: None,
326 on_rate_limit: None,
327 effort: None,
328 interfaces: None,
329 display_name: None,
330 },
331 );
332 let mut workers = BTreeMap::new();
333 workers.insert(
334 "dev".into(),
335 Agent {
336 runtime: "claude-code".into(),
337 model: None,
338 role_prompt: None,
339 permission_mode: None,
340 autonomy: "low_risk_only".into(),
341 can_dm: vec!["mgr".into()],
342 can_broadcast: vec!["team".into()],
343 reports_to: Some("mgr".into()),
344 on_rate_limit: None,
345 effort: None,
346 interfaces: None,
347 display_name: None,
348 },
349 );
350 Compose {
351 root: PathBuf::from("."),
352 global: Global {
353 version: crate::compose::SchemaVersion::new("2.0.0"),
354 broker: Default::default(),
355 supervisor: Default::default(),
356 budget: Default::default(),
357 hitl: Default::default(),
358 rate_limits: Default::default(),
359 interfaces: vec![],
360 projects: vec![],
361 attachments: Default::default(),
362 },
363 projects: vec![Project {
364 version: 2,
365 project: ProjectMeta {
366 id: "hello".into(),
367 name: "Hello".into(),
368 cwd: PathBuf::from("."),
369 },
370 channels: vec![Channel {
371 name: "team".into(),
372 members: ChannelMembers::All("*".into()),
373 }],
374 managers,
375 workers,
376 interfaces: None,
377 }],
378 }
379 }
380
381 #[test]
382 fn clean_compose_validates() {
383 let c = toy_compose("dev");
384 assert_eq!(validate(&c), vec![]);
385 }
386
387 #[test]
388 fn dm_to_unknown_agent_flags() {
389 let c = toy_compose("ghost");
390 let e = validate(&c);
391 assert!(matches!(
392 e.as_slice(),
393 [ValidationError::DmUnknownTarget { .. }]
394 ));
395 }
396
397 #[test]
398 fn unknown_broker_flags() {
399 let mut c = toy_compose("dev");
400 c.global.broker.r#type = "redis".into();
401 assert!(validate(&c)
402 .iter()
403 .any(|e| matches!(e, ValidationError::UnknownBroker(_))));
404 }
405
406 #[test]
407 fn drain_timeout_above_600s_flags() {
408 let mut c = toy_compose("dev");
409 c.global.supervisor.drain_timeout_secs = 86_400;
410 assert!(validate(&c)
411 .iter()
412 .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(86_400))));
413 }
414
415 #[test]
416 fn drain_timeout_zero_is_valid() {
417 let mut c = toy_compose("dev");
418 c.global.supervisor.drain_timeout_secs = 0;
419 assert!(!validate(&c)
420 .iter()
421 .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(_))));
422 }
423
424 #[test]
425 fn empty_role_prompt_list_flags() {
426 let mut c = toy_compose("dev");
427 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
428 Some(crate::compose::RolePrompt::Multiple(vec![]));
429 assert!(validate(&c)
430 .iter()
431 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
432 }
433
434 #[test]
435 fn empty_role_prompt_string_flags() {
436 let mut c = toy_compose("dev");
441 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
442 Some(crate::compose::RolePrompt::Single(PathBuf::from("")));
443 assert!(validate(&c)
444 .iter()
445 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
446 }
447
448 #[test]
449 fn single_role_prompt_validates() {
450 let mut c = toy_compose("dev");
451 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
452 crate::compose::RolePrompt::Single(PathBuf::from("roles/mgr.md")),
453 );
454 assert!(!validate(&c)
455 .iter()
456 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
457 }
458
459 #[test]
460 fn populated_role_prompt_list_validates() {
461 let mut c = toy_compose("dev");
462 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
463 crate::compose::RolePrompt::Multiple(vec![PathBuf::from("roles/mgr.md")]),
464 );
465 assert!(!validate(&c)
466 .iter()
467 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
468 }
469
470 #[test]
471 fn blank_display_name_flags() {
472 let mut c = toy_compose("dev");
473 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(String::new());
474 assert!(validate(&c)
475 .iter()
476 .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
477 }
478
479 #[test]
480 fn display_name_at_max_length_validates() {
481 let mut c = toy_compose("dev");
482 let exactly_max = "x".repeat(DISPLAY_NAME_MAX_CHARS);
483 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(exactly_max);
484 assert!(!validate(&c).iter().any(|e| matches!(
485 e,
486 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
487 )));
488 }
489
490 #[test]
491 fn display_name_above_max_length_flags() {
492 let mut c = toy_compose("dev");
493 let too_long = "x".repeat(DISPLAY_NAME_MAX_CHARS + 1);
494 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(too_long);
495 assert!(validate(&c)
496 .iter()
497 .any(|e| matches!(e, ValidationError::DisplayNameTooLong { .. })));
498 }
499
500 #[test]
501 fn display_name_counts_chars_not_bytes() {
502 let mut c = toy_compose("dev");
507 let sixty_four_crabs = "🦀".repeat(DISPLAY_NAME_MAX_CHARS);
508 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(sixty_four_crabs);
509 assert!(!validate(&c).iter().any(|e| matches!(
510 e,
511 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
512 )));
513 }
514
515 #[test]
516 fn whitespace_only_display_name_flags_blank() {
517 let mut c = toy_compose("dev");
521 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(" ".into());
522 assert!(validate(&c)
523 .iter()
524 .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
525 }
526
527 #[test]
528 fn populated_display_name_validates() {
529 let mut c = toy_compose("dev");
530 c.projects[0].managers.get_mut("mgr").unwrap().display_name =
531 Some("Sage (Visionary)".into());
532 assert!(!validate(&c).iter().any(|e| matches!(
533 e,
534 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
535 )));
536 }
537
538 #[test]
541 fn is_valid_id_accepts_existing_id_shapes() {
542 for ok in [
546 "teamctl",
547 "ops",
548 "nico",
549 "eng_lead",
550 "pr-22-review",
551 "blog-site",
552 "my.team",
553 "a1",
554 "x-2.0",
555 "a",
556 "0",
557 "A",
558 "_",
559 "-",
560 ".",
561 ] {
562 assert!(is_valid_id(ok), "must accept conformant id `{ok}`");
563 }
564 }
565
566 #[test]
567 fn is_valid_id_rejects_shell_metacharacter_class() {
568 for bad in [
572 "evil; rm",
573 "proj$(id)",
574 "with space",
575 "back`ticks`",
576 "p|ipe",
577 "p&",
578 "p*g",
579 "p?g",
580 "p~e",
581 "p!g",
582 "p#g",
583 "p'q",
584 "p\"q",
585 "p\\g",
586 "p<g",
587 "p>g",
588 "p(g",
589 "p)g",
590 "p\tg",
591 "p\ng",
592 ] {
593 assert!(!is_valid_id(bad), "must reject `{bad:?}`");
594 }
595 }
596
597 #[test]
598 fn is_valid_id_rejects_colon_as_reserved_separator() {
599 assert!(!is_valid_id("p:rj"));
604 assert!(!is_valid_id(":"));
605 assert!(!is_valid_id("a:"));
606 assert!(!is_valid_id(":a"));
607 }
608
609 #[test]
610 fn is_valid_id_rejects_empty_and_control_chars() {
611 assert!(!is_valid_id(""));
612 assert!(!is_valid_id("\0"));
613 assert!(!is_valid_id("p\x07q"));
614 }
615
616 #[test]
617 fn is_valid_id_rejects_non_ascii() {
618 assert!(!is_valid_id("crab🦀"));
621 assert!(!is_valid_id("café"));
622 }
623
624 #[test]
625 fn clean_compose_passes_id_charset() {
626 let c = toy_compose("dev");
629 let errs = validate(&c);
630 assert!(
631 !errs.iter().any(|e| matches!(
632 e,
633 ValidationError::InvalidProjectId(_) | ValidationError::InvalidAgentId { .. }
634 )),
635 "clean compose unexpectedly flagged for id charset: {errs:?}",
636 );
637 }
638
639 #[test]
640 fn project_id_with_shell_metacharacters_flags() {
641 let mut c = toy_compose("dev");
647 c.projects[0].project.id = "evil; rm -rf ~".into();
648 let errs = validate(&c);
649 assert!(
650 errs.iter().any(|e| matches!(
651 e,
652 ValidationError::InvalidProjectId(s) if s == "evil; rm -rf ~"
653 )),
654 "expected InvalidProjectId, got {errs:?}",
655 );
656 }
657
658 #[test]
659 fn manager_id_with_shell_metacharacters_flags() {
660 let mut c = toy_compose("dev");
664 let bad = "$(id)";
665 let mgr = c.projects[0].managers.remove("mgr").unwrap();
666 c.projects[0].managers.insert(bad.into(), mgr);
667 let errs = validate(&c);
668 assert!(
669 errs.iter().any(|e| matches!(
670 e,
671 ValidationError::InvalidAgentId { project, agent }
672 if project == "hello" && agent == bad
673 )),
674 "expected InvalidAgentId for manager, got {errs:?}",
675 );
676 }
677
678 #[test]
679 fn worker_id_with_shell_metacharacters_flags() {
680 let mut c = toy_compose("dev");
682 let bad = "rogue|pipe";
683 let wkr = c.projects[0].workers.remove("dev").unwrap();
684 c.projects[0].workers.insert(bad.into(), wkr);
685 let errs = validate(&c);
688 assert!(
689 errs.iter().any(|e| matches!(
690 e,
691 ValidationError::InvalidAgentId { project, agent }
692 if project == "hello" && agent == bad
693 )),
694 "expected InvalidAgentId for worker, got {errs:?}",
695 );
696 }
697
698 #[test]
699 fn project_id_with_reserved_colon_flags() {
700 let mut c = toy_compose("dev");
704 c.projects[0].project.id = "foo:bar".into();
705 let errs = validate(&c);
706 assert!(
707 errs.iter()
708 .any(|e| matches!(e, ValidationError::InvalidProjectId(s) if s == "foo:bar")),
709 "expected InvalidProjectId on colon, got {errs:?}",
710 );
711 }
712
713 #[test]
718 fn valid_semver_string_validates() {
719 let c = toy_compose("dev");
721 assert!(
722 !validate(&c)
723 .iter()
724 .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
725 "canonical version `2.0.0` must validate"
726 );
727 }
728
729 #[test]
730 fn malformed_semver_string_flags() {
731 let mut c = toy_compose("dev");
732 c.global.version = crate::compose::SchemaVersion::new("abc");
733 let errs = validate(&c);
734 assert!(
735 errs.iter().any(|e| matches!(
736 e,
737 ValidationError::SchemaVersionInvalid { got } if got == "abc"
738 )),
739 "non-semver string must surface SchemaVersionInvalid; got {errs:?}"
740 );
741 }
742
743 #[test]
744 fn bare_two_string_flags_too() {
745 let mut c = toy_compose("dev");
748 c.global.version = crate::compose::SchemaVersion::new("2");
749 assert!(
750 validate(&c).iter().any(|e| matches!(
751 e,
752 ValidationError::SchemaVersionInvalid { got } if got == "2"
753 )),
754 "bare-2-string must NOT pass the semver shape check"
755 );
756 }
757
758 #[test]
759 fn semver_with_prerelease_and_build_metadata_validates() {
760 for ok in [
765 "1.0.0",
766 "2.3.4",
767 "2.0.0-alpha",
768 "1.0.0+build.5",
769 "2.0.0-rc.1+build.7",
770 ] {
771 let mut c = toy_compose("dev");
772 c.global.version = crate::compose::SchemaVersion::new(ok);
773 assert!(
774 !validate(&c)
775 .iter()
776 .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
777 "semver `{ok}` must validate"
778 );
779 }
780 }
781}