use std::collections::BTreeSet;
use thiserror::Error;
use crate::compose::{ChannelMembers, Compose};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ValidationError {
#[error("project `{0}`: duplicate agent id `{1}` in managers and workers")]
DuplicateAgent(String, String),
#[error(
"project `{project}`: unknown agent `{agent}` referenced in channel `{channel}` members"
)]
ChannelUnknownMember {
project: String,
channel: String,
agent: String,
},
#[error("project `{project}`: agent `{agent}` `can_dm` lists unknown agent `{target}`")]
DmUnknownTarget {
project: String,
agent: String,
target: String,
},
#[error(
"project `{project}`: agent `{agent}` `can_broadcast` lists unknown channel `{channel}`"
)]
BroadcastUnknownChannel {
project: String,
agent: String,
channel: String,
},
#[error(
"project `{project}`: agent `{agent}` has an `interfaces.telegram` block but is not a manager"
)]
TelegramInboxOnWorker { project: String, agent: String },
#[error(
"worker `{project}:{agent}` declares `reports_to: {target}` but no such manager exists"
)]
UnknownManager {
project: String,
agent: String,
target: String,
},
#[error("broker type `{0}` not supported (known: sqlite)")]
UnknownBroker(String),
#[error("supervisor type `{0}` not supported (known: tmux, systemd, launchd)")]
UnknownSupervisor(String),
#[error("duplicate project id `{0}`")]
DuplicateProject(String),
#[error(
"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)"
)]
InvalidProjectId(String),
#[error(
"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)"
)]
InvalidAgentId { project: String, agent: String },
#[error("project `{project}`: agent `{agent}` uses runtime `{runtime}`, which is not built in and not declared in `<root>/runtimes/{runtime}.yaml`")]
UnknownRuntime {
project: String,
agent: String,
runtime: String,
},
#[error("supervisor.drain_timeout_secs={0} is unreasonable; expected 0..=600")]
DrainTimeoutOutOfRange(u64),
#[error(
"compose schema `version: {got}` is not a valid semver string (expected e.g. `\"2.0.0\"`)"
)]
SchemaVersionInvalid { got: String },
#[error(
"project `{project}`: agent `{agent}` has a blank `role_prompt` (empty string or empty list)"
)]
BlankRolePrompt { project: String, agent: String },
#[error("project `{project}`: agent `{agent}` has a blank `display_name`")]
BlankDisplayName { project: String, agent: String },
#[error("project `{project}`: agent `{agent}` `display_name` is {got} chars (max {max})")]
DisplayNameTooLong {
project: String,
agent: String,
got: usize,
max: usize,
},
#[error(
"project `{project}`: agent `{agent}` declares an MCP server named `team`, which is reserved for the built-in mailbox server"
)]
ReservedMcpServerName { project: String, agent: String },
}
pub const DISPLAY_NAME_MAX_CHARS: usize = 64;
pub fn is_valid_id(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
pub fn validate(compose: &Compose) -> Vec<ValidationError> {
let mut errs = Vec::new();
let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
let check_runtime = !runtimes.is_empty();
match compose.global.broker.r#type.as_str() {
"sqlite" => {}
other => errs.push(ValidationError::UnknownBroker(other.into())),
}
match compose.global.supervisor.r#type.as_str() {
"tmux" | "systemd" | "launchd" => {}
other => errs.push(ValidationError::UnknownSupervisor(other.into())),
}
if compose.global.supervisor.drain_timeout_secs > 600 {
errs.push(ValidationError::DrainTimeoutOutOfRange(
compose.global.supervisor.drain_timeout_secs,
));
}
if semver::Version::parse(&compose.global.version.value).is_err() {
errs.push(ValidationError::SchemaVersionInvalid {
got: compose.global.version.value.clone(),
});
}
let mut seen_projects = BTreeSet::new();
for p in &compose.projects {
if !seen_projects.insert(p.project.id.clone()) {
errs.push(ValidationError::DuplicateProject(p.project.id.clone()));
}
if !is_valid_id(&p.project.id) {
errs.push(ValidationError::InvalidProjectId(p.project.id.clone()));
}
for id in p.managers.keys().chain(p.workers.keys()) {
if !is_valid_id(id) {
errs.push(ValidationError::InvalidAgentId {
project: p.project.id.clone(),
agent: id.clone(),
});
}
}
let mgr_ids: BTreeSet<&str> = p.managers.keys().map(|s| s.as_str()).collect();
let wrk_ids: BTreeSet<&str> = p.workers.keys().map(|s| s.as_str()).collect();
for dup in mgr_ids.intersection(&wrk_ids) {
errs.push(ValidationError::DuplicateAgent(
p.project.id.clone(),
(*dup).to_string(),
));
}
let all_agents: BTreeSet<&str> = mgr_ids.union(&wrk_ids).copied().collect();
let channel_names: BTreeSet<&str> = p.channels.iter().map(|c| c.name.as_str()).collect();
for ch in &p.channels {
if let ChannelMembers::Explicit(members) = &ch.members {
for m in members {
if !all_agents.contains(m.as_str()) {
errs.push(ValidationError::ChannelUnknownMember {
project: p.project.id.clone(),
channel: ch.name.clone(),
agent: m.clone(),
});
}
}
}
}
let check_agent = |errs: &mut Vec<ValidationError>,
id: &str,
a: &crate::compose::Agent,
is_manager: bool| {
if a.telegram().is_some() && !is_manager {
errs.push(ValidationError::TelegramInboxOnWorker {
project: p.project.id.clone(),
agent: id.into(),
});
}
for t in &a.can_dm {
if !all_agents.contains(t.as_str()) {
errs.push(ValidationError::DmUnknownTarget {
project: p.project.id.clone(),
agent: id.into(),
target: t.clone(),
});
}
}
for c in &a.can_broadcast {
if !channel_names.contains(c.as_str()) {
errs.push(ValidationError::BroadcastUnknownChannel {
project: p.project.id.clone(),
agent: id.into(),
channel: c.clone(),
});
}
}
if let Some(t) = &a.reports_to {
if !mgr_ids.contains(t.as_str()) {
errs.push(ValidationError::UnknownManager {
project: p.project.id.clone(),
agent: id.into(),
target: t.clone(),
});
}
}
if check_runtime && !runtimes.contains_key(a.runtime.as_str()) {
errs.push(ValidationError::UnknownRuntime {
project: p.project.id.clone(),
agent: id.into(),
runtime: a.runtime.clone(),
});
}
if let Some(rp) = &a.role_prompt {
if rp.is_blank() {
errs.push(ValidationError::BlankRolePrompt {
project: p.project.id.clone(),
agent: id.into(),
});
}
}
if let Some(dn) = &a.display_name {
let trimmed_len = dn.trim().chars().count();
if trimmed_len == 0 {
errs.push(ValidationError::BlankDisplayName {
project: p.project.id.clone(),
agent: id.into(),
});
} else if dn.chars().count() > DISPLAY_NAME_MAX_CHARS {
errs.push(ValidationError::DisplayNameTooLong {
project: p.project.id.clone(),
agent: id.into(),
got: dn.chars().count(),
max: DISPLAY_NAME_MAX_CHARS,
});
}
}
if a.mcps.contains_key("team") {
errs.push(ValidationError::ReservedMcpServerName {
project: p.project.id.clone(),
agent: id.into(),
});
}
};
for (id, a) in &p.managers {
check_agent(&mut errs, id, a, true);
}
for (id, a) in &p.workers {
check_agent(&mut errs, id, a, false);
}
}
errs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compose::*;
use std::collections::BTreeMap;
use std::path::PathBuf;
fn toy_compose(agent_dm_target: &str) -> Compose {
let mut managers = BTreeMap::new();
managers.insert(
"mgr".into(),
Agent {
runtime: "claude-code".into(),
model: Some("claude-opus-4-8".into()),
role_prompt: None,
permission_mode: None,
autonomy: "low_risk_only".into(),
can_dm: vec![agent_dm_target.into()],
can_broadcast: vec!["team".into()],
reports_to: None,
on_rate_limit: None,
effort: None,
interfaces: None,
display_name: None,
hooks: vec![],
mcps: Default::default(),
subagents: vec![],
skills: vec![],
},
);
let mut workers = BTreeMap::new();
workers.insert(
"dev".into(),
Agent {
runtime: "claude-code".into(),
model: None,
role_prompt: None,
permission_mode: None,
autonomy: "low_risk_only".into(),
can_dm: vec!["mgr".into()],
can_broadcast: vec!["team".into()],
reports_to: Some("mgr".into()),
on_rate_limit: None,
effort: None,
interfaces: None,
display_name: None,
hooks: vec![],
mcps: Default::default(),
subagents: vec![],
skills: vec![],
},
);
Compose {
root: PathBuf::from("."),
global: Global {
version: crate::compose::SchemaVersion::new("2.0.0"),
broker: Default::default(),
supervisor: Default::default(),
budget: Default::default(),
hitl: Default::default(),
rate_limits: Default::default(),
interfaces: vec![],
projects: vec![],
attachments: Default::default(),
},
projects: vec![Project {
version: 2,
project: ProjectMeta {
id: "hello".into(),
name: "Hello".into(),
cwd: PathBuf::from("."),
},
channels: vec![Channel {
name: "team".into(),
members: ChannelMembers::All("*".into()),
}],
managers,
workers,
interfaces: None,
}],
}
}
#[test]
fn clean_compose_validates() {
let c = toy_compose("dev");
assert_eq!(validate(&c), vec![]);
}
#[test]
fn dm_to_unknown_agent_flags() {
let c = toy_compose("ghost");
let e = validate(&c);
assert!(matches!(
e.as_slice(),
[ValidationError::DmUnknownTarget { .. }]
));
}
#[test]
fn unknown_broker_flags() {
let mut c = toy_compose("dev");
c.global.broker.r#type = "redis".into();
assert!(validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::UnknownBroker(_))));
}
#[test]
fn drain_timeout_above_600s_flags() {
let mut c = toy_compose("dev");
c.global.supervisor.drain_timeout_secs = 86_400;
assert!(validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(86_400))));
}
#[test]
fn drain_timeout_zero_is_valid() {
let mut c = toy_compose("dev");
c.global.supervisor.drain_timeout_secs = 0;
assert!(!validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(_))));
}
#[test]
fn empty_role_prompt_list_flags() {
let mut c = toy_compose("dev");
c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
Some(crate::compose::RolePrompt::Multiple(vec![]));
assert!(validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
}
#[test]
fn empty_role_prompt_string_flags() {
let mut c = toy_compose("dev");
c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
Some(crate::compose::RolePrompt::Single(PathBuf::from("")));
assert!(validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
}
#[test]
fn single_role_prompt_validates() {
let mut c = toy_compose("dev");
c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
crate::compose::RolePrompt::Single(PathBuf::from("roles/mgr.md")),
);
assert!(!validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
}
#[test]
fn populated_role_prompt_list_validates() {
let mut c = toy_compose("dev");
c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
crate::compose::RolePrompt::Multiple(vec![PathBuf::from("roles/mgr.md")]),
);
assert!(!validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
}
#[test]
fn blank_display_name_flags() {
let mut c = toy_compose("dev");
c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(String::new());
assert!(validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
}
#[test]
fn declared_mcp_server_named_team_flags() {
let mut c = toy_compose("dev");
let mut mcps = std::collections::BTreeMap::new();
mcps.insert(
"team".into(),
crate::compose::McpServer {
command: "evil".into(),
args: vec![],
env: Default::default(),
},
);
c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
assert!(validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
}
#[test]
fn declared_mcp_server_with_normal_name_validates() {
let mut c = toy_compose("dev");
let mut mcps = std::collections::BTreeMap::new();
mcps.insert(
"github".into(),
crate::compose::McpServer {
command: "npx".into(),
args: vec![],
env: Default::default(),
},
);
c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
assert!(!validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
}
#[test]
fn display_name_at_max_length_validates() {
let mut c = toy_compose("dev");
let exactly_max = "x".repeat(DISPLAY_NAME_MAX_CHARS);
c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(exactly_max);
assert!(!validate(&c).iter().any(|e| matches!(
e,
ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
)));
}
#[test]
fn display_name_above_max_length_flags() {
let mut c = toy_compose("dev");
let too_long = "x".repeat(DISPLAY_NAME_MAX_CHARS + 1);
c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(too_long);
assert!(validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::DisplayNameTooLong { .. })));
}
#[test]
fn display_name_counts_chars_not_bytes() {
let mut c = toy_compose("dev");
let sixty_four_crabs = "🦀".repeat(DISPLAY_NAME_MAX_CHARS);
c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(sixty_four_crabs);
assert!(!validate(&c).iter().any(|e| matches!(
e,
ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
)));
}
#[test]
fn whitespace_only_display_name_flags_blank() {
let mut c = toy_compose("dev");
c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(" ".into());
assert!(validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
}
#[test]
fn populated_display_name_validates() {
let mut c = toy_compose("dev");
c.projects[0].managers.get_mut("mgr").unwrap().display_name =
Some("Sage (Visionary)".into());
assert!(!validate(&c).iter().any(|e| matches!(
e,
ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
)));
}
#[test]
fn is_valid_id_accepts_existing_id_shapes() {
for ok in [
"teamctl",
"ops",
"nico",
"eng_lead",
"pr-22-review",
"blog-site",
"my.team",
"a1",
"x-2.0",
"a",
"0",
"A",
"_",
"-",
".",
] {
assert!(is_valid_id(ok), "must accept conformant id `{ok}`");
}
}
#[test]
fn is_valid_id_rejects_shell_metacharacter_class() {
for bad in [
"evil; rm",
"proj$(id)",
"with space",
"back`ticks`",
"p|ipe",
"p&",
"p*g",
"p?g",
"p~e",
"p!g",
"p#g",
"p'q",
"p\"q",
"p\\g",
"p<g",
"p>g",
"p(g",
"p)g",
"p\tg",
"p\ng",
] {
assert!(!is_valid_id(bad), "must reject `{bad:?}`");
}
}
#[test]
fn is_valid_id_rejects_colon_as_reserved_separator() {
assert!(!is_valid_id("p:rj"));
assert!(!is_valid_id(":"));
assert!(!is_valid_id("a:"));
assert!(!is_valid_id(":a"));
}
#[test]
fn is_valid_id_rejects_empty_and_control_chars() {
assert!(!is_valid_id(""));
assert!(!is_valid_id("\0"));
assert!(!is_valid_id("p\x07q"));
}
#[test]
fn is_valid_id_rejects_non_ascii() {
assert!(!is_valid_id("crab🦀"));
assert!(!is_valid_id("café"));
}
#[test]
fn clean_compose_passes_id_charset() {
let c = toy_compose("dev");
let errs = validate(&c);
assert!(
!errs.iter().any(|e| matches!(
e,
ValidationError::InvalidProjectId(_) | ValidationError::InvalidAgentId { .. }
)),
"clean compose unexpectedly flagged for id charset: {errs:?}",
);
}
#[test]
fn project_id_with_shell_metacharacters_flags() {
let mut c = toy_compose("dev");
c.projects[0].project.id = "evil; rm -rf ~".into();
let errs = validate(&c);
assert!(
errs.iter().any(|e| matches!(
e,
ValidationError::InvalidProjectId(s) if s == "evil; rm -rf ~"
)),
"expected InvalidProjectId, got {errs:?}",
);
}
#[test]
fn manager_id_with_shell_metacharacters_flags() {
let mut c = toy_compose("dev");
let bad = "$(id)";
let mgr = c.projects[0].managers.remove("mgr").unwrap();
c.projects[0].managers.insert(bad.into(), mgr);
let errs = validate(&c);
assert!(
errs.iter().any(|e| matches!(
e,
ValidationError::InvalidAgentId { project, agent }
if project == "hello" && agent == bad
)),
"expected InvalidAgentId for manager, got {errs:?}",
);
}
#[test]
fn worker_id_with_shell_metacharacters_flags() {
let mut c = toy_compose("dev");
let bad = "rogue|pipe";
let wkr = c.projects[0].workers.remove("dev").unwrap();
c.projects[0].workers.insert(bad.into(), wkr);
let errs = validate(&c);
assert!(
errs.iter().any(|e| matches!(
e,
ValidationError::InvalidAgentId { project, agent }
if project == "hello" && agent == bad
)),
"expected InvalidAgentId for worker, got {errs:?}",
);
}
#[test]
fn project_id_with_reserved_colon_flags() {
let mut c = toy_compose("dev");
c.projects[0].project.id = "foo:bar".into();
let errs = validate(&c);
assert!(
errs.iter()
.any(|e| matches!(e, ValidationError::InvalidProjectId(s) if s == "foo:bar")),
"expected InvalidProjectId on colon, got {errs:?}",
);
}
#[test]
fn valid_semver_string_validates() {
let c = toy_compose("dev");
assert!(
!validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
"canonical version `2.0.0` must validate"
);
}
#[test]
fn malformed_semver_string_flags() {
let mut c = toy_compose("dev");
c.global.version = crate::compose::SchemaVersion::new("abc");
let errs = validate(&c);
assert!(
errs.iter().any(|e| matches!(
e,
ValidationError::SchemaVersionInvalid { got } if got == "abc"
)),
"non-semver string must surface SchemaVersionInvalid; got {errs:?}"
);
}
#[test]
fn bare_two_string_flags_too() {
let mut c = toy_compose("dev");
c.global.version = crate::compose::SchemaVersion::new("2");
assert!(
validate(&c).iter().any(|e| matches!(
e,
ValidationError::SchemaVersionInvalid { got } if got == "2"
)),
"bare-2-string must NOT pass the semver shape check"
);
}
#[test]
fn semver_with_prerelease_and_build_metadata_validates() {
for ok in [
"1.0.0",
"2.3.4",
"2.0.0-alpha",
"1.0.0+build.5",
"2.0.0-rc.1+build.7",
] {
let mut c = toy_compose("dev");
c.global.version = crate::compose::SchemaVersion::new(ok);
assert!(
!validate(&c)
.iter()
.any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
"semver `{ok}` must validate"
);
}
}
}