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 #[error(
107 "project `{project}`: agent `{agent}` declares an MCP server named `team`, which is reserved for the built-in mailbox server"
108 )]
109 ReservedMcpServerName { project: String, agent: String },
110}
111
112#[derive(Debug, Error, PartialEq, Eq)]
117pub enum ValidationWarning {
118 #[error("agent `{project}:{agent}` declares {count} hook(s) but runtime `{runtime}` does not support hooks — they will be ignored at render time")]
119 HooksUnsupported {
120 project: String,
121 agent: String,
122 runtime: String,
123 count: usize,
124 },
125
126 #[error("agent `{project}:{agent}` declares {count} sub-agent(s) but runtime `{runtime}` does not support sub-agents — they will be ignored at render time")]
127 SubagentsUnsupported {
128 project: String,
129 agent: String,
130 runtime: String,
131 count: usize,
132 },
133
134 #[error("agent `{project}:{agent}` declares {count} skill(s) but runtime `{runtime}` does not support skills — they will be ignored at render time")]
135 SkillsUnsupported {
136 project: String,
137 agent: String,
138 runtime: String,
139 count: usize,
140 },
141
142 #[error("agent `{project}:{agent}` declares `effort:` but runtime `{runtime}` does not consume it — it will be ignored")]
143 EffortUnsupported {
144 project: String,
145 agent: String,
146 runtime: String,
147 },
148
149 #[error("agent `{project}:{agent}` declares MCP server `{server}` with a `${{VAR}}` placeholder in env — runtime `{runtime}` does not interpolate env values, so the literal placeholder reaches the server; use a literal value or export the secret in the environment the server inherits")]
150 McpEnvInterpolationUnsupported {
151 project: String,
152 agent: String,
153 runtime: String,
154 server: String,
155 },
156
157 #[error("agent `{project}:{agent}` sets `permission_mode: {mode}` but runtime `{runtime}` has no permission mapping — the setting is ignored at launch (the wrapper's gemini arm hardcodes --yolo)")]
158 PermissionModeUnsupported {
159 project: String,
160 agent: String,
161 runtime: String,
162 mode: String,
163 },
164
165 #[error("agent `{project}:{agent}` sets `permission_mode: bypassPermissions` but opencode has no full-bypass upstream — the wrapper downgrades it to `--auto`; deny rules stay enforced")]
166 PermissionModeBypassDowngraded { project: String, agent: String },
167}
168
169pub const DISPLAY_NAME_MAX_CHARS: usize = 64;
177
178pub fn is_valid_id(s: &str) -> bool {
193 !s.is_empty()
194 && s.chars()
195 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
196}
197
198pub fn validate(compose: &Compose) -> Vec<ValidationError> {
199 let mut errs = Vec::new();
200
201 let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
205 let check_runtime = !runtimes.is_empty();
206
207 match compose.global.broker.r#type.as_str() {
208 "sqlite" => {}
209 other => errs.push(ValidationError::UnknownBroker(other.into())),
210 }
211 match compose.global.supervisor.r#type.as_str() {
212 "tmux" | "systemd" | "launchd" => {}
213 other => errs.push(ValidationError::UnknownSupervisor(other.into())),
214 }
215 if compose.global.supervisor.drain_timeout_secs > 600 {
216 errs.push(ValidationError::DrainTimeoutOutOfRange(
217 compose.global.supervisor.drain_timeout_secs,
218 ));
219 }
220
221 if semver::Version::parse(&compose.global.version.value).is_err() {
230 errs.push(ValidationError::SchemaVersionInvalid {
231 got: compose.global.version.value.clone(),
232 });
233 }
234
235 let mut seen_projects = BTreeSet::new();
236 for p in &compose.projects {
237 if !seen_projects.insert(p.project.id.clone()) {
238 errs.push(ValidationError::DuplicateProject(p.project.id.clone()));
239 }
240 if !is_valid_id(&p.project.id) {
245 errs.push(ValidationError::InvalidProjectId(p.project.id.clone()));
246 }
247 for id in p.managers.keys().chain(p.workers.keys()) {
248 if !is_valid_id(id) {
249 errs.push(ValidationError::InvalidAgentId {
250 project: p.project.id.clone(),
251 agent: id.clone(),
252 });
253 }
254 }
255
256 let mgr_ids: BTreeSet<&str> = p.managers.keys().map(|s| s.as_str()).collect();
257 let wrk_ids: BTreeSet<&str> = p.workers.keys().map(|s| s.as_str()).collect();
258 for dup in mgr_ids.intersection(&wrk_ids) {
259 errs.push(ValidationError::DuplicateAgent(
260 p.project.id.clone(),
261 (*dup).to_string(),
262 ));
263 }
264 let all_agents: BTreeSet<&str> = mgr_ids.union(&wrk_ids).copied().collect();
265
266 let channel_names: BTreeSet<&str> = p.channels.iter().map(|c| c.name.as_str()).collect();
268 for ch in &p.channels {
269 if let ChannelMembers::Explicit(members) = &ch.members {
270 for m in members {
271 if !all_agents.contains(m.as_str()) {
272 errs.push(ValidationError::ChannelUnknownMember {
273 project: p.project.id.clone(),
274 channel: ch.name.clone(),
275 agent: m.clone(),
276 });
277 }
278 }
279 }
280 }
281
282 let check_agent = |errs: &mut Vec<ValidationError>,
284 id: &str,
285 a: &crate::compose::Agent,
286 is_manager: bool| {
287 if a.telegram().is_some() && !is_manager {
288 errs.push(ValidationError::TelegramInboxOnWorker {
289 project: p.project.id.clone(),
290 agent: id.into(),
291 });
292 }
293 for t in &a.can_dm {
294 if !all_agents.contains(t.as_str()) {
295 errs.push(ValidationError::DmUnknownTarget {
296 project: p.project.id.clone(),
297 agent: id.into(),
298 target: t.clone(),
299 });
300 }
301 }
302 for c in &a.can_broadcast {
303 if !channel_names.contains(c.as_str()) {
304 errs.push(ValidationError::BroadcastUnknownChannel {
305 project: p.project.id.clone(),
306 agent: id.into(),
307 channel: c.clone(),
308 });
309 }
310 }
311 if let Some(t) = &a.reports_to {
312 if !mgr_ids.contains(t.as_str()) {
313 errs.push(ValidationError::UnknownManager {
314 project: p.project.id.clone(),
315 agent: id.into(),
316 target: t.clone(),
317 });
318 }
319 }
320 if check_runtime && !runtimes.contains_key(a.runtime.as_str()) {
321 errs.push(ValidationError::UnknownRuntime {
322 project: p.project.id.clone(),
323 agent: id.into(),
324 runtime: a.runtime.clone(),
325 });
326 }
327 if let Some(rp) = &a.role_prompt {
328 if rp.is_blank() {
329 errs.push(ValidationError::BlankRolePrompt {
330 project: p.project.id.clone(),
331 agent: id.into(),
332 });
333 }
334 }
335 if let Some(dn) = &a.display_name {
336 let trimmed_len = dn.trim().chars().count();
341 if trimmed_len == 0 {
342 errs.push(ValidationError::BlankDisplayName {
343 project: p.project.id.clone(),
344 agent: id.into(),
345 });
346 } else if dn.chars().count() > DISPLAY_NAME_MAX_CHARS {
347 errs.push(ValidationError::DisplayNameTooLong {
348 project: p.project.id.clone(),
349 agent: id.into(),
350 got: dn.chars().count(),
351 max: DISPLAY_NAME_MAX_CHARS,
352 });
353 }
354 }
355 if a.mcps.contains_key("team") {
360 errs.push(ValidationError::ReservedMcpServerName {
361 project: p.project.id.clone(),
362 agent: id.into(),
363 });
364 }
365 };
366
367 for (id, a) in &p.managers {
368 check_agent(&mut errs, id, a, true);
369 }
370 for (id, a) in &p.workers {
371 check_agent(&mut errs, id, a, false);
372 }
373 }
374
375 errs
376}
377
378pub fn validate_warnings(compose: &Compose) -> Vec<ValidationWarning> {
396 let mut warns = Vec::new();
397 for p in &compose.projects {
398 let check_agent =
399 |warns: &mut Vec<ValidationWarning>, id: &str, a: &crate::compose::Agent| {
400 if a.runtime == "claude-code" {
401 return;
402 }
403 if !a.hooks.is_empty() {
404 warns.push(ValidationWarning::HooksUnsupported {
405 project: p.project.id.clone(),
406 agent: id.into(),
407 runtime: a.runtime.clone(),
408 count: a.hooks.len(),
409 });
410 }
411 if !a.subagents.is_empty() {
412 warns.push(ValidationWarning::SubagentsUnsupported {
413 project: p.project.id.clone(),
414 agent: id.into(),
415 runtime: a.runtime.clone(),
416 count: a.subagents.len(),
417 });
418 }
419 if !a.skills.is_empty() {
420 warns.push(ValidationWarning::SkillsUnsupported {
421 project: p.project.id.clone(),
422 agent: id.into(),
423 runtime: a.runtime.clone(),
424 count: a.skills.len(),
425 });
426 }
427 if a.effort.is_some() && a.runtime != "codex" {
428 warns.push(ValidationWarning::EffortUnsupported {
429 project: p.project.id.clone(),
430 agent: id.into(),
431 runtime: a.runtime.clone(),
432 });
433 }
434 if matches!(a.runtime.as_str(), "codex" | "opencode") {
444 for (server, srv) in &a.mcps {
445 if srv.env.values().any(|v| v.contains("${")) {
446 warns.push(ValidationWarning::McpEnvInterpolationUnsupported {
447 project: p.project.id.clone(),
448 agent: id.into(),
449 runtime: a.runtime.clone(),
450 server: server.clone(),
451 });
452 }
453 }
454 }
455 if let Some(mode) = &a.permission_mode {
467 match a.runtime.as_str() {
468 "codex" => {}
469 "opencode" => {
470 if mode == "bypassPermissions" {
471 warns.push(ValidationWarning::PermissionModeBypassDowngraded {
472 project: p.project.id.clone(),
473 agent: id.into(),
474 });
475 }
476 }
477 _ => {
478 warns.push(ValidationWarning::PermissionModeUnsupported {
479 project: p.project.id.clone(),
480 agent: id.into(),
481 runtime: a.runtime.clone(),
482 mode: mode.clone(),
483 });
484 }
485 }
486 }
487 };
488 for (id, a) in &p.managers {
489 check_agent(&mut warns, id, a);
490 }
491 for (id, a) in &p.workers {
492 check_agent(&mut warns, id, a);
493 }
494 }
495 warns
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::compose::*;
502 use std::collections::BTreeMap;
503 use std::path::PathBuf;
504
505 fn toy_compose(agent_dm_target: &str) -> Compose {
506 let mut managers = BTreeMap::new();
507 managers.insert(
508 "mgr".into(),
509 Agent {
510 runtime: "claude-code".into(),
511 model: Some("claude-opus-4-8".into()),
512 role_prompt: None,
513 permission_mode: None,
514 autonomy: "low_risk_only".into(),
515 can_dm: vec![agent_dm_target.into()],
516 can_broadcast: vec!["team".into()],
517 reports_to: None,
518 on_rate_limit: None,
519 effort: None,
520 ultracode: false,
521 interfaces: None,
522 display_name: None,
523 hooks: vec![],
524 mcps: Default::default(),
525 subagents: vec![],
526 skills: vec![],
527 },
528 );
529 let mut workers = BTreeMap::new();
530 workers.insert(
531 "dev".into(),
532 Agent {
533 runtime: "claude-code".into(),
534 model: None,
535 role_prompt: None,
536 permission_mode: None,
537 autonomy: "low_risk_only".into(),
538 can_dm: vec!["mgr".into()],
539 can_broadcast: vec!["team".into()],
540 reports_to: Some("mgr".into()),
541 on_rate_limit: None,
542 effort: None,
543 ultracode: false,
544 interfaces: None,
545 display_name: None,
546 hooks: vec![],
547 mcps: Default::default(),
548 subagents: vec![],
549 skills: vec![],
550 },
551 );
552 Compose {
553 root: PathBuf::from("."),
554 global: Global {
555 version: crate::compose::SchemaVersion::new("2.0.0"),
556 broker: Default::default(),
557 supervisor: Default::default(),
558 budget: Default::default(),
559 hitl: Default::default(),
560 rate_limits: Default::default(),
561 interfaces: vec![],
562 projects: vec![],
563 attachments: Default::default(),
564 },
565 projects: vec![Project {
566 version: 2,
567 project: ProjectMeta {
568 id: "hello".into(),
569 name: "Hello".into(),
570 cwd: PathBuf::from("."),
571 },
572 channels: vec![Channel {
573 name: "team".into(),
574 members: ChannelMembers::All("*".into()),
575 }],
576 managers,
577 workers,
578 interfaces: None,
579 }],
580 }
581 }
582
583 #[test]
584 fn clean_compose_validates() {
585 let c = toy_compose("dev");
586 assert_eq!(validate(&c), vec![]);
587 }
588
589 #[test]
590 fn dm_to_unknown_agent_flags() {
591 let c = toy_compose("ghost");
592 let e = validate(&c);
593 assert!(matches!(
594 e.as_slice(),
595 [ValidationError::DmUnknownTarget { .. }]
596 ));
597 }
598
599 #[test]
600 fn unknown_broker_flags() {
601 let mut c = toy_compose("dev");
602 c.global.broker.r#type = "redis".into();
603 assert!(validate(&c)
604 .iter()
605 .any(|e| matches!(e, ValidationError::UnknownBroker(_))));
606 }
607
608 #[test]
609 fn drain_timeout_above_600s_flags() {
610 let mut c = toy_compose("dev");
611 c.global.supervisor.drain_timeout_secs = 86_400;
612 assert!(validate(&c)
613 .iter()
614 .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(86_400))));
615 }
616
617 #[test]
618 fn drain_timeout_zero_is_valid() {
619 let mut c = toy_compose("dev");
620 c.global.supervisor.drain_timeout_secs = 0;
621 assert!(!validate(&c)
622 .iter()
623 .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(_))));
624 }
625
626 #[test]
627 fn empty_role_prompt_list_flags() {
628 let mut c = toy_compose("dev");
629 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
630 Some(crate::compose::RolePrompt::Multiple(vec![]));
631 assert!(validate(&c)
632 .iter()
633 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
634 }
635
636 #[test]
637 fn empty_role_prompt_string_flags() {
638 let mut c = toy_compose("dev");
643 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
644 Some(crate::compose::RolePrompt::Single(PathBuf::from("")));
645 assert!(validate(&c)
646 .iter()
647 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
648 }
649
650 #[test]
651 fn single_role_prompt_validates() {
652 let mut c = toy_compose("dev");
653 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
654 crate::compose::RolePrompt::Single(PathBuf::from("roles/mgr.md")),
655 );
656 assert!(!validate(&c)
657 .iter()
658 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
659 }
660
661 #[test]
662 fn populated_role_prompt_list_validates() {
663 let mut c = toy_compose("dev");
664 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
665 crate::compose::RolePrompt::Multiple(vec![PathBuf::from("roles/mgr.md")]),
666 );
667 assert!(!validate(&c)
668 .iter()
669 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
670 }
671
672 #[test]
673 fn blank_display_name_flags() {
674 let mut c = toy_compose("dev");
675 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(String::new());
676 assert!(validate(&c)
677 .iter()
678 .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
679 }
680
681 #[test]
682 fn declared_mcp_server_named_team_flags() {
683 let mut c = toy_compose("dev");
686 let mut mcps = std::collections::BTreeMap::new();
687 mcps.insert(
688 "team".into(),
689 crate::compose::McpServer {
690 command: "evil".into(),
691 args: vec![],
692 env: Default::default(),
693 },
694 );
695 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
696 assert!(validate(&c)
697 .iter()
698 .any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
699 }
700
701 #[test]
702 fn declared_mcp_server_with_normal_name_validates() {
703 let mut c = toy_compose("dev");
705 let mut mcps = std::collections::BTreeMap::new();
706 mcps.insert(
707 "github".into(),
708 crate::compose::McpServer {
709 command: "npx".into(),
710 args: vec![],
711 env: Default::default(),
712 },
713 );
714 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
715 assert!(!validate(&c)
716 .iter()
717 .any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
718 }
719
720 #[test]
721 fn display_name_at_max_length_validates() {
722 let mut c = toy_compose("dev");
723 let exactly_max = "x".repeat(DISPLAY_NAME_MAX_CHARS);
724 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(exactly_max);
725 assert!(!validate(&c).iter().any(|e| matches!(
726 e,
727 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
728 )));
729 }
730
731 #[test]
732 fn display_name_above_max_length_flags() {
733 let mut c = toy_compose("dev");
734 let too_long = "x".repeat(DISPLAY_NAME_MAX_CHARS + 1);
735 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(too_long);
736 assert!(validate(&c)
737 .iter()
738 .any(|e| matches!(e, ValidationError::DisplayNameTooLong { .. })));
739 }
740
741 #[test]
742 fn display_name_counts_chars_not_bytes() {
743 let mut c = toy_compose("dev");
748 let sixty_four_crabs = "🦀".repeat(DISPLAY_NAME_MAX_CHARS);
749 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(sixty_four_crabs);
750 assert!(!validate(&c).iter().any(|e| matches!(
751 e,
752 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
753 )));
754 }
755
756 #[test]
757 fn whitespace_only_display_name_flags_blank() {
758 let mut c = toy_compose("dev");
762 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(" ".into());
763 assert!(validate(&c)
764 .iter()
765 .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
766 }
767
768 #[test]
769 fn populated_display_name_validates() {
770 let mut c = toy_compose("dev");
771 c.projects[0].managers.get_mut("mgr").unwrap().display_name =
772 Some("Sage (Visionary)".into());
773 assert!(!validate(&c).iter().any(|e| matches!(
774 e,
775 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
776 )));
777 }
778
779 #[test]
782 fn is_valid_id_accepts_existing_id_shapes() {
783 for ok in [
787 "teamctl",
788 "ops",
789 "nico",
790 "eng_lead",
791 "pr-22-review",
792 "blog-site",
793 "my.team",
794 "a1",
795 "x-2.0",
796 "a",
797 "0",
798 "A",
799 "_",
800 "-",
801 ".",
802 ] {
803 assert!(is_valid_id(ok), "must accept conformant id `{ok}`");
804 }
805 }
806
807 #[test]
808 fn is_valid_id_rejects_shell_metacharacter_class() {
809 for bad in [
813 "evil; rm",
814 "proj$(id)",
815 "with space",
816 "back`ticks`",
817 "p|ipe",
818 "p&",
819 "p*g",
820 "p?g",
821 "p~e",
822 "p!g",
823 "p#g",
824 "p'q",
825 "p\"q",
826 "p\\g",
827 "p<g",
828 "p>g",
829 "p(g",
830 "p)g",
831 "p\tg",
832 "p\ng",
833 ] {
834 assert!(!is_valid_id(bad), "must reject `{bad:?}`");
835 }
836 }
837
838 #[test]
839 fn is_valid_id_rejects_colon_as_reserved_separator() {
840 assert!(!is_valid_id("p:rj"));
845 assert!(!is_valid_id(":"));
846 assert!(!is_valid_id("a:"));
847 assert!(!is_valid_id(":a"));
848 }
849
850 #[test]
851 fn is_valid_id_rejects_empty_and_control_chars() {
852 assert!(!is_valid_id(""));
853 assert!(!is_valid_id("\0"));
854 assert!(!is_valid_id("p\x07q"));
855 }
856
857 #[test]
858 fn is_valid_id_rejects_non_ascii() {
859 assert!(!is_valid_id("crab🦀"));
862 assert!(!is_valid_id("café"));
863 }
864
865 #[test]
866 fn clean_compose_passes_id_charset() {
867 let c = toy_compose("dev");
870 let errs = validate(&c);
871 assert!(
872 !errs.iter().any(|e| matches!(
873 e,
874 ValidationError::InvalidProjectId(_) | ValidationError::InvalidAgentId { .. }
875 )),
876 "clean compose unexpectedly flagged for id charset: {errs:?}",
877 );
878 }
879
880 #[test]
881 fn project_id_with_shell_metacharacters_flags() {
882 let mut c = toy_compose("dev");
888 c.projects[0].project.id = "evil; rm -rf ~".into();
889 let errs = validate(&c);
890 assert!(
891 errs.iter().any(|e| matches!(
892 e,
893 ValidationError::InvalidProjectId(s) if s == "evil; rm -rf ~"
894 )),
895 "expected InvalidProjectId, got {errs:?}",
896 );
897 }
898
899 #[test]
900 fn manager_id_with_shell_metacharacters_flags() {
901 let mut c = toy_compose("dev");
905 let bad = "$(id)";
906 let mgr = c.projects[0].managers.remove("mgr").unwrap();
907 c.projects[0].managers.insert(bad.into(), mgr);
908 let errs = validate(&c);
909 assert!(
910 errs.iter().any(|e| matches!(
911 e,
912 ValidationError::InvalidAgentId { project, agent }
913 if project == "hello" && agent == bad
914 )),
915 "expected InvalidAgentId for manager, got {errs:?}",
916 );
917 }
918
919 #[test]
920 fn worker_id_with_shell_metacharacters_flags() {
921 let mut c = toy_compose("dev");
923 let bad = "rogue|pipe";
924 let wkr = c.projects[0].workers.remove("dev").unwrap();
925 c.projects[0].workers.insert(bad.into(), wkr);
926 let errs = validate(&c);
929 assert!(
930 errs.iter().any(|e| matches!(
931 e,
932 ValidationError::InvalidAgentId { project, agent }
933 if project == "hello" && agent == bad
934 )),
935 "expected InvalidAgentId for worker, got {errs:?}",
936 );
937 }
938
939 #[test]
940 fn project_id_with_reserved_colon_flags() {
941 let mut c = toy_compose("dev");
945 c.projects[0].project.id = "foo:bar".into();
946 let errs = validate(&c);
947 assert!(
948 errs.iter()
949 .any(|e| matches!(e, ValidationError::InvalidProjectId(s) if s == "foo:bar")),
950 "expected InvalidProjectId on colon, got {errs:?}",
951 );
952 }
953
954 #[test]
959 fn valid_semver_string_validates() {
960 let c = toy_compose("dev");
962 assert!(
963 !validate(&c)
964 .iter()
965 .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
966 "canonical version `2.0.0` must validate"
967 );
968 }
969
970 #[test]
971 fn malformed_semver_string_flags() {
972 let mut c = toy_compose("dev");
973 c.global.version = crate::compose::SchemaVersion::new("abc");
974 let errs = validate(&c);
975 assert!(
976 errs.iter().any(|e| matches!(
977 e,
978 ValidationError::SchemaVersionInvalid { got } if got == "abc"
979 )),
980 "non-semver string must surface SchemaVersionInvalid; got {errs:?}"
981 );
982 }
983
984 #[test]
985 fn bare_two_string_flags_too() {
986 let mut c = toy_compose("dev");
989 c.global.version = crate::compose::SchemaVersion::new("2");
990 assert!(
991 validate(&c).iter().any(|e| matches!(
992 e,
993 ValidationError::SchemaVersionInvalid { got } if got == "2"
994 )),
995 "bare-2-string must NOT pass the semver shape check"
996 );
997 }
998
999 #[test]
1000 fn semver_with_prerelease_and_build_metadata_validates() {
1001 for ok in [
1006 "1.0.0",
1007 "2.3.4",
1008 "2.0.0-alpha",
1009 "1.0.0+build.5",
1010 "2.0.0-rc.1+build.7",
1011 ] {
1012 let mut c = toy_compose("dev");
1013 c.global.version = crate::compose::SchemaVersion::new(ok);
1014 assert!(
1015 !validate(&c)
1016 .iter()
1017 .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
1018 "semver `{ok}` must validate"
1019 );
1020 }
1021 }
1022
1023 fn one_hook() -> crate::compose::HookSpec {
1031 crate::compose::HookSpec {
1032 event: "PreToolUse".into(),
1033 matcher: None,
1034 command: PathBuf::from("hooks/guard.sh"),
1035 }
1036 }
1037
1038 #[test]
1039 fn clean_compose_produces_no_warnings() {
1040 let c = toy_compose("dev");
1041 assert_eq!(validate_warnings(&c), vec![]);
1042 }
1043
1044 #[test]
1045 fn codex_agent_with_hooks_warns() {
1046 let mut c = toy_compose("dev");
1047 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1048 mgr.runtime = "codex".into();
1049 mgr.hooks = vec![one_hook(), one_hook()];
1050 let warns = validate_warnings(&c);
1051 assert!(
1052 warns.iter().any(|w| matches!(
1053 w,
1054 ValidationWarning::HooksUnsupported { project, agent, runtime, count }
1055 if project == "hello" && agent == "mgr" && runtime == "codex" && *count == 2
1056 )),
1057 "expected HooksUnsupported, got {warns:?}",
1058 );
1059 }
1060
1061 #[test]
1062 fn codex_agent_with_subagents_warns() {
1063 let mut c = toy_compose("dev");
1064 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1065 mgr.runtime = "codex".into();
1066 mgr.subagents = vec![PathBuf::from("agents/reviewer.md")];
1067 assert!(validate_warnings(&c)
1068 .iter()
1069 .any(|w| matches!(w, ValidationWarning::SubagentsUnsupported { count: 1, .. })));
1070 }
1071
1072 #[test]
1073 fn codex_agent_with_skills_warns() {
1074 let mut c = toy_compose("dev");
1075 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1076 mgr.runtime = "codex".into();
1077 mgr.skills = vec![PathBuf::from("skills/release")];
1078 assert!(validate_warnings(&c)
1079 .iter()
1080 .any(|w| matches!(w, ValidationWarning::SkillsUnsupported { count: 1, .. })));
1081 }
1082
1083 fn github_mcp_with_placeholder() -> crate::compose::McpServer {
1085 crate::compose::McpServer {
1086 command: "npx".into(),
1087 args: vec!["-y".into(), "@modelcontextprotocol/server-github".into()],
1088 env: [("GITHUB_TOKEN".to_string(), "${GITHUB_TOKEN}".to_string())]
1089 .into_iter()
1090 .collect(),
1091 }
1092 }
1093
1094 #[test]
1095 fn claude_code_agent_with_all_capabilities_produces_no_warnings() {
1096 let mut c = toy_compose("dev");
1097 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1098 mgr.hooks = vec![one_hook()];
1099 mgr.subagents = vec![PathBuf::from("agents/reviewer.md")];
1100 mgr.skills = vec![PathBuf::from("skills/release")];
1101 mgr.effort = Some(crate::compose::EffortLevel::High);
1102 mgr.permission_mode = Some("attended".into());
1105 mgr.mcps
1106 .insert("github".into(), github_mcp_with_placeholder());
1107 assert_eq!(validate_warnings(&c), vec![]);
1108 }
1109
1110 #[test]
1111 fn codex_agent_with_effort_only_produces_no_warnings() {
1112 let mut c = toy_compose("dev");
1115 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1116 mgr.runtime = "codex".into();
1117 mgr.effort = Some(crate::compose::EffortLevel::High);
1118 assert_eq!(validate_warnings(&c), vec![]);
1119 }
1120
1121 #[test]
1122 fn codex_agent_with_mcp_env_placeholder_warns() {
1123 let mut c = toy_compose("dev");
1127 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1128 mgr.runtime = "codex".into();
1129 mgr.mcps
1130 .insert("github".into(), github_mcp_with_placeholder());
1131 let warns = validate_warnings(&c);
1132 assert!(
1133 warns.iter().any(|w| matches!(
1134 w,
1135 ValidationWarning::McpEnvInterpolationUnsupported { project, agent, runtime, server }
1136 if project == "hello" && agent == "mgr" && runtime == "codex" && server == "github"
1137 )),
1138 "expected McpEnvInterpolationUnsupported, got {warns:?}",
1139 );
1140 }
1141
1142 #[test]
1143 fn codex_agent_with_literal_mcp_env_produces_no_warnings() {
1144 let mut c = toy_compose("dev");
1146 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1147 mgr.runtime = "codex".into();
1148 let mut gh = github_mcp_with_placeholder();
1149 gh.env
1150 .insert("GITHUB_TOKEN".into(), "ghp_literal-token".into());
1151 mgr.mcps.insert("github".into(), gh);
1152 assert_eq!(validate_warnings(&c), vec![]);
1153 }
1154
1155 #[test]
1156 fn gemini_agent_with_permission_mode_warns() {
1157 let mut c = toy_compose("dev");
1161 let wkr = c.projects[0].workers.get_mut("dev").unwrap();
1162 wkr.runtime = "gemini".into();
1163 wkr.permission_mode = Some("attended".into());
1164 let warns = validate_warnings(&c);
1165 assert!(
1166 warns.iter().any(|w| matches!(
1167 w,
1168 ValidationWarning::PermissionModeUnsupported { agent, runtime, mode, .. }
1169 if agent == "dev" && runtime == "gemini" && mode == "attended"
1170 )),
1171 "expected PermissionModeUnsupported, got {warns:?}",
1172 );
1173 }
1174
1175 #[test]
1176 fn gemini_agent_without_permission_mode_produces_no_permission_warning() {
1177 let mut c = toy_compose("dev");
1179 let wkr = c.projects[0].workers.get_mut("dev").unwrap();
1180 wkr.runtime = "gemini".into();
1181 assert!(!validate_warnings(&c)
1182 .iter()
1183 .any(|w| matches!(w, ValidationWarning::PermissionModeUnsupported { .. })));
1184 }
1185
1186 #[test]
1187 fn codex_agent_with_permission_mode_produces_no_warnings() {
1188 let mut c = toy_compose("dev");
1191 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1192 mgr.runtime = "codex".into();
1193 mgr.permission_mode = Some("bypassPermissions".into());
1194 assert_eq!(validate_warnings(&c), vec![]);
1195 }
1196
1197 #[test]
1198 fn gemini_agent_with_effort_warns() {
1199 let mut c = toy_compose("dev");
1201 let wkr = c.projects[0].workers.get_mut("dev").unwrap();
1202 wkr.runtime = "gemini".into();
1203 wkr.effort = Some(crate::compose::EffortLevel::Low);
1204 let warns = validate_warnings(&c);
1205 assert!(
1206 warns.iter().any(|w| matches!(
1207 w,
1208 ValidationWarning::EffortUnsupported { agent, runtime, .. }
1209 if agent == "dev" && runtime == "gemini"
1210 )),
1211 "expected EffortUnsupported, got {warns:?}",
1212 );
1213 }
1214
1215 #[test]
1216 fn opencode_agent_with_effort_warns() {
1217 let mut c = toy_compose("dev");
1221 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1222 mgr.runtime = "opencode".into();
1223 mgr.effort = Some(crate::compose::EffortLevel::High);
1224 let warns = validate_warnings(&c);
1225 assert!(
1226 warns.iter().any(|w| matches!(
1227 w,
1228 ValidationWarning::EffortUnsupported { agent, runtime, .. }
1229 if agent == "mgr" && runtime == "opencode"
1230 )),
1231 "expected EffortUnsupported, got {warns:?}",
1232 );
1233 }
1234
1235 #[test]
1236 fn opencode_agent_with_hooks_subagents_skills_warns() {
1237 let mut c = toy_compose("dev");
1240 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1241 mgr.runtime = "opencode".into();
1242 mgr.hooks = vec![one_hook()];
1243 mgr.subagents = vec![PathBuf::from("agents/reviewer.md")];
1244 mgr.skills = vec![PathBuf::from("skills/release")];
1245 let warns = validate_warnings(&c);
1246 assert!(
1247 warns
1248 .iter()
1249 .any(|w| matches!(w, ValidationWarning::HooksUnsupported { runtime, .. } if runtime == "opencode")),
1250 "expected HooksUnsupported, got {warns:?}",
1251 );
1252 assert!(
1253 warns
1254 .iter()
1255 .any(|w| matches!(w, ValidationWarning::SubagentsUnsupported { runtime, .. } if runtime == "opencode")),
1256 "expected SubagentsUnsupported, got {warns:?}",
1257 );
1258 assert!(
1259 warns
1260 .iter()
1261 .any(|w| matches!(w, ValidationWarning::SkillsUnsupported { runtime, .. } if runtime == "opencode")),
1262 "expected SkillsUnsupported, got {warns:?}",
1263 );
1264 }
1265
1266 #[test]
1267 fn opencode_agent_with_mcp_env_placeholder_warns() {
1268 let mut c = toy_compose("dev");
1272 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1273 mgr.runtime = "opencode".into();
1274 mgr.mcps
1275 .insert("github".into(), github_mcp_with_placeholder());
1276 let warns = validate_warnings(&c);
1277 assert!(
1278 warns.iter().any(|w| matches!(
1279 w,
1280 ValidationWarning::McpEnvInterpolationUnsupported { project, agent, runtime, server }
1281 if project == "hello" && agent == "mgr" && runtime == "opencode" && server == "github"
1282 )),
1283 "expected McpEnvInterpolationUnsupported, got {warns:?}",
1284 );
1285 }
1286
1287 #[test]
1288 fn opencode_agent_with_permission_mode_produces_no_warnings() {
1289 let mut c = toy_compose("dev");
1293 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1294 mgr.runtime = "opencode".into();
1295 mgr.permission_mode = Some("attended".into());
1296 assert_eq!(validate_warnings(&c), vec![]);
1297 }
1298
1299 #[test]
1300 fn opencode_agent_with_bypass_permissions_warns_downgrade() {
1301 let mut c = toy_compose("dev");
1305 let mgr = c.projects[0].managers.get_mut("mgr").unwrap();
1306 mgr.runtime = "opencode".into();
1307 mgr.permission_mode = Some("bypassPermissions".into());
1308 let warns = validate_warnings(&c);
1309 assert!(
1310 warns.iter().any(|w| matches!(
1311 w,
1312 ValidationWarning::PermissionModeBypassDowngraded { project, agent }
1313 if project == "hello" && agent == "mgr"
1314 )),
1315 "expected PermissionModeBypassDowngraded, got {warns:?}",
1316 );
1317 assert!(
1320 !warns
1321 .iter()
1322 .any(|w| matches!(w, ValidationWarning::PermissionModeUnsupported { .. })),
1323 "downgrade must not double-fire the generic warning: {warns:?}",
1324 );
1325 }
1326}