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