Skip to main content

team_core/
validate.rs

1//! Invariant checks for a loaded [`Compose`] tree.
2//!
3//! Errors are collected rather than returned on first failure so the CLI can
4//! pretty-print the full list.
5
6use 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/// Non-fatal findings: compose shapes that load and render fine but
113/// silently drop declared behavior. `teamctl validate` prints these
114/// without failing — the compose is still valid, the operator just
115/// isn't getting what they wrote.
116#[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
169/// T-160: max length for `display_name`. 64 is a sensible upper bound
170/// matching ratatui column widths in the TUI roster pane; longer names
171/// would force unsightly truncation downstream. Counted in Unicode
172/// scalar values (`chars().count()`), not grapheme clusters — most
173/// operator-typed labels are simple text where the two coincide, and
174/// the saved dependency on `unicode-segmentation` is not worth the
175/// fidelity gain for an at-most-64-cell rendering window.
176pub const DISPLAY_NAME_MAX_CHARS: usize = 64;
177
178/// T-310: charset rule for `project.id` and agent ids — both flow,
179/// unquoted, into shell-bound strings (`{project}:{agent}` in
180/// `supervisor::build_up_command`) and into tmux session names. A
181/// conservative ASCII allowlist keeps every downstream consumer safe
182/// at the boundary instead of forcing each call site to quote
183/// defensively. `:` is intentionally NOT allowed — it's reserved as
184/// the canonical `<project>:<agent>` join separator everywhere in
185/// teamctl, and admitting it into an id would make `id()` parse
186/// ambiguous.
187///
188/// Rejected by construction: any whitespace, any shell metacharacter
189/// (`;`, `|`, `&`, `$`, backtick, `(`, `)`, `<`, `>`, `*`, `?`, `~`,
190/// `!`, `#`, `'`, `"`, `\`), any control char, any non-ASCII (incl.
191/// emoji), the empty string.
192pub 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    // Known runtimes. Embedded defaults are always present, so the
202    // validator can always enforce that every referenced runtime resolves
203    // to a descriptor (built in or user-supplied override).
204    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    // T-265 PR-a: compose schema version must be a valid semver
222    // string. Deserialization already accepted only (a) a string
223    // (passed verbatim into `SchemaVersion::value`) or (b) the
224    // legacy integer `2` (coerced to `"2.0.0"`); this validate-time
225    // check rejects in-string garbage like `"abc"` or `"2"` (the
226    // bare `"2"` is NOT semver — it needs the `.0.0` suffix).
227    // Delegated to the `semver` crate to get prerelease /
228    // build-metadata edge cases right rather than hand-rolling.
229    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        // T-310: id charset gate. Both `project.id` and the agent-id
241        // map keys flow unquoted into shell-bound strings + tmux
242        // session names downstream; rejecting unsafe chars at the
243        // boundary hardens every consumer at once.
244        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        // Channel members reference known agents.
267        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        // Per-agent checks.
283        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                // T-160: trim before checking so `display_name: "   "`
337                // is rejected as blank rather than slipping through
338                // (whitespace-only labels render as a void cell in the
339                // TUI — same operator-confusion shape as empty).
340                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            // #383 Phase 4: `team` is the built-in mailbox MCP server,
356            // injected on every agent. A declared server of the same name
357            // would shadow the bus, so reject it at validate (render also
358            // skips it defensively).
359            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
378/// Capability-mismatch sweep, separate from [`validate`] so the
379/// errors-only contract (and every `up` / `down` / `reload` / bot call
380/// site) stays untouched. `teamctl validate` calls both and prints
381/// warnings without flipping the exit code.
382///
383/// Hooks, sub-agents, and skills are claude-code-only today — the render
384/// layer already `tracing::warn!`s when it drops them (render can run
385/// without validate), but those warns are invisible in normal CLI runs;
386/// this is the operator-visible surface. `effort:` is consumed by both
387/// claude-code (`--effort`) and codex (`-c model_reasoning_effort`); any
388/// other wrapper arm ignores `$EFFORT` — opencode included, whose plain
389/// TUI rejects `--variant`. `permission_mode:` maps to flags in the
390/// claude-code, codex, and opencode arms (opencode has no full-bypass
391/// upstream, so `bypassPermissions` degrades to `--auto` and gets its own
392/// warning). And `${VAR}` placeholders in declared MCP env values are
393/// expanded by claude-code alone — codex and opencode hand the server the
394/// literal string.
395pub 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                // Claude Code expands `${VAR}` placeholders in .mcp.json
435                // env values at launch; codex reads config.toml literally
436                // and opencode its config json likewise, so the placeholder
437                // string reaches the server process and auth silently
438                // breaks. Deliberately NOT fixed by expanding at render
439                // time — that would write resolved secrets to disk. Warn so
440                // the operator gives the server a literal value or exports
441                // the secret in the environment the MCP server process
442                // inherits.
443                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                // `permission_mode` maps to real flags only in the wrapper's
456                // claude-code, codex, and opencode arms; the gemini arm
457                // hardcodes `--yolo`, so e.g. `permission_mode: attended`
458                // silently becomes bypass-everything — the most dangerous
459                // silent mismatch in the matrix. `None` is the schema
460                // default, so warning only on `Some` keeps untouched agents
461                // quiet. opencode is a partial mapping: it has no
462                // full-bypass upstream (the --yolo feature request was
463                // closed not-planned), so `bypassPermissions` degrades to
464                // `--auto` — deny rules stay enforced — and gets its own
465                // downgrade warning instead of a silent softening.
466                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        // Pre-existing hole in the old `Option<PathBuf>` schema: an
639        // empty string slipped through and rendered
640        // `SYSTEM_PROMPT_PATH=<root>/`. Closed alongside the list
641        // form's empty-list check.
642        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        // #383 Phase 4: `team` is the reserved built-in mailbox server;
684        // declaring one of the same name must be a validation error.
685        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        // A non-reserved server name must NOT trip the reserved check.
704        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        // T-160: limit applies to Unicode-scalar-value (`chars()`)
744        // count, not bytes. Each `🦀` is one char but four UTF-8
745        // bytes; 64 crabs must still validate even though that's 256
746        // bytes on disk.
747        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        // T-160 qa follow-up: whitespace-only labels render as a void
759        // cell in the TUI — reject under the same `BlankDisplayName`
760        // error as empty strings so the operator gets a clear message.
761        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    // ── T-310: id charset validation ────────────────────────────────────
780
781    #[test]
782    fn is_valid_id_accepts_existing_id_shapes() {
783        // Pin the conformant shapes currently in use across dogfood,
784        // cookbook, tests, and conventional fixtures — none of these
785        // may regress.
786        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        // The qa PoC class on #310: any shell metacharacter in an id
810        // would flow unquoted into `build_up_command`. Each of these
811        // must be rejected at the boundary.
812        for bad in [
813            "evil; rm",
814            "proj$(id)",
815            "with space",
816            "back`ticks`",
817            "p|ipe",
818            "p&amp",
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        // `:` is reserved for the canonical `<project>:<agent>` join;
841        // admitting it into an id would make `AgentHandle::id()`
842        // ambiguous *and* would still flow unquoted into shell-bound
843        // strings.
844        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        // Emoji and Unicode letters are operationally bad even though
860        // some are shell-inert; stay strictly ASCII per the issue.
861        assert!(!is_valid_id("crab🦀"));
862        assert!(!is_valid_id("café"));
863    }
864
865    #[test]
866    fn clean_compose_passes_id_charset() {
867        // Regression for conformant teams (acceptance criterion 5):
868        // existing valid ids are unaffected.
869        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        // qa PoC class regression (acceptance criterion 4): a
883        // `project.id` containing shell-metacharacter content is
884        // rejected by `validate` *before* it could reach
885        // `build_up_command`'s unquoted shell interpolation on
886        // `teamctl up` / `reload` / `down`.
887        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        // Same PoC class via the manager-key path — `compose.agents()`
902        // yields these ids and the supervisor flows them unquoted into
903        // `{project}:{agent}`.
904        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        // Same PoC class via the worker-key path.
922        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        // The dm target `dev` is also stale now — filter for the
927        // charset error specifically.
928        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        // `:` is the canonical project:agent join — admitting it would
942        // make routing parse ambiguous *and* still leak unquoted into
943        // shell strings.
944        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    // T-265 PR-a: schema version semver-shape check. Deserialize
955    // accepts any string verbatim (narrow concern); this validate
956    // step is what enforces the shape.
957
958    #[test]
959    fn valid_semver_string_validates() {
960        // Default fixture uses "2.0.0" — the canonical form.
961        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        // `"2"` is NOT semver (needs `.0.0`). Bare-2-string would
987        // sneak past if our check were too loose; pin it explicitly.
988        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        // Real-world semver supports `-pre` + `+build` suffixes.
1002        // Delegating to the `semver` crate gets these right;
1003        // pin the contract so a future "let's hand-roll a regex"
1004        // refactor surfaces here.
1005        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    // ── capability-mismatch warnings (non-fatal) ─────────────────────────
1024    //
1025    // Hooks / sub-agents / skills are claude-code-only; effort is consumed
1026    // by claude-code and codex but not gemini. The render layer drops the
1027    // unsupported ones with a tracing warn that's invisible in normal CLI
1028    // runs — `validate_warnings` is the operator-visible surface.
1029
1030    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    /// An `mcps:` server whose env carries a `${VAR}` placeholder.
1084    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        // Both stay quiet on claude-code: it maps permission_mode to a
1103        // real flag and expands `${VAR}` in MCP env values at launch.
1104        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        // Effort IS wired for codex (`-c model_reasoning_effort` in the
1113        // wrapper) — it must never trip the warning sweep.
1114        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        // Codex does not interpolate config.toml env values — the literal
1124        // `${GITHUB_TOKEN}` string would reach the server and auth
1125        // silently breaks.
1126        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        // Literal env values are exactly what codex wants — no warning.
1145        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        // The wrapper's gemini arm hardcodes --yolo, so an explicit
1158        // `permission_mode: attended` silently becomes bypass-everything —
1159        // the warning is the only visible trace of that mismatch.
1160        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        // `None` is the schema default — only an explicitly-set mode warns.
1178        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        // The codex arm maps permission_mode (attended / bypassPermissions
1189        // / headless default) to real flags — never a mismatch.
1190        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        // The wrapper's gemini arm never reads $EFFORT.
1200        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        // The wrapper's opencode arm never reads $EFFORT — the plain
1218        // opencode TUI rejects `--variant` (run-subcommand only), so the
1219        // effort exemption list stays {claude-code, codex}.
1220        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        // Pins that opencode rides the generic claude-only sweep: hooks,
1238        // sub-agents, and skills all warn, exactly like codex/gemini.
1239        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        // opencode reads its rendered config json literally, same as
1269        // codex's config.toml — the literal `${GITHUB_TOKEN}` string
1270        // would reach the server and auth silently breaks.
1271        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        // The opencode arm maps permission_mode (attended / headless
1290        // default → --auto) to real behavior — no mismatch for any mode
1291        // short of bypassPermissions.
1292        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        // opencode has no full-bypass upstream — the wrapper downgrades
1302        // `bypassPermissions` to `--auto` (deny rules stay enforced), and
1303        // the warning is the only visible trace of that softening.
1304        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        // The generic unsupported warning must NOT also fire — opencode
1318        // is a supported-with-downgrade runtime, not an unmapped one.
1319        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}