返回 CodeWhale
profile.rs
根目录 / crates / tui / src / fleet / profile.rs
1 //! Fleet profile vocabulary, local profile discovery, and config-facing aliases.
2
3 #![allow(dead_code)]
4
5 use std::collections::BTreeSet;
6 use std::path::{Path, PathBuf};
7
8 use anyhow::{Context, Result, anyhow, bail};
9 use serde::Deserialize;
10
11 use crate::tui::app::ReasoningEffort;
12
13 #[allow(unused_imports)]
14 pub use codewhale_config::{
15 FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions, FleetRole, FleetSlot,
16 };
17
18 pub use super::roster::ProfileOrigin;
19
20 pub const WORKSPACE_AGENT_PROFILE_DIR: &str = ".codewhale/agents";
21 pub const PERSONAL_AGENT_PROFILE_DIR: &str = "agents";
22
23 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
24 pub enum FleetProfileScope {
25 Project,
26 Personal,
27 }
28
29 impl FleetProfileScope {
30 #[must_use]
31 pub fn label(self) -> &'static str {
32 match self {
33 Self::Project => "project",
34 Self::Personal => "personal",
35 }
36 }
37
38 #[must_use]
39 pub fn display_dir(self) -> &'static str {
40 match self {
41 Self::Project => WORKSPACE_AGENT_PROFILE_DIR,
42 Self::Personal => "$CODEWHALE_HOME/agents",
43 }
44 }
45
46 #[must_use]
47 pub fn toggled(self) -> Self {
48 match self {
49 Self::Project => Self::Personal,
50 Self::Personal => Self::Project,
51 }
52 }
53 }
54
55 pub fn personal_agent_profile_dir() -> Result<PathBuf> {
56 Ok(codewhale_config::codewhale_home()?.join(PERSONAL_AGENT_PROFILE_DIR))
57 }
58
59 pub fn agent_profile_dir_for_scope(scope: FleetProfileScope, workspace: &Path) -> Result<PathBuf> {
60 match scope {
61 FleetProfileScope::Project => Ok(workspace.join(WORKSPACE_AGENT_PROFILE_DIR)),
62 FleetProfileScope::Personal => personal_agent_profile_dir(),
63 }
64 }
65
66 #[derive(Debug, Clone, PartialEq, Eq)]
67 pub struct AgentProfile {
68 pub id: String,
69 pub display_name: Option<String>,
70 pub description: Option<String>,
71 pub profile: FleetProfile,
72 pub source: PathBuf,
73 /// Roster layer this profile came from (#fleet-roster cutover (v0.8.67)).
74 /// File-based loading in this module always yields `Workspace`; the
75 /// roster stamps `BuiltIn` / `Config` for the other layers.
76 pub origin: ProfileOrigin,
77 }
78
79 /// The minimum profile information needed to prevent a save from clobbering
80 /// another file. Identity discovery intentionally accepts otherwise legacy
81 /// profile keys: an old route-policy field must not block authoring an
82 /// unrelated, current profile, but malformed TOML or an invalid id still fails
83 /// closed because the collision check cannot be trusted.
84 #[derive(Debug, Clone, PartialEq, Eq)]
85 pub struct AgentProfileIdentity {
86 pub id: String,
87 pub source: PathBuf,
88 }
89
90 #[derive(Debug, Deserialize)]
91 #[serde(deny_unknown_fields)]
92 struct AgentProfileToml {
93 #[serde(default)]
94 id: Option<String>,
95 #[serde(default)]
96 name: Option<String>,
97 #[serde(default)]
98 display_name: Option<String>,
99 #[serde(default)]
100 description: Option<String>,
101 #[serde(default)]
102 role_hint: Option<String>,
103 #[serde(default)]
104 base_role: Option<String>,
105 #[serde(default)]
106 persona: Option<String>,
107 #[serde(default)]
108 loadout: Option<String>,
109 #[serde(default, alias = "model_hint", alias = "model_id")]
110 model: Option<String>,
111 /// Explicit provider id for `model` (#4093), e.g. `"deepseek"` or
112 /// `"openrouter"`. Validated against the known `ApiProvider` vocabulary at
113 /// load time — never inferred by sniffing `model` for a provider-shaped
114 /// substring (EPIC #2608). `deny_unknown_fields` no longer needs to guard
115 /// this name: it is now a first-class, validated field instead of a
116 /// smuggled one.
117 #[serde(default)]
118 provider: Option<String>,
119 /// Optional saved thinking tier for this profile (#4137). TOML may use
120 /// the canonical `reasoning_effort` spelling or the UI-facing `thinking`
121 /// / `reasoning` aliases; loading normalizes to a canonical setting label.
122 #[serde(default, alias = "thinking", alias = "reasoning")]
123 reasoning_effort: Option<String>,
124 #[serde(default)]
125 instructions: Option<AgentProfileInstructions>,
126 #[serde(default)]
127 tools: Option<AgentProfileTools>,
128 #[serde(default)]
129 permissions: Option<AgentProfilePermissionsToml>,
130 }
131
132 #[derive(Debug, Deserialize)]
133 struct AgentProfileIdentityToml {
134 #[serde(default)]
135 id: Option<String>,
136 #[serde(default)]
137 name: Option<String>,
138 }
139
140 #[derive(Debug, Deserialize)]
141 #[serde(deny_unknown_fields)]
142 struct AgentProfileInstructions {
143 #[serde(default)]
144 text: Option<String>,
145 }
146
147 #[derive(Debug, Deserialize)]
148 #[serde(deny_unknown_fields)]
149 struct AgentProfileTools {
150 #[serde(default)]
151 posture: Option<String>,
152 }
153
154 #[derive(Debug, Deserialize)]
155 #[serde(deny_unknown_fields)]
156 struct AgentProfilePermissionsToml {
157 #[serde(default)]
158 allow_shell: Option<bool>,
159 #[serde(default)]
160 trust: Option<bool>,
161 #[serde(default)]
162 approval_required: Option<bool>,
163 }
164
165 pub fn load_workspace_agent_profiles(workspace: impl AsRef<Path>) -> Result<Vec<AgentProfile>> {
166 load_agent_profiles_from_dir(workspace.as_ref().join(WORKSPACE_AGENT_PROFILE_DIR))
167 }
168
169 /// Load every valid workspace profile while reporting invalid neighbors
170 /// individually. The runtime roster uses this path so one stale profile does
171 /// not hide a newly-authored valid profile (or the rest of the party).
172 pub fn load_workspace_agent_profiles_tolerant(
173 workspace: impl AsRef<Path>,
174 ) -> Result<(Vec<AgentProfile>, Vec<String>)> {
175 let dir = workspace.as_ref().join(WORKSPACE_AGENT_PROFILE_DIR);
176 load_agent_profiles_from_dir_tolerant(dir, ProfileOrigin::Workspace)
177 }
178
179 pub fn load_personal_agent_profiles_tolerant() -> Result<(Vec<AgentProfile>, Vec<String>)> {
180 load_agent_profiles_from_dir_tolerant(personal_agent_profile_dir()?, ProfileOrigin::Personal)
181 }
182
183 pub fn load_agent_profiles_from_dir_tolerant(
184 dir: impl AsRef<Path>,
185 origin: ProfileOrigin,
186 ) -> Result<(Vec<AgentProfile>, Vec<String>)> {
187 let dir = dir.as_ref();
188 let paths = agent_profile_paths(dir)?;
189 let mut profiles = Vec::new();
190 let mut issues = Vec::new();
191 let mut seen = BTreeSet::new();
192 let mut duplicates = BTreeSet::new();
193 let mut identified = Vec::new();
194
195 // Resolve identities first so duplicate ids fail closed as a group rather
196 // than allowing whichever filename happens to sort first to win.
197 for path in paths {
198 match load_agent_profile_identity_file(&path) {
199 Ok(identity) => {
200 let canonical_id = identity.id.to_ascii_lowercase();
201 if !seen.insert(canonical_id.clone()) {
202 duplicates.insert(canonical_id.clone());
203 }
204 identified.push((path, identity, canonical_id));
205 }
206 Err(err) => issues.push(format!("{err:#}")),
207 }
208 }
209
210 for (path, _identity, canonical_id) in identified {
211 if duplicates.contains(&canonical_id) {
212 issues.push(format!(
213 "duplicate agent profile id {} includes {}",
214 canonical_id,
215 path.display()
216 ));
217 continue;
218 }
219 match load_agent_profile_file(&path) {
220 Ok(mut profile) => {
221 profile.origin = origin;
222 profiles.push(profile);
223 }
224 Err(err) => issues.push(format!("{err:#}")),
225 }
226 }
227
228 Ok((profiles, issues))
229 }
230
231 /// Read only the identity-bearing fields from workspace profiles for the
232 /// authoring collision gate. Unknown legacy fields are harmless here because
233 /// no profile behavior is loaded or executed from this representation.
234 pub fn load_workspace_agent_profile_identities(
235 workspace: impl AsRef<Path>,
236 ) -> Result<Vec<AgentProfileIdentity>> {
237 let dir = workspace.as_ref().join(WORKSPACE_AGENT_PROFILE_DIR);
238 load_agent_profile_identities_from_dir(dir)
239 }
240
241 pub fn load_agent_profile_identities_from_dir(
242 dir: impl AsRef<Path>,
243 ) -> Result<Vec<AgentProfileIdentity>> {
244 let dir = dir.as_ref();
245 agent_profile_paths(dir)?
246 .into_iter()
247 .map(|path| load_agent_profile_identity_file(&path))
248 .collect()
249 }
250
251 pub fn load_agent_profiles_from_dir(dir: impl AsRef<Path>) -> Result<Vec<AgentProfile>> {
252 let dir = dir.as_ref();
253 let mut profiles = Vec::new();
254 let mut seen = BTreeSet::new();
255 for path in agent_profile_paths(dir)? {
256 let profile = load_agent_profile_file(&path)?;
257 if !seen.insert(profile.id.to_ascii_lowercase()) {
258 bail!("duplicate agent profile id {}", profile.id);
259 }
260 profiles.push(profile);
261 }
262 Ok(profiles)
263 }
264
265 fn agent_profile_paths(dir: &Path) -> Result<Vec<PathBuf>> {
266 if !dir.exists() {
267 return Ok(Vec::new());
268 }
269 if !dir.is_dir() {
270 bail!("agent profile path {} is not a directory", dir.display());
271 }
272
273 let mut paths = std::fs::read_dir(dir)
274 .with_context(|| format!("reading agent profile dir {}", dir.display()))?
275 .collect::<std::io::Result<Vec<_>>>()
276 .with_context(|| format!("reading agent profile entries in {}", dir.display()))?
277 .into_iter()
278 .map(|entry| entry.path())
279 .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("toml"))
280 .collect::<Vec<_>>();
281 paths.sort();
282 Ok(paths)
283 }
284
285 fn load_agent_profile_identity_file(path: &Path) -> Result<AgentProfileIdentity> {
286 let raw = std::fs::read_to_string(path)
287 .with_context(|| format!("reading agent profile identity {}", path.display()))?;
288 let parsed: AgentProfileIdentityToml = toml::from_str(&raw)
289 .map_err(|err| anyhow!("parsing agent profile identity {}: {err}", path.display()))?;
290 let fallback_id = path
291 .file_stem()
292 .and_then(|value| value.to_str())
293 .unwrap_or("profile");
294 let id = first_present([parsed.id.as_deref(), parsed.name.as_deref()])
295 .unwrap_or(fallback_id)
296 .to_string();
297 validate_agent_profile_token(path, "id/name", &id)?;
298 Ok(AgentProfileIdentity {
299 id,
300 source: path.to_path_buf(),
301 })
302 }
303
304 fn load_agent_profile_file(path: &Path) -> Result<AgentProfile> {
305 let raw = std::fs::read_to_string(path)
306 .with_context(|| format!("reading agent profile {}", path.display()))?;
307 let parsed: AgentProfileToml = toml::from_str(&raw)
308 .map_err(|err| anyhow!("parsing agent profile {}: {err}", path.display()))?;
309 agent_profile_from_toml(path, parsed)
310 }
311
312 fn agent_profile_from_toml(path: &Path, parsed: AgentProfileToml) -> Result<AgentProfile> {
313 reject_permission_expansion(path, parsed.tools.as_ref(), parsed.permissions.as_ref())?;
314
315 let fallback_id = path
316 .file_stem()
317 .and_then(|value| value.to_str())
318 .unwrap_or("profile");
319 let id = first_present([parsed.id.as_deref(), parsed.name.as_deref()])
320 .unwrap_or(fallback_id)
321 .to_string();
322 validate_agent_profile_token(path, "id/name", &id)?;
323
324 let role_name = canonical_public_role_name(
325 first_present([
326 parsed.base_role.as_deref(),
327 parsed.role_hint.as_deref(),
328 parsed.name.as_deref(),
329 ])
330 .unwrap_or(&id),
331 );
332 validate_agent_profile_token(path, "base_role/role_hint", &role_name)?;
333
334 let loadout = first_present([parsed.loadout.as_deref()])
335 .map(FleetLoadout::from_name)
336 .unwrap_or_default();
337 let model = non_empty_trimmed(parsed.model.as_deref()).map(str::to_string);
338 validate_agent_profile_model_hint(path, model.as_deref())?;
339
340 let provider = non_empty_trimmed(parsed.provider.as_deref())
341 .map(str::to_string)
342 .map(|provider| validate_agent_profile_provider(path, &provider).map(|()| provider))
343 .transpose()?;
344 let reasoning_effort =
345 normalize_agent_profile_reasoning_effort(path, parsed.reasoning_effort.as_deref())?;
346
347 let instructions = parsed
348 .instructions
349 .as_ref()
350 .and_then(|instructions| non_empty_trimmed(instructions.text.as_deref()))
351 .or_else(|| non_empty_trimmed(parsed.persona.as_deref()))
352 .map(str::to_string);
353
354 let description = non_empty_trimmed(parsed.description.as_deref()).map(str::to_string);
355 let profile = FleetProfile {
356 slot: FleetSlot::from_name(&role_name),
357 role: FleetRole {
358 name: role_name,
359 description: description.clone(),
360 instructions,
361 },
362 loadout,
363 model,
364 provider,
365 reasoning_effort,
366 permissions: FleetProfilePermissions::default(),
367 delegation: FleetDelegationHints::default(),
368 };
369
370 Ok(AgentProfile {
371 id,
372 display_name: non_empty_trimmed(parsed.display_name.as_deref()).map(str::to_string),
373 description,
374 profile,
375 source: path.to_path_buf(),
376 origin: ProfileOrigin::Workspace,
377 })
378 }
379
380 /// Canonicalize renamed public Fleet roles at profile load boundaries.
381 ///
382 /// Profile ids remain untouched so an older file can still be addressed by
383 /// its saved id. Only the semantic role is migrated; every new receipt and UI
384 /// label derived from it therefore says `consultant`.
385 pub(crate) fn canonical_public_role_name(role: &str) -> String {
386 match role.trim().to_ascii_lowercase().as_str() {
387 "oracle" | "advisor" => "consultant".to_string(),
388 _ => role.to_string(),
389 }
390 }
391
392 fn reject_permission_expansion(
393 path: &Path,
394 tools: Option<&AgentProfileTools>,
395 permissions: Option<&AgentProfilePermissionsToml>,
396 ) -> Result<()> {
397 if let Some(posture) = tools
398 .and_then(|tools| tools.posture.as_deref())
399 .and_then(trimmed_non_empty)
400 {
401 match posture {
402 "read-only" | "readonly" | "read_only" => {}
403 other => bail!(
404 "agent profile {} tools.posture={other:?} would widen permissions; use FleetProfile policy for grants",
405 path.display()
406 ),
407 }
408 }
409
410 if let Some(permissions) = permissions {
411 if permissions.allow_shell.unwrap_or(false) {
412 bail!(
413 "agent profile {} may not request allow_shell=true",
414 path.display()
415 );
416 }
417 if permissions.trust.unwrap_or(false) {
418 bail!(
419 "agent profile {} may not request trust=true",
420 path.display()
421 );
422 }
423 if permissions.approval_required == Some(false) {
424 bail!(
425 "agent profile {} may not disable approval_required",
426 path.display()
427 );
428 }
429 }
430 Ok(())
431 }
432
433 fn validate_agent_profile_token(path: &Path, field: &str, value: &str) -> Result<()> {
434 let trimmed = value.trim();
435 if trimmed.is_empty() {
436 bail!("agent profile {} {field} cannot be empty", path.display());
437 }
438 if trimmed != value || !trimmed.chars().all(is_agent_profile_token_char) {
439 bail!(
440 "agent profile {} {field} must be a simple token",
441 path.display()
442 );
443 }
444 Ok(())
445 }
446
447 fn validate_agent_profile_model_hint(path: &Path, value: Option<&str>) -> Result<()> {
448 let Some(value) = value else {
449 return Ok(());
450 };
451 if !is_model_hint(value) {
452 bail!(
453 "agent profile {} model must be a visible model id without whitespace or secrets",
454 path.display()
455 );
456 }
457 Ok(())
458 }
459
460 /// Validate an explicit `provider` field as a safe provider id (#4093).
461 ///
462 /// Built-in providers are accepted by the runtime vocabulary, and user-named
463 /// OpenAI-compatible custom providers are accepted as simple tokens so the
464 /// launch path can resolve `[providers.<id>]` from the session config (#3965).
465 /// This field remains the ONLY place a profile's provider is established:
466 /// callers never infer it from `model` (EPIC #2608).
467 fn validate_agent_profile_provider(path: &Path, value: &str) -> Result<()> {
468 let trimmed = value.trim();
469 if trimmed.is_empty() {
470 bail!("agent profile {} provider cannot be empty", path.display());
471 }
472 if trimmed != value || !trimmed.chars().all(is_agent_profile_token_char) {
473 bail!(
474 "agent profile {} provider must be a simple provider id",
475 path.display()
476 );
477 }
478 Ok(())
479 }
480
481 fn normalize_agent_profile_reasoning_effort(
482 path: &Path,
483 value: Option<&str>,
484 ) -> Result<Option<String>> {
485 let Some(value) = non_empty_trimmed(value) else {
486 return Ok(None);
487 };
488 if matches!(
489 value.to_ascii_lowercase().as_str(),
490 "inherit" | "parent" | "same" | "current" | "default" | "unset"
491 ) {
492 return Ok(None);
493 }
494 ReasoningEffort::parse_strict(value)
495 .map(|effort| Some(effort.as_setting().to_string()))
496 .map_err(|_| {
497 anyhow!(
498 "agent profile {} reasoning_effort {value:?} must be one of: inherit, auto, off, low, medium, high, max",
499 path.display()
500 )
501 })
502 }
503
504 fn is_agent_profile_token_char(ch: char) -> bool {
505 ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')
506 }
507
508 fn is_model_hint(value: &str) -> bool {
509 let trimmed = value.trim();
510 !trimmed.is_empty()
511 && trimmed == value
512 && trimmed
513 .chars()
514 .all(|ch| ch.is_ascii_graphic() && !matches!(ch, '=' | '\'' | '"'))
515 }
516
517 fn first_present<'a>(values: impl IntoIterator<Item = Option<&'a str>>) -> Option<&'a str> {
518 values.into_iter().flatten().find_map(trimmed_non_empty)
519 }
520
521 fn non_empty_trimmed(value: Option<&str>) -> Option<&str> {
522 value.and_then(trimmed_non_empty)
523 }
524
525 fn trimmed_non_empty(value: &str) -> Option<&str> {
526 let trimmed = value.trim();
527 (!trimmed.is_empty()).then_some(trimmed)
528 }
529
530 /// Outcome of parsing untrusted model output into a fleet profile draft.
531 /// Mirrors `UntrustedDraftParse` from the constitution pipeline: the reply is
532 /// data, never trusted, and any failure is a reason string for the status
533 /// line — drafting failures degrade to the manual authoring flow.
534 #[derive(Debug)]
535 pub enum UntrustedProfileParse {
536 Drafted(Box<FleetProfileDraft>),
537 Empty,
538 Invalid(String),
539 }
540
541 /// A model-drafted fleet agent profile that has passed the untrusted gate:
542 /// balanced-JSON extraction, serde parse with `deny_unknown_fields` (so
543 /// provider/base_url/api_key/permissions/tools cannot ride along), the same
544 /// escalation rejections the profile loader applies, token and model-hint
545 /// validation, prose bounds, and control-character stripping. The persisted
546 /// TOML is rendered deterministically from this struct — model bytes are
547 /// never written to disk verbatim.
548 ///
549 /// `provider` (#4093) is set ONLY by the structured Fleet setup picker (a
550 /// user's explicit, credential-checked selection) — never by
551 /// [`Self::from_untrusted_json`], whose wire schema
552 /// ([`FleetProfileDraftJson`]) has no `provider` field and rejects one via
553 /// `deny_unknown_fields`. A model's untrusted reply can never smuggle a
554 /// provider; only an interactive pick can set this field.
555 #[derive(Debug, Clone, PartialEq, Eq)]
556 pub struct FleetProfileDraft {
557 pub id: String,
558 pub display_name: Option<String>,
559 pub description: Option<String>,
560 pub role_hint: String,
561 pub model_class_hint: Option<String>,
562 pub model: Option<String>,
563 /// Explicit provider id for `model` (e.g. `"deepseek"`), set only by the
564 /// structured picker. `None` means "no route pin" (inherit) — matching
565 /// `model: None` — or a legacy/untrusted draft that predates this field.
566 pub provider: Option<String>,
567 /// Explicit saved thinking tier, set only by structured setup controls.
568 /// `None` means inherit the operator/session reasoning tier.
569 pub reasoning_effort: Option<String>,
570 pub instructions: Option<String>,
571 }
572
573 /// Bounds for model-drafted profile prose. Same philosophy as the
574 /// constitution bounds: roomy enough for a real profile, hard enough that a
575 /// misbehaving provider cannot bloat the store.
576 pub const MAX_PROFILE_DESCRIPTION_LEN: usize = 1000;
577 pub const MAX_PROFILE_INSTRUCTIONS_LEN: usize = 4000;
578 const MAX_PROFILE_DISPLAY_NAME_LEN: usize = 80;
579 const MAX_PROFILE_TOKEN_LEN: usize = 64;
580
581 /// The JSON shape the drafting prompt asks for. `deny_unknown_fields` is the
582 /// first escalation gate: a draft that tries to smuggle `permissions`,
583 /// `tools`, `provider`, `base_url`, or `api_key` fails the parse outright
584 /// instead of being silently stripped.
585 #[derive(Debug, Deserialize)]
586 #[serde(deny_unknown_fields)]
587 struct FleetProfileDraftJson {
588 #[serde(default)]
589 id: Option<String>,
590 #[serde(default)]
591 display_name: Option<String>,
592 #[serde(default)]
593 description: Option<String>,
594 #[serde(default)]
595 role_hint: Option<String>,
596 #[serde(default)]
597 model_class_hint: Option<String>,
598 #[serde(default)]
599 model: Option<String>,
600 #[serde(default)]
601 instructions: Option<String>,
602 }
603
604 impl FleetProfileDraft {
605 /// Parse untrusted model output. Any structural problem is `Invalid`
606 /// with a short reason; a parse that carries no usable content is
607 /// `Empty`.
608 #[must_use]
609 pub fn from_untrusted_json(raw: &str) -> UntrustedProfileParse {
610 let Some(json) = extract_first_json_object(raw) else {
611 return UntrustedProfileParse::Invalid("no JSON object found".to_string());
612 };
613 let parsed: FleetProfileDraftJson = match serde_json::from_str(json) {
614 Ok(parsed) => parsed,
615 Err(err) => return UntrustedProfileParse::Invalid(err.to_string()),
616 };
617
618 let role_hint = match parsed
619 .role_hint
620 .as_deref()
621 .and_then(trimmed_non_empty)
622 .map(sanitize_profile_token)
623 {
624 Some(token) if !token.is_empty() => canonical_public_role_name(&token),
625 _ => return UntrustedProfileParse::Invalid("role_hint missing".to_string()),
626 };
627 let id = parsed
628 .id
629 .as_deref()
630 .and_then(trimmed_non_empty)
631 .map(sanitize_profile_token)
632 .filter(|token| !token.is_empty())
633 .unwrap_or_else(|| role_hint.clone());
634 let model_class_hint = parsed
635 .model_class_hint
636 .as_deref()
637 .and_then(trimmed_non_empty)
638 .map(sanitize_profile_token)
639 .filter(|token| !token.is_empty());
640 let model = parsed
641 .model
642 .as_deref()
643 .and_then(trimmed_non_empty)
644 .map(str::to_string);
645 if let Some(ref model) = model
646 && !is_model_hint(model)
647 {
648 return UntrustedProfileParse::Invalid(
649 "model must be a visible model id without whitespace or secrets".to_string(),
650 );
651 }
652 let display_name = parsed
653 .display_name
654 .as_deref()
655 .map(|text| sanitize_profile_prose(text, MAX_PROFILE_DISPLAY_NAME_LEN))
656 .and_then(|text| trimmed_non_empty(&text).map(str::to_string));
657 let description = parsed
658 .description
659 .as_deref()
660 .map(|text| sanitize_profile_prose(text, MAX_PROFILE_DESCRIPTION_LEN))
661 .and_then(|text| trimmed_non_empty(&text).map(str::to_string));
662 let instructions = parsed
663 .instructions
664 .as_deref()
665 .map(|text| sanitize_profile_prose(text, MAX_PROFILE_INSTRUCTIONS_LEN))
666 .and_then(|text| trimmed_non_empty(&text).map(str::to_string));
667
668 let draft = FleetProfileDraft {
669 id,
670 display_name,
671 description,
672 role_hint,
673 model_class_hint,
674 model,
675 // Never set from untrusted model output — `FleetProfileDraftJson`
676 // has no `provider` field, so there is nothing to read here.
677 provider: None,
678 reasoning_effort: None,
679 instructions,
680 };
681 if draft.description.is_none() && draft.instructions.is_none() {
682 return UntrustedProfileParse::Empty;
683 }
684 UntrustedProfileParse::Drafted(Box::new(draft))
685 }
686
687 /// Deterministic TOML rendering — the exact bytes the ratify keypress
688 /// would persist. Loading this back through the profile loader must
689 /// succeed with the default (floor) permissions.
690 #[must_use]
691 pub fn render_toml(&self) -> String {
692 let mut root = toml::value::Table::new();
693 root.insert("id".to_string(), toml::Value::String(self.id.clone()));
694 if let Some(ref display_name) = self.display_name {
695 root.insert(
696 "display_name".to_string(),
697 toml::Value::String(display_name.clone()),
698 );
699 }
700 if let Some(ref description) = self.description {
701 root.insert(
702 "description".to_string(),
703 toml::Value::String(description.clone()),
704 );
705 }
706 root.insert(
707 "role_hint".to_string(),
708 toml::Value::String(self.role_hint.clone()),
709 );
710 if let Some(ref hint) = self.model_class_hint {
711 root.insert("loadout".to_string(), toml::Value::String(hint.clone()));
712 }
713 if let Some(ref model) = self.model {
714 root.insert("model".to_string(), toml::Value::String(model.clone()));
715 // A provider pin is only meaningful alongside a concrete model
716 // (#4093): an `inherit` draft (`model: None`) never carries one,
717 // so the rendered TOML can't imply a route it doesn't have.
718 if let Some(ref provider) = self.provider {
719 root.insert(
720 "provider".to_string(),
721 toml::Value::String(provider.clone()),
722 );
723 }
724 }
725 if let Some(ref reasoning_effort) = self.reasoning_effort {
726 root.insert(
727 "reasoning_effort".to_string(),
728 toml::Value::String(reasoning_effort.clone()),
729 );
730 }
731 if let Some(ref instructions) = self.instructions {
732 let mut table = toml::value::Table::new();
733 table.insert(
734 "text".to_string(),
735 toml::Value::String(instructions.clone()),
736 );
737 root.insert("instructions".to_string(), toml::Value::Table(table));
738 }
739 toml::to_string_pretty(&toml::Value::Table(root))
740 .unwrap_or_else(|_| String::from("# failed to render profile"))
741 }
742
743 /// File name (stem + `.toml`) for this draft, always derived from the
744 /// sanitized id — never a model-chosen free-form path.
745 #[must_use]
746 pub fn file_name(&self) -> String {
747 format!("{}.toml", self.id)
748 }
749 }
750
751 /// Keep only the loader's token alphabet, lowercased, bounded.
752 fn sanitize_profile_token(value: &str) -> String {
753 value
754 .trim()
755 .chars()
756 .map(|ch| ch.to_ascii_lowercase())
757 .filter(|ch| is_agent_profile_token_char(*ch))
758 .take(MAX_PROFILE_TOKEN_LEN)
759 .collect()
760 }
761
762 /// Strip control characters (newline/tab survive) and bound length by chars.
763 fn sanitize_profile_prose(text: &str, max_len: usize) -> String {
764 text.chars()
765 .filter(|ch| !ch.is_control() || matches!(ch, '\n' | '\t'))
766 .take(max_len)
767 .collect()
768 }
769
770 /// Extract the first balanced `{...}` object from untrusted output, so fenced
771 /// or prose-wrapped JSON still parses. Mirrors the constitution pipeline's
772 /// extractor (which is private to codewhale-config).
773 fn extract_first_json_object(raw: &str) -> Option<&str> {
774 let start = raw.find('{')?;
775 let mut depth = 0usize;
776 let mut in_string = false;
777 let mut escaped = false;
778 for (offset, ch) in raw[start..].char_indices() {
779 if escaped {
780 escaped = false;
781 continue;
782 }
783 match ch {
784 '\\' if in_string => escaped = true,
785 '"' => in_string = !in_string,
786 '{' if !in_string => depth += 1,
787 '}' if !in_string => {
788 depth -= 1;
789 if depth == 0 {
790 return Some(&raw[start..=start + offset]);
791 }
792 }
793 _ => {}
794 }
795 }
796 None
797 }
798
799 #[cfg(test)]
800 mod tests {
801 use super::*;
802 use tempfile::TempDir;
803
804 #[test]
805 fn draft_gate_rejects_unknown_and_escalation_fields() {
806 for raw in [
807 r#"{"id":"x","role_hint":"reviewer","description":"d","permissions":{"allow_shell":true}}"#,
808 r#"{"id":"x","role_hint":"reviewer","description":"d","tools":{"posture":"full"}}"#,
809 r#"{"id":"x","role_hint":"reviewer","description":"d","provider":"openai"}"#,
810 r#"{"id":"x","role_hint":"reviewer","description":"d","api_key":"sk-nope"}"#,
811 ] {
812 assert!(
813 matches!(
814 FleetProfileDraft::from_untrusted_json(raw),
815 UntrustedProfileParse::Invalid(_)
816 ),
817 "{raw} must be rejected, not stripped"
818 );
819 }
820 }
821
822 #[test]
823 fn draft_gate_bounds_and_sanitizes() {
824 let huge = "x".repeat(MAX_PROFILE_INSTRUCTIONS_LEN + 500);
825 // \u0007 (BEL) inside the description must be stripped by the
826 // prose sanitizer; the oversized instructions must be bounded.
827 let raw = format!(
828 "{{\"id\":\" Weird ID!! \",\"role_hint\":\"Code Reviewer\",\"description\":\"has\\u0007control\",\"instructions\":\"{huge}\"}}"
829 );
830 let UntrustedProfileParse::Drafted(draft) = FleetProfileDraft::from_untrusted_json(&raw)
831 else {
832 panic!("draft should parse");
833 };
834 assert_eq!(draft.id, "weirdid");
835 assert_eq!(draft.role_hint, "codereviewer");
836 assert_eq!(draft.description.as_deref(), Some("hascontrol"));
837 assert_eq!(
838 draft.instructions.as_deref().unwrap().chars().count(),
839 MAX_PROFILE_INSTRUCTIONS_LEN
840 );
841 }
842
843 #[test]
844 fn draft_gate_rejects_secret_shaped_model_and_missing_role() {
845 assert!(matches!(
846 FleetProfileDraft::from_untrusted_json(
847 r#"{"id":"x","role_hint":"reviewer","description":"d","model":"has secret ="}"#
848 ),
849 UntrustedProfileParse::Invalid(_)
850 ));
851 assert!(matches!(
852 FleetProfileDraft::from_untrusted_json(r#"{"id":"x","description":"d"}"#),
853 UntrustedProfileParse::Invalid(_)
854 ));
855 assert!(matches!(
856 FleetProfileDraft::from_untrusted_json(r#"{"id":"x","role_hint":"reviewer"}"#),
857 UntrustedProfileParse::Empty
858 ));
859 }
860
861 #[test]
862 fn draft_gate_accepts_fenced_output() {
863 let raw = "Here you go:\n```json\n{\"id\":\"reviewer\",\"role_hint\":\"reviewer\",\"description\":\"Reviews diffs.\"}\n```";
864 assert!(matches!(
865 FleetProfileDraft::from_untrusted_json(raw),
866 UntrustedProfileParse::Drafted(_)
867 ));
868 }
869
870 #[test]
871 fn rendered_draft_round_trips_through_the_loader_with_floor_permissions() {
872 let UntrustedProfileParse::Drafted(draft) = FleetProfileDraft::from_untrusted_json(
873 r#"{"id":"reviewer","display_name":"Reviewer","description":"Reviews diffs for correctness.","role_hint":"reviewer","model_class_hint":"cheap","model":"glm-5.2","instructions":"Read the diff.\nReport findings, then stop."}"#,
874 ) else {
875 panic!("draft should parse");
876 };
877
878 let dir = TempDir::new().unwrap();
879 let path = write_profile(dir.path(), &draft.file_name(), &draft.render_toml());
880 let profiles = load_agent_profiles_from_dir(dir.path()).expect("rendered TOML loads");
881 assert_eq!(profiles.len(), 1);
882 let loaded = &profiles[0];
883 assert_eq!(loaded.id, "reviewer");
884 assert_eq!(loaded.display_name.as_deref(), Some("Reviewer"));
885 assert_eq!(loaded.profile.model.as_deref(), Some("glm-5.2"));
886 assert_eq!(
887 loaded.profile.role.instructions.as_deref(),
888 Some("Read the diff.\nReport findings, then stop.")
889 );
890 // The loader always installs the permission floor, no matter what.
891 assert_eq!(
892 loaded.profile.permissions,
893 FleetProfilePermissions::default()
894 );
895 assert_eq!(path, loaded.source);
896 }
897
898 #[test]
899 fn draft_with_explicit_provider_round_trips_through_the_loader() {
900 // A structured (picker-driven) draft that pins a model on a provider
901 // other than whatever the parent session happens to use (#4093): the
902 // rendered TOML must carry both fields explicitly, and the loader
903 // must read the provider back out verbatim — never re-derive it by
904 // sniffing `model` for a provider-shaped substring.
905 let draft = FleetProfileDraft {
906 id: "scout-deepseek".to_string(),
907 display_name: Some("Scout".to_string()),
908 description: Some("Cross-provider scout profile.".to_string()),
909 role_hint: "scout".to_string(),
910 model_class_hint: None,
911 model: Some("deepseek-v4-flash".to_string()),
912 provider: Some("deepseek".to_string()),
913 reasoning_effort: None,
914 instructions: None,
915 };
916
917 let rendered = draft.render_toml();
918 assert!(
919 rendered.contains("provider = \"deepseek\""),
920 "rendered TOML must persist the explicit provider: {rendered}"
921 );
922 assert!(rendered.contains("model = \"deepseek-v4-flash\""));
923
924 let dir = TempDir::new().unwrap();
925 write_profile(dir.path(), &draft.file_name(), &rendered);
926 let profiles = load_agent_profiles_from_dir(dir.path()).expect("rendered TOML loads");
927 assert_eq!(profiles.len(), 1);
928 let loaded = &profiles[0];
929 assert_eq!(loaded.profile.model.as_deref(), Some("deepseek-v4-flash"));
930 assert_eq!(loaded.profile.provider.as_deref(), Some("deepseek"));
931 }
932
933 #[test]
934 fn draft_with_reasoning_effort_round_trips_through_the_loader() {
935 let draft = FleetProfileDraft {
936 id: "scout-deep".to_string(),
937 display_name: Some("Scout".to_string()),
938 description: Some("Deep scout profile.".to_string()),
939 role_hint: "scout".to_string(),
940 model_class_hint: None,
941 model: Some("deepseek-v4-pro".to_string()),
942 provider: Some("deepseek".to_string()),
943 reasoning_effort: Some("max".to_string()),
944 instructions: None,
945 };
946
947 let rendered = draft.render_toml();
948 assert!(
949 rendered.contains("reasoning_effort = \"max\""),
950 "rendered TOML must persist explicit reasoning: {rendered}"
951 );
952
953 let dir = TempDir::new().unwrap();
954 write_profile(dir.path(), &draft.file_name(), &rendered);
955 let profiles = load_agent_profiles_from_dir(dir.path()).expect("rendered TOML loads");
956 assert_eq!(profiles.len(), 1);
957 let loaded = &profiles[0];
958 assert_eq!(loaded.profile.provider.as_deref(), Some("deepseek"));
959 assert_eq!(loaded.profile.model.as_deref(), Some("deepseek-v4-pro"));
960 assert_eq!(loaded.profile.reasoning_effort.as_deref(), Some("max"));
961 }
962
963 #[test]
964 fn profile_loader_normalizes_reasoning_aliases() {
965 let dir = TempDir::new().unwrap();
966 write_profile(
967 dir.path(),
968 "scout.toml",
969 r#"
970 id = "scout"
971 role_hint = "scout"
972 thinking = "xhigh"
973
974 [instructions]
975 text = "Scout deeply."
976 "#,
977 );
978
979 let profiles = load_agent_profiles_from_dir(dir.path()).expect("profile TOML loads");
980 assert_eq!(profiles.len(), 1);
981 assert_eq!(profiles[0].profile.reasoning_effort.as_deref(), Some("max"));
982 }
983
984 #[test]
985 fn profile_loader_migrates_advisory_role_aliases_to_consultant() {
986 let dir = tempfile::tempdir().unwrap();
987 for alias in ["oracle", "advisor"] {
988 let path = dir.path().join(format!("{alias}.toml"));
989 std::fs::write(
990 &path,
991 format!("id = \"{alias}\"\nrole_hint = \"{alias}\"\n"),
992 )
993 .unwrap();
994 let loaded = load_agent_profile_file(&path).expect("load compatibility profile");
995 assert_eq!(loaded.id, alias, "saved identity remains addressable");
996 assert_eq!(loaded.profile.role.name, "consultant");
997 assert_eq!(loaded.profile.slot.as_str(), "consultant");
998 }
999 }
1000
1001 #[test]
1002 fn model_draft_migrates_advisory_role_alias_to_consultant() {
1003 let UntrustedProfileParse::Drafted(draft) = FleetProfileDraft::from_untrusted_json(
1004 r#"{"id":"second-opinion","role_hint":"oracle","description":"Counsel."}"#,
1005 ) else {
1006 panic!("expected a drafted profile");
1007 };
1008 assert_eq!(draft.role_hint, "consultant");
1009 assert!(draft.render_toml().contains("role_hint = \"consultant\""));
1010 }
1011
1012 #[test]
1013 fn profile_loader_rejects_unknown_reasoning_effort() {
1014 let dir = TempDir::new().unwrap();
1015 write_profile(
1016 dir.path(),
1017 "scout.toml",
1018 r#"
1019 id = "scout"
1020 role_hint = "scout"
1021 reasoning = "expensive"
1022 "#,
1023 );
1024
1025 let err = load_agent_profiles_from_dir(dir.path()).expect_err("invalid effort must fail");
1026 assert!(
1027 err.to_string().contains("reasoning_effort"),
1028 "unexpected error: {err}"
1029 );
1030 }
1031
1032 #[test]
1033 fn inherit_draft_never_renders_a_provider_without_a_model() {
1034 // `provider` is only meaningful alongside a concrete model pin; an
1035 // `inherit` draft (no `model`) must never render one even if a stale
1036 // caller sets the field.
1037 let draft = FleetProfileDraft {
1038 id: "inherit".to_string(),
1039 display_name: None,
1040 description: None,
1041 role_hint: "general".to_string(),
1042 model_class_hint: None,
1043 model: None,
1044 provider: Some("deepseek".to_string()),
1045 reasoning_effort: None,
1046 instructions: None,
1047 };
1048 let rendered = draft.render_toml();
1049 assert!(!rendered.contains("provider"), "{rendered}");
1050 }
1051
1052 fn write_profile(dir: &Path, filename: &str, contents: &str) -> PathBuf {
1053 let path = dir.join(filename);
1054 std::fs::write(&path, contents).unwrap();
1055 path
1056 }
1057
1058 #[test]
1059 fn fleet_profile_round_trips_through_serde_with_safe_defaults() {
1060 let profile = FleetProfile::default();
1061
1062 let serialized = toml::to_string(&profile).expect("profile serializes");
1063 let round_tripped: FleetProfile =
1064 toml::from_str(&serialized).expect("profile deserializes");
1065
1066 assert_eq!(round_tripped, profile);
1067 assert_eq!(round_tripped.role.name, "general");
1068 assert_eq!(round_tripped.loadout, FleetLoadout::Inherit);
1069 assert!(!round_tripped.permissions.allow_shell);
1070 assert!(!round_tripped.permissions.trust);
1071 assert!(round_tripped.permissions.approval_required);
1072 assert_eq!(round_tripped.delegation.max_spawn_depth, None);
1073 assert_eq!(round_tripped.delegation.max_concurrency, None);
1074 }
1075
1076 #[test]
1077 fn fleet_profile_explicit_toml_parses_role_loadout_permissions() {
1078 let profile: FleetProfile = toml::from_str(
1079 r#"
1080 slot = "reviewer"
1081 loadout = "deep-reasoning"
1082
1083 [role]
1084 name = "verifier"
1085 instructions = "Review the patch and produce verification evidence."
1086
1087 [permissions]
1088 allow_shell = true
1089 trust = true
1090 approval_required = false
1091
1092 [delegation]
1093 max_spawn_depth = 1
1094 concurrency = 2
1095 "#,
1096 )
1097 .expect("explicit fleet profile parses");
1098
1099 assert_eq!(profile.slot, FleetSlot::Reviewer);
1100 assert_eq!(profile.role.name, "verifier");
1101 assert_eq!(
1102 profile.role.instructions.as_deref(),
1103 Some("Review the patch and produce verification evidence.")
1104 );
1105 assert_eq!(
1106 profile.loadout,
1107 FleetLoadout::Custom("deep-reasoning".to_string())
1108 );
1109 assert!(profile.permissions.allow_shell);
1110 assert!(profile.permissions.trust);
1111 assert!(!profile.permissions.approval_required);
1112 assert_eq!(profile.delegation.max_spawn_depth, Some(1));
1113 assert_eq!(profile.delegation.max_concurrency, Some(2));
1114 }
1115
1116 #[test]
1117 fn fleet_profile_accepts_compact_role_string() {
1118 let profile: FleetProfile = toml::from_str(
1119 r#"
1120 role = "scout"
1121 loadout = "fast"
1122 model = "deepseek-v4-flash"
1123 "#,
1124 )
1125 .expect("compact fleet profile parses");
1126
1127 assert_eq!(profile.role.name, "scout");
1128 assert_eq!(profile.loadout, FleetLoadout::Fast);
1129 assert_eq!(profile.model.as_deref(), Some("deepseek-v4-flash"));
1130 assert_eq!(profile.permissions, FleetProfilePermissions::default());
1131 }
1132
1133 #[test]
1134 fn agent_profile_loader_returns_empty_for_missing_workspace_dir() {
1135 let tmp = TempDir::new().unwrap();
1136
1137 let profiles = load_workspace_agent_profiles(tmp.path()).unwrap();
1138
1139 assert!(profiles.is_empty());
1140 }
1141
1142 #[test]
1143 fn profile_identity_loader_accepts_legacy_route_policy_fields() {
1144 let tmp = TempDir::new().unwrap();
1145 let agents_dir = tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR);
1146 std::fs::create_dir_all(&agents_dir).unwrap();
1147 let source = write_profile(
1148 &agents_dir,
1149 "reviewer.toml",
1150 r#"
1151 id = "reviewer"
1152 role_hint = "reviewer"
1153 model_class_hint = "heavy"
1154 models = ["glm-5.2", "deepseek-v4-pro"]
1155 "#,
1156 );
1157
1158 let identities = load_workspace_agent_profile_identities(tmp.path())
1159 .expect("legacy fields do not obscure identity");
1160
1161 assert_eq!(
1162 identities,
1163 vec![AgentProfileIdentity {
1164 id: "reviewer".to_string(),
1165 source,
1166 }]
1167 );
1168 }
1169
1170 #[test]
1171 fn profile_identity_loader_fails_closed_for_malformed_toml() {
1172 let tmp = TempDir::new().unwrap();
1173 let agents_dir = tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR);
1174 std::fs::create_dir_all(&agents_dir).unwrap();
1175 write_profile(&agents_dir, "broken.toml", "id = [\n");
1176
1177 let err = load_workspace_agent_profile_identities(tmp.path())
1178 .expect_err("malformed TOML cannot prove collision safety")
1179 .to_string();
1180
1181 assert!(err.contains("broken.toml"), "unexpected error: {err}");
1182 assert!(err.contains("profile identity"), "unexpected error: {err}");
1183 }
1184
1185 #[test]
1186 fn tolerant_loader_keeps_valid_profile_beside_legacy_profile() {
1187 let tmp = TempDir::new().unwrap();
1188 let agents_dir = tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR);
1189 std::fs::create_dir_all(&agents_dir).unwrap();
1190 write_profile(
1191 &agents_dir,
1192 "reviewer.toml",
1193 "id = \"reviewer\"\nmodel_class_hint = \"heavy\"\n",
1194 );
1195 write_profile(
1196 &agents_dir,
1197 "scout.toml",
1198 "id = \"scout\"\nrole_hint = \"scout\"\nprovider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n",
1199 );
1200
1201 let (profiles, issues) = load_workspace_agent_profiles_tolerant(tmp.path())
1202 .expect("directory discovery succeeds");
1203
1204 assert_eq!(profiles.len(), 1);
1205 assert_eq!(profiles[0].id, "scout");
1206 assert_eq!(
1207 profiles[0].profile.model.as_deref(),
1208 Some("deepseek-v4-flash")
1209 );
1210 assert_eq!(issues.len(), 1);
1211 assert!(issues[0].contains("reviewer.toml"), "{issues:?}");
1212 assert!(issues[0].contains("model_class_hint"), "{issues:?}");
1213 }
1214
1215 #[test]
1216 fn tolerant_loader_skips_every_duplicate_id_but_keeps_unique_neighbors() {
1217 let tmp = TempDir::new().unwrap();
1218 let agents_dir = tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR);
1219 std::fs::create_dir_all(&agents_dir).unwrap();
1220 write_profile(&agents_dir, "a.toml", "id = \"reviewer\"\n");
1221 write_profile(&agents_dir, "b.toml", "name = \"reviewer\"\n");
1222 write_profile(&agents_dir, "scout.toml", "id = \"scout\"\n");
1223
1224 let (profiles, issues) = load_workspace_agent_profiles_tolerant(tmp.path())
1225 .expect("directory discovery succeeds");
1226
1227 assert_eq!(
1228 profiles
1229 .iter()
1230 .map(|profile| profile.id.as_str())
1231 .collect::<Vec<_>>(),
1232 vec!["scout"]
1233 );
1234 assert_eq!(issues.len(), 2);
1235 assert!(
1236 issues
1237 .iter()
1238 .all(|issue| issue.contains("duplicate agent profile id reviewer")),
1239 "{issues:?}"
1240 );
1241 }
1242
1243 #[test]
1244 fn profile_identity_loader_fails_closed_for_invalid_id_token() {
1245 let tmp = TempDir::new().unwrap();
1246 let agents_dir = tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR);
1247 std::fs::create_dir_all(&agents_dir).unwrap();
1248 write_profile(&agents_dir, "broken.toml", "id = \"bad id\"\n");
1249
1250 let err = load_workspace_agent_profile_identities(tmp.path())
1251 .expect_err("invalid identity tokens cannot prove collision safety")
1252 .to_string();
1253
1254 assert!(err.contains("broken.toml"), "unexpected error: {err}");
1255 assert!(err.contains("simple token"), "unexpected error: {err}");
1256 }
1257
1258 #[test]
1259 fn scout_save_succeeds_beside_untouched_legacy_reviewer() {
1260 let tmp = TempDir::new().unwrap();
1261 let agents_dir = tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR);
1262 std::fs::create_dir_all(&agents_dir).unwrap();
1263 let legacy = r#"
1264 id = "reviewer"
1265 role_hint = "reviewer"
1266 model_class_hint = "heavy"
1267 models = ["glm-5.2", "deepseek-v4-pro"]
1268 "#;
1269 let reviewer_path = write_profile(&agents_dir, "reviewer.toml", legacy);
1270 let before = std::fs::read_to_string(&reviewer_path).unwrap();
1271
1272 let identities = load_workspace_agent_profile_identities(tmp.path())
1273 .expect("legacy neighbor must not block identity discovery");
1274 assert_eq!(identities.len(), 1);
1275 assert_eq!(identities[0].id, "reviewer");
1276 assert!(
1277 identities
1278 .iter()
1279 .all(|identity| !identity.id.eq_ignore_ascii_case("scout")),
1280 "scout id must be free beside legacy reviewer"
1281 );
1282
1283 let draft = FleetProfileDraft {
1284 id: "scout".to_string(),
1285 display_name: Some("Scout".to_string()),
1286 description: Some("Workspace scout.".to_string()),
1287 role_hint: "scout".to_string(),
1288 model_class_hint: None,
1289 model: Some("deepseek-v4-flash".to_string()),
1290 provider: Some("deepseek".to_string()),
1291 reasoning_effort: None,
1292 instructions: None,
1293 };
1294 let scout_path = write_profile(&agents_dir, &draft.file_name(), &draft.render_toml());
1295
1296 let after = std::fs::read_to_string(&reviewer_path).unwrap();
1297 assert_eq!(before, after, "legacy reviewer must remain unmodified");
1298 assert!(scout_path.exists());
1299
1300 let (profiles, issues) = load_workspace_agent_profiles_tolerant(tmp.path())
1301 .expect("directory discovery succeeds");
1302 assert_eq!(profiles.len(), 1);
1303 assert_eq!(profiles[0].id, "scout");
1304 assert_eq!(
1305 profiles[0].profile.model.as_deref(),
1306 Some("deepseek-v4-flash")
1307 );
1308 assert_eq!(issues.len(), 1);
1309 assert!(issues[0].contains("reviewer.toml"), "{issues:?}");
1310 }
1311
1312 #[test]
1313 fn agent_profile_loader_normalizes_project_agent_toml() {
1314 let tmp = TempDir::new().unwrap();
1315 let agents_dir = tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR);
1316 std::fs::create_dir_all(&agents_dir).unwrap();
1317 let source = write_profile(
1318 &agents_dir,
1319 "reviewer.toml",
1320 r#"
1321 name = "adversarial_reviewer"
1322 display_name = "Adversarial Reviewer"
1323 description = "Skeptical read-only review posture"
1324 role_hint = "reviewer"
1325 loadout = "balanced"
1326 model = "deepseek-v4-pro"
1327
1328 [instructions]
1329 text = "Focus on regressions, missing tests, and fragile assumptions."
1330
1331 [tools]
1332 posture = "read-only"
1333 "#,
1334 );
1335
1336 let profiles = load_workspace_agent_profiles(tmp.path()).unwrap();
1337
1338 assert_eq!(profiles.len(), 1);
1339 let profile = &profiles[0];
1340 assert_eq!(profile.id, "adversarial_reviewer");
1341 assert_eq!(
1342 profile.display_name.as_deref(),
1343 Some("Adversarial Reviewer")
1344 );
1345 assert_eq!(
1346 profile.description.as_deref(),
1347 Some("Skeptical read-only review posture")
1348 );
1349 assert_eq!(profile.profile.slot, FleetSlot::Reviewer);
1350 assert_eq!(profile.profile.role.name, "reviewer");
1351 assert_eq!(
1352 profile.profile.role.instructions.as_deref(),
1353 Some("Focus on regressions, missing tests, and fragile assumptions.")
1354 );
1355 assert_eq!(
1356 profile.profile.loadout,
1357 FleetLoadout::Custom("balanced".to_string())
1358 );
1359 assert_eq!(profile.profile.model.as_deref(), Some("deepseek-v4-pro"));
1360 assert_eq!(
1361 profile.profile.permissions,
1362 FleetProfilePermissions::default()
1363 );
1364 assert_eq!(profile.source, source);
1365 }
1366
1367 #[test]
1368 fn agent_profile_loader_rejects_retired_model_policy_aliases() {
1369 for (field, value) in [("model_class_hint", "balanced"), ("route_tier", "fast")] {
1370 let tmp = TempDir::new().unwrap();
1371 write_profile(
1372 tmp.path(),
1373 "reviewer.toml",
1374 &format!(
1375 r#"
1376 name = "reviewer"
1377 role_hint = "reviewer"
1378 {field} = "{value}"
1379 "#
1380 ),
1381 );
1382
1383 let err = load_agent_profiles_from_dir(tmp.path())
1384 .unwrap_err()
1385 .to_string();
1386
1387 assert!(
1388 err.contains(field) || err.contains("unknown field"),
1389 "unexpected error for {field}: {err}"
1390 );
1391 }
1392 }
1393
1394 #[test]
1395 fn agent_profile_loader_accepts_and_round_trips_explicit_provider_field() {
1396 // #4093: `provider` is now a first-class, validated field — a Fleet
1397 // profile can name its own route explicitly, independent of whatever
1398 // provider is active when the profile is later loaded/launched.
1399 let tmp = TempDir::new().unwrap();
1400 write_profile(
1401 tmp.path(),
1402 "reviewer.toml",
1403 r#"
1404 name = "reviewer"
1405 provider = "openrouter"
1406 model = "deepseek/deepseek-v4-pro"
1407 "#,
1408 );
1409
1410 let profiles = load_agent_profiles_from_dir(tmp.path()).expect("profile loads");
1411 assert_eq!(profiles.len(), 1);
1412 assert_eq!(profiles[0].profile.provider.as_deref(), Some("openrouter"));
1413 assert_eq!(
1414 profiles[0].profile.model.as_deref(),
1415 Some("deepseek/deepseek-v4-pro")
1416 );
1417 }
1418
1419 #[test]
1420 fn agent_profile_loader_accepts_custom_provider_name() {
1421 // #3965: LM Studio and other user-named OpenAI-compatible providers
1422 // are resolved from `[providers.<id>]` at launch time, so the profile
1423 // loader must preserve the safe id instead of requiring a built-in.
1424 let tmp = TempDir::new().unwrap();
1425 write_profile(
1426 tmp.path(),
1427 "reviewer.toml",
1428 r#"
1429 name = "reviewer"
1430 provider = "lm-studio"
1431 model = "qwen-2.5-7b"
1432 "#,
1433 );
1434
1435 let profiles = load_agent_profiles_from_dir(tmp.path()).expect("profile loads");
1436
1437 assert_eq!(profiles[0].profile.provider.as_deref(), Some("lm-studio"));
1438 assert_eq!(profiles[0].profile.model.as_deref(), Some("qwen-2.5-7b"));
1439 }
1440
1441 #[test]
1442 fn agent_profile_loader_rejects_malformed_provider_name() {
1443 let tmp = TempDir::new().unwrap();
1444 write_profile(
1445 tmp.path(),
1446 "reviewer.toml",
1447 r#"
1448 name = "reviewer"
1449 provider = "lm studio"
1450 model = "some-model"
1451 "#,
1452 );
1453
1454 let err = load_agent_profiles_from_dir(tmp.path())
1455 .unwrap_err()
1456 .to_string();
1457
1458 assert!(
1459 err.contains("provider must be a simple provider id"),
1460 "unexpected error: {err}"
1461 );
1462 }
1463
1464 #[test]
1465 fn agent_profile_loader_rejects_permission_expansion() {
1466 let tmp = TempDir::new().unwrap();
1467 write_profile(
1468 tmp.path(),
1469 "builder.toml",
1470 r#"
1471 name = "builder"
1472
1473 [tools]
1474 posture = "read-write"
1475 "#,
1476 );
1477
1478 let err = load_agent_profiles_from_dir(tmp.path())
1479 .unwrap_err()
1480 .to_string();
1481
1482 assert!(
1483 err.contains("would widen permissions"),
1484 "unexpected error: {err}"
1485 );
1486 }
1487
1488 #[test]
1489 fn agent_profile_loader_rejects_secret_like_model_hint() {
1490 let tmp = TempDir::new().unwrap();
1491 write_profile(
1492 tmp.path(),
1493 "reviewer.toml",
1494 r#"
1495 name = "reviewer"
1496 model = "deepseek-v4-pro api_key=secret"
1497 "#,
1498 );
1499
1500 let err = load_agent_profiles_from_dir(tmp.path())
1501 .unwrap_err()
1502 .to_string();
1503
1504 assert!(
1505 err.contains("model must be a visible model id"),
1506 "unexpected error: {err}"
1507 );
1508 }
1509
1510 #[test]
1511 fn agent_profile_loader_rejects_duplicate_ids() {
1512 let tmp = TempDir::new().unwrap();
1513 write_profile(tmp.path(), "a.toml", "name = \"reviewer\"\n");
1514 write_profile(tmp.path(), "b.toml", "id = \"reviewer\"\n");
1515
1516 let err = load_agent_profiles_from_dir(tmp.path())
1517 .unwrap_err()
1518 .to_string();
1519
1520 assert!(
1521 err.contains("duplicate agent profile id reviewer"),
1522 "unexpected error: {err}"
1523 );
1524 }
1525 }
1526
1526 lines RUST