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