返回 CodeWhale
roster.rs
根目录 / crates / tui / src / fleet / roster.rs
1 //! Fleet roster — the persistent, inspectable party of named agent roles.
2 //!
3 //! The roster merges four layers into one config-backed lineup shared by
4 //! model-spawned sub-agents and fleet dispatch (#fleet-roster cutover
5 //! (v0.8.67)):
6 //!
7 //! - built-in members (the default party, always available),
8 //! - `[fleet.profiles]` entries from config.toml,
9 //! - personal `$CODEWHALE_HOME/agents/*.toml` profile files,
10 //! - workspace `.codewhale/agents/*.toml` profile files.
11 //!
12 //! Precedence is Workspace > Personal > Config > BuiltIn, merged by id. Loading never
13 //! fails the session: an unreadable workspace profile dir degrades to the
14 //! built-in + config layers with a log line.
15 //!
16 //! Two guardrails (#5098):
17 //!
18 //! - Shadowing is recorded, not silent: when a higher layer displaces a
19 //! lower-precedence file for the same id, the roster keeps a
20 //! [`ShadowedProfile`] receipt (logged at load, badged in the roster view)
21 //! so an edit in the losing layer is visibly ignored rather than dropped.
22 //! - Project-scope profiles (`.codewhale/agents/*.toml`) join the roster only
23 //! when project-level config is trusted for the launch; `--no-project-config`
24 //! opts the whole layer out, same as `.codewhale/config.toml` (#485).
25
26 #![allow(dead_code)]
27
28 use std::collections::HashMap;
29 use std::path::{Path, PathBuf};
30
31 use serde::{Deserialize, Serialize};
32
33 use codewhale_config::{
34 FleetConfigToml, FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions,
35 FleetRole, FleetSlot,
36 };
37
38 use super::profile::{
39 AgentProfile, load_agent_profiles_from_dir_tolerant, load_workspace_agent_profiles_tolerant,
40 personal_agent_profile_dir,
41 };
42
43 /// Which layer a roster member came from. Higher layers override lower ones
44 /// by id (Workspace > Personal > Config > BuiltIn).
45 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46 #[serde(rename_all = "snake_case")]
47 pub enum ProfileOrigin {
48 BuiltIn,
49 Config,
50 Personal,
51 Workspace,
52 }
53
54 impl std::fmt::Display for ProfileOrigin {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.write_str(match self {
57 Self::BuiltIn => "built-in",
58 Self::Config => "config",
59 Self::Personal => "personal",
60 Self::Workspace => "project",
61 })
62 }
63 }
64
65 /// The merged fleet roster. Think RPG saved party / K8s runconfig: a stable,
66 /// named lineup of agent roles the session can inspect and dispatch against.
67 #[derive(Debug, Clone)]
68 pub struct FleetRoster {
69 members: Vec<AgentProfile>,
70 /// Lower-precedence profiles displaced by a higher layer for the same id
71 /// (#5098). Shadowing is normal precedence, but it must be VISIBLE: a
72 /// personal edit that loses to a stale project copy otherwise changes
73 /// nothing anywhere with no signal why.
74 shadowed: Vec<ShadowedProfile>,
75 }
76
77 /// A lower-precedence profile displaced by a higher layer for the same id.
78 #[derive(Debug, Clone, PartialEq, Eq)]
79 pub struct ShadowedProfile {
80 pub id: String,
81 pub shadowed_origin: ProfileOrigin,
82 pub shadowed_source: PathBuf,
83 pub winner_origin: ProfileOrigin,
84 pub winner_source: PathBuf,
85 }
86
87 /// Process-launch decision: whether project-scope agent profiles
88 /// (`.codewhale/agents/*.toml`) may join the dispatch roster (#5098). Set
89 /// once from `--no-project-config` at launch so every roster re-read (spawn
90 /// refresh, dispatch, views) honors the same trust decision other
91 /// project-level config already has (#485). Defaults to enabled, matching
92 /// project config itself.
93 static PROJECT_AGENT_PROFILES_ENABLED: std::sync::atomic::AtomicBool =
94 std::sync::atomic::AtomicBool::new(true);
95
96 /// Record the launch-time trust decision for project-scope agent profiles.
97 pub fn set_project_agent_profiles_enabled(enabled: bool) {
98 PROJECT_AGENT_PROFILES_ENABLED.store(enabled, std::sync::atomic::Ordering::Relaxed);
99 }
100
101 /// Whether project-scope agent profiles join the roster in this process.
102 #[must_use]
103 pub fn project_agent_profiles_enabled() -> bool {
104 PROJECT_AGENT_PROFILES_ENABLED.load(std::sync::atomic::Ordering::Relaxed)
105 }
106
107 impl FleetRoster {
108 /// Roster containing only the built-in party. Used as the runtime default
109 /// before config/workspace layers are wired in.
110 #[must_use]
111 pub fn built_ins_only() -> Self {
112 Self {
113 members: Self::built_in_members(),
114 shadowed: Vec::new(),
115 }
116 }
117
118 /// A roster built from an explicit member list.
119 ///
120 /// Used for run-scoped rosters that are not a merge of the config layers —
121 /// notably an exact named Fleet, whose members are frozen at Workflow
122 /// start and must not pick up built-in or workspace profiles by name.
123 #[must_use]
124 pub fn from_members(members: Vec<AgentProfile>) -> Self {
125 Self {
126 members,
127 shadowed: Vec::new(),
128 }
129 }
130
131 /// Load and merge the full roster for a workspace.
132 ///
133 /// Config members come from `[fleet.profiles]` (id = map key). Personal
134 /// members come from `$CODEWHALE_HOME/agents/*.toml`, and workspace members
135 /// come from `.codewhale/agents/*.toml`. A load failure is logged and
136 /// skipped so one broken profile layer cannot take down the session.
137 #[must_use]
138 pub fn load(fleet_config: &FleetConfigToml, workspace: &Path) -> Self {
139 let personal_dir = personal_agent_profile_dir().ok();
140 Self::load_with_personal_dir(
141 fleet_config,
142 workspace,
143 personal_dir.as_deref(),
144 project_agent_profiles_enabled(),
145 )
146 }
147
148 fn load_with_personal_dir(
149 fleet_config: &FleetConfigToml,
150 workspace: &Path,
151 personal_dir: Option<&Path>,
152 include_workspace_profiles: bool,
153 ) -> Self {
154 let mut built_ins = Self::built_in_members();
155 let mut extras: Vec<AgentProfile> = Vec::new();
156 let mut shadowed: Vec<ShadowedProfile> = Vec::new();
157
158 for (id, profile) in &fleet_config.profiles {
159 let mut profile = profile.clone();
160 profile.role.name = super::profile::canonical_public_role_name(&profile.role.name);
161 profile.slot = FleetSlot::from_name(&profile.role.name);
162 let member = AgentProfile {
163 id: id.clone(),
164 display_name: None,
165 description: profile.role.description.clone(),
166 profile,
167 source: PathBuf::from("config.toml"),
168 origin: ProfileOrigin::Config,
169 };
170 record_shadow(
171 merge_member(&mut built_ins, &mut extras, member),
172 &mut shadowed,
173 );
174 }
175
176 if let Some(personal_dir) = personal_dir {
177 match load_agent_profiles_from_dir_tolerant(personal_dir, ProfileOrigin::Personal) {
178 Ok((profiles, issues)) => {
179 for issue in issues {
180 tracing::warn!(
181 "fleet roster: skipping invalid personal agent profile: {issue}"
182 );
183 }
184 for member in profiles {
185 record_shadow(
186 merge_member(&mut built_ins, &mut extras, member),
187 &mut shadowed,
188 );
189 }
190 }
191 Err(err) => {
192 tracing::warn!("fleet roster: skipping personal agent profiles: {err:#}");
193 }
194 }
195 }
196
197 // #5098: project-scope profiles join the dispatch roster only when the
198 // launch trusted project-level config (`--no-project-config` opts the
199 // whole layer out, same as `.codewhale/config.toml`).
200 if include_workspace_profiles {
201 match load_workspace_agent_profiles_tolerant(workspace) {
202 Ok((profiles, issues)) => {
203 for issue in issues {
204 tracing::warn!(
205 workspace = %workspace.display(),
206 "fleet roster: skipping invalid workspace agent profile: {issue}"
207 );
208 }
209 for member in profiles {
210 record_shadow(
211 merge_member(&mut built_ins, &mut extras, member),
212 &mut shadowed,
213 );
214 }
215 }
216 Err(err) => {
217 tracing::warn!(
218 workspace = %workspace.display(),
219 "fleet roster: skipping workspace agent profiles: {err:#}"
220 );
221 }
222 }
223 }
224
225 for shadow in &shadowed {
226 // Overriding a built-in is the intended customization path —
227 // keep it quiet. A file layer (config/personal) losing to another
228 // file layer is the #5098 footgun: the edit changes nothing
229 // anywhere and must be visible.
230 if shadow.shadowed_origin == ProfileOrigin::BuiltIn {
231 tracing::debug!(
232 "fleet roster: '{}' {} copy at {} overrides the built-in default",
233 shadow.id,
234 shadow.winner_origin,
235 shadow.winner_source.display()
236 );
237 } else {
238 tracing::warn!(
239 "fleet roster: '{}' {} copy at {} shadows the {} copy at {} (ignored)",
240 shadow.id,
241 shadow.winner_origin,
242 shadow.winner_source.display(),
243 shadow.shadowed_origin,
244 shadow.shadowed_source.display()
245 );
246 }
247 }
248
249 // Built-ins keep their canonical slot order (overrides included);
250 // config/workspace-only extras follow alphabetically.
251 extras.sort_by_key(|a| a.id.to_lowercase());
252 let mut members = built_ins;
253 members.extend(extras);
254 Self { members, shadowed }
255 }
256
257 /// The default party. Built-ins carry no permission grants (permissions
258 /// stay at the [`FleetProfilePermissions::default`] floor); behavior comes
259 /// from the role posture / system prompts plus the role `instructions`
260 /// below, which encode the coordination hierarchy: the **operator** (the
261 /// session's `/model` selection) directs the work and assigns managers
262 /// to workflows; a **manager** is the middle manager of one workflow.
263 #[must_use]
264 pub fn built_in_members() -> Vec<AgentProfile> {
265 [
266 (
267 "manager",
268 FleetSlot::Manager,
269 FleetLoadout::Inherit,
270 "Middle manager for one workflow: decomposes it into bounded tasks, dispatches workers, integrates results, and reports to the operator.",
271 Some(
272 "You lead exactly one workflow. Decompose it into bounded tasks, dispatch them to the right roles, keep work-in-progress small, integrate the results, and report a concise receipt (what was done, evidence, gaps) upward. Do not take on work outside your workflow.",
273 ),
274 ),
275 (
276 "operator",
277 FleetSlot::Operator,
278 FleetLoadout::Inherit,
279 "The helm of the session — the session's /model selection. Assigns managers to Workflows, routes work between them, arbitrates conflicts, and reviews what comes back.",
280 Some(
281 "You direct the overall work, not individual Workflow steps. Assign a manager per Workflow, route work and context between them, arbitrate conflicts and priorities, review the receipts that come back, and decide what runs next. Delegate execution; keep judgment.",
282 ),
283 ),
284 (
285 "scout",
286 FleetSlot::Scout,
287 FleetLoadout::Inherit,
288 "Read-only reconnaissance: find files, map code, gather evidence.",
289 None,
290 ),
291 (
292 "builder",
293 FleetSlot::Implementer,
294 FleetLoadout::Inherit,
295 "Writes code: implements bounded tasks with write and shell access.",
296 None,
297 ),
298 (
299 "reviewer",
300 FleetSlot::Reviewer,
301 FleetLoadout::Inherit,
302 "Adversarial code review: assumes the change is broken and tries to prove it — regressions, missing tests, unhandled cases. Read-only.",
303 Some(
304 "Be adversarial: assume the change is wrong until the evidence proves otherwise. Actively try to refute the claims made about the work — hunt regressions, missing tests, unhandled edge cases, and quiet behavior changes. Report severity-scored findings with file:line evidence; if nothing survives your attack, say so plainly. Never patch.",
305 ),
306 ),
307 (
308 "verifier",
309 FleetSlot::Verifier,
310 FleetLoadout::Inherit,
311 "Runs builds and tests to verify claims; reports evidence, does not patch.",
312 None,
313 ),
314 (
315 "consultant",
316 FleetSlot::Custom("consultant".to_string()),
317 FleetLoadout::Inherit,
318 "Short-lived, high-reasoning, read-only counsel for difficult decisions and overlooked risks.",
319 Some(
320 "Give the operator a direct second opinion grounded in what you can read. Surface the decisive tradeoff, overlooked failure mode, and your recommendation. Advise only: do not edit files or run commands.",
321 ),
322 ),
323 (
324 "synthesizer",
325 FleetSlot::Summarizer,
326 FleetLoadout::Inherit,
327 "Read-only synthesis: merge findings into one coherent report.",
328 None,
329 ),
330 (
331 "general",
332 FleetSlot::General,
333 FleetLoadout::Inherit,
334 "General-purpose worker with full capabilities.",
335 None,
336 ),
337 ]
338 .into_iter()
339 .map(|(id, slot, loadout, description, instructions)| AgentProfile {
340 id: id.to_string(),
341 display_name: None,
342 description: Some(description.to_string()),
343 profile: FleetProfile {
344 slot,
345 role: FleetRole {
346 name: id.to_string(),
347 description: Some(description.to_string()),
348 instructions: instructions.map(str::to_string),
349 },
350 loadout,
351 model: None,
352 provider: None,
353 reasoning_effort: (id == "consultant").then(|| "high".to_string()),
354 permissions: FleetProfilePermissions::default(),
355 delegation: FleetDelegationHints::default(),
356 },
357 source: PathBuf::from("built-in"),
358 origin: ProfileOrigin::BuiltIn,
359 })
360 .collect()
361 }
362
363 /// Look up a member by id (trimmed, case-insensitive).
364 #[must_use]
365 pub fn get(&self, id: &str) -> Option<&AgentProfile> {
366 let id = id.trim();
367 self.members
368 .iter()
369 .find(|member| member.id.trim().eq_ignore_ascii_case(id))
370 }
371
372 /// All members in stable order: built-in canonical order first (an
373 /// overridden built-in keeps its slot but shows its overriding origin),
374 /// then extra config/workspace-only members alphabetically.
375 #[must_use]
376 pub fn members(&self) -> &[AgentProfile] {
377 &self.members
378 }
379
380 /// Per-member explicit model pins, keyed by lowercased member id.
381 /// Feeds the sub-agent `role_models` lookup; explicit `[subagents]`
382 /// overrides are merged on top by the engine and win.
383 ///
384 /// Members that ALSO pin a provider are deliberately excluded. This map is
385 /// provider-less by construction: the sub-agent role/type lookup
386 /// (`configured_model_for_role_or_type`) applies whatever it finds against
387 /// the *session* provider's client, so exporting a provider-pinned model
388 /// here strips the only thing that made the id routable. A profile pinning
389 /// `provider = "deepseek"` + `model = "deepseek-v4-flash"` then leaks that
390 /// bare id onto an unrelated session route — and on a pass-through
391 /// provider (Alibaba Model Studio, whose Token Plan actually serves
392 /// `deepseek-v4-flash-0731`) nothing downstream rejects it, so the child
393 /// dies on the provider's own denial instead of inheriting the parent's
394 /// working model. Provider-pinned profiles keep their full route through
395 /// the profile spawn path (`child_provider_binding`), which builds a client
396 /// for the pinned provider and carries the model with it.
397 #[must_use]
398 pub fn model_overrides(&self) -> HashMap<String, String> {
399 self.members
400 .iter()
401 .filter_map(|member| {
402 if member
403 .profile
404 .provider
405 .as_deref()
406 .is_some_and(|provider| !provider.trim().is_empty())
407 {
408 return None;
409 }
410 let model = member.profile.model.as_deref()?.trim();
411 (!model.is_empty()).then(|| (member.id.to_lowercase(), model.to_string()))
412 })
413 .collect()
414 }
415 /// Lower-precedence profiles displaced by higher layers (#5098). Empty
416 /// for `built_ins_only` / `from_members` rosters.
417 #[must_use]
418 pub fn shadowed(&self) -> &[ShadowedProfile] {
419 &self.shadowed
420 }
421
422 /// Shadow records for one member id (trimmed, case-insensitive).
423 pub fn shadowed_for<'a>(&'a self, id: &'a str) -> impl Iterator<Item = &'a ShadowedProfile> {
424 let id = id.trim().to_lowercase();
425 self.shadowed
426 .iter()
427 .filter(move |shadow| shadow.id.trim().eq_ignore_ascii_case(&id))
428 }
429 }
430
431 /// Fold a displaced layer (if any) into the shadow log.
432 fn record_shadow(displaced: Option<ShadowedProfile>, shadowed: &mut Vec<ShadowedProfile>) {
433 if let Some(shadow) = displaced {
434 shadowed.push(shadow);
435 }
436 }
437
438 /// Overlay `member` onto the roster layers: replace an existing member with
439 /// the same id (case-insensitive) in place, otherwise collect it as an extra.
440 /// Returns a shadow record when a lower-precedence layer was displaced so the
441 /// load can log it and the roster can surface it (#5098).
442 fn merge_member(
443 built_ins: &mut [AgentProfile],
444 extras: &mut Vec<AgentProfile>,
445 member: AgentProfile,
446 ) -> Option<ShadowedProfile> {
447 let matches =
448 |existing: &AgentProfile| existing.id.trim().eq_ignore_ascii_case(member.id.trim());
449 let slot = built_ins
450 .iter_mut()
451 .find(|existing| matches(existing))
452 .or_else(|| extras.iter_mut().find(|existing| matches(existing)));
453 match slot {
454 Some(existing) => {
455 let shadow = ShadowedProfile {
456 id: existing.id.clone(),
457 shadowed_origin: existing.origin,
458 shadowed_source: existing.source.clone(),
459 winner_origin: member.origin,
460 winner_source: member.source.clone(),
461 };
462 *existing = member;
463 Some(shadow)
464 }
465 None => {
466 extras.push(member);
467 None
468 }
469 }
470 }
471
472 #[cfg(test)]
473 mod tests {
474 use super::*;
475 use std::collections::BTreeMap;
476 use tempfile::TempDir;
477
478 fn config_with_profiles(profiles: BTreeMap<String, FleetProfile>) -> FleetConfigToml {
479 FleetConfigToml {
480 profiles,
481 ..FleetConfigToml::default()
482 }
483 }
484
485 fn config_profile(role: &str, model: Option<&str>) -> FleetProfile {
486 FleetProfile {
487 slot: FleetSlot::from_name(role),
488 role: FleetRole {
489 name: role.to_string(),
490 description: Some(format!("{role} from config")),
491 instructions: None,
492 },
493 loadout: FleetLoadout::Inherit,
494 model: model.map(str::to_string),
495 provider: None,
496 reasoning_effort: None,
497 permissions: FleetProfilePermissions::default(),
498 delegation: FleetDelegationHints::default(),
499 }
500 }
501
502 fn write_workspace_profile(workspace: &Path, filename: &str, contents: &str) {
503 let dir = workspace.join(super::super::profile::WORKSPACE_AGENT_PROFILE_DIR);
504 std::fs::create_dir_all(&dir).unwrap();
505 std::fs::write(dir.join(filename), contents).unwrap();
506 }
507
508 #[test]
509 fn built_in_party_is_complete_with_floor_permissions() {
510 let members = FleetRoster::built_in_members();
511 let ids: Vec<&str> = members.iter().map(|m| m.id.as_str()).collect();
512 assert_eq!(
513 ids,
514 [
515 "manager",
516 "operator",
517 "scout",
518 "builder",
519 "reviewer",
520 "verifier",
521 "consultant",
522 "synthesizer",
523 "general"
524 ]
525 );
526 for member in &members {
527 assert_eq!(member.origin, ProfileOrigin::BuiltIn, "{}", member.id);
528 assert_eq!(
529 member.profile.permissions,
530 FleetProfilePermissions::default(),
531 "built-in {} must stay at the permission floor",
532 member.id
533 );
534 assert_eq!(
535 member.profile.delegation,
536 FleetDelegationHints::default(),
537 "{}",
538 member.id
539 );
540 assert!(member.profile.model.is_none(), "{}", member.id);
541 assert_eq!(
542 member.profile.reasoning_effort.as_deref(),
543 (member.id == "consultant").then_some("high"),
544 "built-in {} reasoning",
545 member.id
546 );
547 // The coordination hierarchy (operator/manager) and the
548 // adversarial reviewer carry role doctrine; the remaining
549 // built-ins get behavior from posture / system prompts alone.
550 let carries_doctrine = matches!(
551 member.id.as_str(),
552 "manager" | "operator" | "reviewer" | "consultant"
553 );
554 assert_eq!(
555 member.profile.role.instructions.is_some(),
556 carries_doctrine,
557 "built-in {} instructions presence",
558 member.id
559 );
560 assert!(member.description.is_some(), "{}", member.id);
561 }
562 assert_eq!(members[0].profile.slot, FleetSlot::Manager);
563 assert_eq!(members[1].profile.slot, FleetSlot::Operator);
564 assert_eq!(members[2].profile.loadout, FleetLoadout::Inherit);
565 assert_eq!(members[6].profile.slot.as_str(), "consultant");
566 assert_eq!(members[7].profile.slot, FleetSlot::Summarizer);
567 assert_eq!(members[7].profile.loadout, FleetLoadout::Inherit);
568 }
569
570 #[test]
571 fn config_member_overrides_built_in_and_extras_sort_alphabetically() {
572 let _env_lock = crate::test_support::lock_test_env();
573 let home = TempDir::new().unwrap();
574 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
575 let tmp = TempDir::new().unwrap();
576 let config = config_with_profiles(BTreeMap::from([
577 (
578 "reviewer".to_string(),
579 config_profile("reviewer", Some("deepseek-v4-pro")),
580 ),
581 ("zeta".to_string(), config_profile("scout", None)),
582 ("alpha".to_string(), config_profile("builder", None)),
583 ]));
584
585 // Isolate from ambient personal agent profiles on developer machines.
586 let roster = FleetRoster::load_with_personal_dir(&config, tmp.path(), None, true);
587
588 let ids: Vec<&str> = roster.members().iter().map(|m| m.id.as_str()).collect();
589 assert_eq!(
590 ids,
591 [
592 "manager",
593 "operator",
594 "scout",
595 "builder",
596 "reviewer",
597 "verifier",
598 "consultant",
599 "synthesizer",
600 "general",
601 "alpha",
602 "zeta"
603 ],
604 "overridden built-in keeps its slot; extras follow alphabetically"
605 );
606 let reviewer = roster.get("reviewer").unwrap();
607 assert_eq!(reviewer.origin, ProfileOrigin::Config);
608 assert_eq!(reviewer.profile.model.as_deref(), Some("deepseek-v4-pro"));
609 assert_eq!(reviewer.source, PathBuf::from("config.toml"));
610 }
611
612 #[test]
613 fn workspace_member_wins_over_config_and_built_in() {
614 let tmp = TempDir::new().unwrap();
615 write_workspace_profile(
616 tmp.path(),
617 "reviewer.toml",
618 "id = \"reviewer\"\nrole_hint = \"reviewer\"\nmodel = \"glm-5.2\"\n",
619 );
620 let config = config_with_profiles(BTreeMap::from([(
621 "reviewer".to_string(),
622 config_profile("reviewer", Some("deepseek-v4-pro")),
623 )]));
624
625 let roster = FleetRoster::load(&config, tmp.path());
626
627 let reviewer = roster.get("reviewer").unwrap();
628 assert_eq!(reviewer.origin, ProfileOrigin::Workspace);
629 assert_eq!(reviewer.profile.model.as_deref(), Some("glm-5.2"));
630 // Precedence must not duplicate the member.
631 assert_eq!(
632 roster
633 .members()
634 .iter()
635 .filter(|m| m.id == "reviewer")
636 .count(),
637 1
638 );
639 }
640
641 #[test]
642 fn personal_member_applies_across_projects_but_project_still_wins() {
643 let tmp = TempDir::new().unwrap();
644 let personal_dir = tmp.path().join("personal-agents");
645 std::fs::create_dir_all(&personal_dir).unwrap();
646 std::fs::write(
647 personal_dir.join("reviewer.toml"),
648 "id = \"reviewer\"\nrole_hint = \"reviewer\"\nmodel = \"deepseek-v4-flash\"\n",
649 )
650 .unwrap();
651 let workspace = tmp.path().join("workspace");
652 std::fs::create_dir_all(&workspace).unwrap();
653
654 let personal = FleetRoster::load_with_personal_dir(
655 &FleetConfigToml::default(),
656 &workspace,
657 Some(&personal_dir),
658 true,
659 );
660 let reviewer = personal.get("reviewer").unwrap();
661 assert_eq!(reviewer.origin, ProfileOrigin::Personal);
662 assert_eq!(reviewer.profile.model.as_deref(), Some("deepseek-v4-flash"));
663
664 write_workspace_profile(
665 &workspace,
666 "reviewer.toml",
667 "id = \"reviewer\"\nrole_hint = \"reviewer\"\nmodel = \"glm-5.2\"\n",
668 );
669 let project = FleetRoster::load_with_personal_dir(
670 &FleetConfigToml::default(),
671 &workspace,
672 Some(&personal_dir),
673 true,
674 );
675 let reviewer = project.get("reviewer").unwrap();
676 assert_eq!(reviewer.origin, ProfileOrigin::Workspace);
677 assert_eq!(reviewer.profile.model.as_deref(), Some("glm-5.2"));
678 }
679
680 #[test]
681 fn personal_setup_target_round_trips_through_the_runtime_roster() {
682 let _env_lock = crate::test_support::lock_test_env();
683 let home = TempDir::new().unwrap();
684 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
685 let workspace = TempDir::new().unwrap();
686 let personal_dir = super::super::profile::agent_profile_dir_for_scope(
687 super::super::profile::FleetProfileScope::Personal,
688 workspace.path(),
689 )
690 .expect("personal profile directory");
691 assert_eq!(personal_dir, home.path().join("agents"));
692
693 let target = personal_dir.join("reviewer.toml");
694 let mut transaction = codewhale_config::persistence::SetupTransaction::new();
695 transaction.stage(
696 target.clone(),
697 b"id = \"reviewer\"\nrole_hint = \"reviewer\"\nprovider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n"
698 .to_vec(),
699 );
700 transaction.commit().expect("atomic personal save");
701 assert!(target.is_file(), "save must land under CODEWHALE_HOME");
702
703 let roster = FleetRoster::load(&FleetConfigToml::default(), workspace.path());
704 let reviewer = roster
705 .get("reviewer")
706 .expect("saved personal profile must be loaded");
707 assert_eq!(reviewer.origin, ProfileOrigin::Personal);
708 assert_eq!(reviewer.source, target);
709 assert_eq!(reviewer.profile.provider.as_deref(), Some("deepseek"));
710 assert_eq!(reviewer.profile.model.as_deref(), Some("deepseek-v4-flash"));
711 }
712
713 #[test]
714 fn broken_workspace_dir_degrades_to_built_ins_and_config() {
715 let tmp = TempDir::new().unwrap();
716 // A malformed provider token is still a load failure (#4093 / #3965):
717 // profile pins may name built-ins or simple custom ids like
718 // `lm-studio`, but whitespace/punctuation is rejected so a broken
719 // workspace dir still degrades to built-ins + config.
720 write_workspace_profile(
721 tmp.path(),
722 "broken.toml",
723 "provider = \"not a real provider\"\n",
724 );
725 let config = config_with_profiles(BTreeMap::from([(
726 "extra".to_string(),
727 config_profile("scout", None),
728 )]));
729
730 let roster = FleetRoster::load(&config, tmp.path());
731
732 assert!(roster.get("extra").is_some());
733 assert_eq!(
734 roster.members().len(),
735 FleetRoster::built_in_members().len() + 1
736 );
737 }
738
739 #[test]
740 fn invalid_legacy_profile_does_not_hide_valid_scout_neighbor() {
741 let _env_lock = crate::test_support::lock_test_env();
742 let home = TempDir::new().unwrap();
743 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
744 let tmp = TempDir::new().unwrap();
745 write_workspace_profile(
746 tmp.path(),
747 "reviewer.toml",
748 "id = \"reviewer\"\nmodel_class_hint = \"heavy\"\n",
749 );
750 write_workspace_profile(
751 tmp.path(),
752 "scout.toml",
753 "id = \"scout\"\nrole_hint = \"scout\"\nprovider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n",
754 );
755
756 // Isolate from ambient personal agent profiles on developer machines.
757 let roster = FleetRoster::load_with_personal_dir(
758 &FleetConfigToml::default(),
759 tmp.path(),
760 None,
761 true,
762 );
763
764 let scout = roster.get("scout").expect("valid scout remains visible");
765 assert_eq!(scout.origin, ProfileOrigin::Workspace);
766 assert_eq!(scout.profile.provider.as_deref(), Some("deepseek"));
767 assert_eq!(scout.profile.model.as_deref(), Some("deepseek-v4-flash"));
768 assert_eq!(
769 roster.get("reviewer").unwrap().origin,
770 ProfileOrigin::BuiltIn,
771 "invalid legacy override must fall back to the safe built-in"
772 );
773 }
774
775 #[test]
776 fn model_overrides_use_lowercased_ids_and_only_explicit_models() {
777 let _env_lock = crate::test_support::lock_test_env();
778 let home = TempDir::new().unwrap();
779 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
780 // Isolate personal `$CODEWHALE_HOME/agents` so ambient developer
781 // profiles cannot pin built-ins like manager during unit tests.
782 let tmp = TempDir::new().unwrap();
783 let config = config_with_profiles(BTreeMap::from([
784 (
785 "Reviewer".to_string(),
786 config_profile("reviewer", Some("deepseek-v4-pro")),
787 ),
788 ("scout".to_string(), config_profile("scout", None)),
789 ]));
790
791 let roster = FleetRoster::load(&config, tmp.path());
792 let overrides = roster.model_overrides();
793
794 assert_eq!(
795 overrides,
796 HashMap::from([("reviewer".to_string(), "deepseek-v4-pro".to_string())]),
797 "only members with explicit models are pinned, keyed lowercased"
798 );
799 }
800
801 /// A profile that pins BOTH a provider and a model must not contribute to
802 /// the provider-less `role_models` map. That map is applied against the
803 /// session provider's client, so exporting `deepseek-v4-flash` from a
804 /// `provider = "deepseek"` scout profile sent a bare DeepSeek id onto an
805 /// Alibaba Model Studio session (a pass-through provider, so nothing
806 /// downstream rejected it) and the scout died on the provider's denial —
807 /// the Model Studio Token Plan roster serves `deepseek-v4-flash-0731`, not
808 /// `deepseek-v4-flash`. Provider-pinned profiles keep their model via the
809 /// profile spawn path, which builds a client for the pinned provider.
810 #[test]
811 fn model_overrides_skip_provider_pinned_profiles() {
812 let _env_lock = crate::test_support::lock_test_env();
813 let home = TempDir::new().unwrap();
814 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
815 let tmp = TempDir::new().unwrap();
816
817 let mut pinned = config_profile("scout", Some("deepseek-v4-flash"));
818 pinned.provider = Some("deepseek".to_string());
819 let mut blank_provider = config_profile("builder", Some("deepseek-v4-pro"));
820 blank_provider.provider = Some(" ".to_string());
821
822 let config = config_with_profiles(BTreeMap::from([
823 ("scout".to_string(), pinned),
824 ("builder".to_string(), blank_provider),
825 ]));
826
827 let roster = FleetRoster::load(&config, tmp.path());
828 let overrides = roster.model_overrides();
829
830 assert!(
831 !overrides.contains_key("scout"),
832 "a provider-pinned profile must not leak its model into the \
833 provider-less role_models map: {overrides:?}"
834 );
835 assert_eq!(
836 overrides.get("builder").map(String::as_str),
837 Some("deepseek-v4-pro"),
838 "a blank provider pin is still provider-less: {overrides:?}"
839 );
840 // The pin itself survives on the member for the profile spawn path.
841 let scout = roster.get("scout").expect("scout member");
842 assert_eq!(scout.profile.provider.as_deref(), Some("deepseek"));
843 assert_eq!(scout.profile.model.as_deref(), Some("deepseek-v4-flash"));
844 }
845
846 #[test]
847 fn get_is_trimmed_and_case_insensitive() {
848 let roster = FleetRoster::built_ins_only();
849 assert!(roster.get(" Reviewer ").is_some());
850 assert!(roster.get("SYNTHESIZER").is_some());
851 assert!(roster.get("nonexistent").is_none());
852 }
853
854 #[test]
855 fn origin_labels_are_stable() {
856 assert_eq!(ProfileOrigin::BuiltIn.to_string(), "built-in");
857 assert_eq!(ProfileOrigin::Config.to_string(), "config");
858 assert_eq!(ProfileOrigin::Personal.to_string(), "personal");
859 assert_eq!(ProfileOrigin::Workspace.to_string(), "project");
860 }
861 }
862
863 #[cfg(test)]
864 mod shadow_and_trust_tests {
865 use super::*;
866 use tempfile::TempDir;
867
868 fn write_profile(dir: &Path, filename: &str, contents: &str) {
869 std::fs::create_dir_all(dir).unwrap();
870 std::fs::write(dir.join(filename), contents).unwrap();
871 }
872
873 #[test]
874 fn workspace_shadow_of_personal_file_is_recorded_and_reported() {
875 // #5098: editing the personal builder.toml changed nothing because a
876 // project copy silently shadowed it. The roster must report that the
877 // shadowed personal file exists and is ignored.
878 let tmp = TempDir::new().unwrap();
879 let personal_dir = tmp.path().join("personal");
880 let workspace = tmp.path().join("workspace");
881 std::fs::create_dir_all(&workspace).unwrap();
882 write_profile(
883 &personal_dir,
884 "builder.toml",
885 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-flash\"\n",
886 );
887 write_profile(
888 &workspace.join(".codewhale").join("agents"),
889 "builder.toml",
890 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-pro\"\n",
891 );
892
893 let roster = FleetRoster::load_with_personal_dir(
894 &FleetConfigToml::default(),
895 &workspace,
896 Some(&personal_dir),
897 true,
898 );
899
900 let builder = roster.get("builder").expect("builder member");
901 assert_eq!(builder.origin, ProfileOrigin::Workspace);
902 let shadows: Vec<_> = roster.shadowed_for("builder").collect();
903 // The chain is built-in → personal → workspace; both displacements
904 // are recorded, and the file-on-file one names the ignored personal
905 // copy explicitly.
906 assert_eq!(shadows.len(), 2, "full shadow chain: {shadows:?}");
907 let shadow = shadows
908 .iter()
909 .find(|shadow| shadow.shadowed_origin == ProfileOrigin::Personal)
910 .expect("personal file shadow is recorded");
911 assert!(shadow.shadowed_source.ends_with("builder.toml"));
912 assert_eq!(shadow.winner_origin, ProfileOrigin::Workspace);
913 assert!(
914 shadows
915 .iter()
916 .any(|shadow| shadow.shadowed_origin == ProfileOrigin::BuiltIn),
917 "the built-in displacement is recorded too: {shadows:?}"
918 );
919 assert!(
920 roster.shadowed().iter().any(|s| s.id == "builder"),
921 "roster-level shadow log carries the record"
922 );
923 }
924
925 #[test]
926 fn project_scope_profiles_are_skipped_when_the_layer_is_not_trusted() {
927 // #5098: `load_workspace_agent_profiles_tolerant` applied no trust
928 // check — a cloned repo's .codewhale/agents/*.toml silently joined
929 // the dispatch roster. With project config disabled
930 // (`--no-project-config`), the whole layer stays out.
931 let tmp = TempDir::new().unwrap();
932 let workspace = tmp.path().join("workspace");
933 write_profile(
934 &workspace.join(".codewhale").join("agents"),
935 "builder.toml",
936 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"gpt-5.6-luna\"\n",
937 );
938
939 let gated = FleetRoster::load_with_personal_dir(
940 &FleetConfigToml::default(),
941 &workspace,
942 None,
943 false,
944 );
945 let builder = gated.get("builder").expect("built-in builder remains");
946 assert_eq!(
947 builder.origin,
948 ProfileOrigin::BuiltIn,
949 "untrusted project profile must not join the roster"
950 );
951 assert_ne!(
952 builder.profile.model.as_deref(),
953 Some("gpt-5.6-luna"),
954 "foreign project pin must not reach dispatch"
955 );
956
957 let trusted = FleetRoster::load_with_personal_dir(
958 &FleetConfigToml::default(),
959 &workspace,
960 None,
961 true,
962 );
963 assert_eq!(
964 trusted.get("builder").expect("builder").origin,
965 ProfileOrigin::Workspace,
966 "trusted project profile wins as before"
967 );
968 }
969 }
970
970 lines RUST