1use std::{
2 collections::BTreeMap,
3 path::{Path, PathBuf},
4 sync::Arc,
5};
6
7use cookie_agent_identity::AgentId;
8use cookie_agent_identity::ProviderId;
9use cookie_agent_models::ProviderDefinition;
10use cookie_agent_protocol::paths;
11use indexmap::IndexMap;
12use serde::Deserialize as _;
13
14use crate::{
15 AgentDocument, AgentDocumentSource, AgentMdConfig, AgentRegistry, ApprovalConfig, ConfigError,
16 ContextCompactionConfig, DelegationConfig, EngineConfig, MessagingConfig, ModelRetryConfig,
17 ServerConfig, SessionTitleConfig, ToolOutputConfig,
18 agent_document::parse_agent,
19 runtime::{RawRuntimeLayer, apply_settings, validate_runtime},
20 secure_fs::{
21 LayerRoot, open_layer_root, open_optional_directory, read_optional_file,
22 read_required_file, regular_file_at,
23 },
24 toml_values::{
25 SensitiveJsonValue, SensitiveProviderValues, SensitiveTomlValue,
26 interpolate_provider_values, safe_toml_error, validate_toml_value, zeroize_toml_value,
27 },
28};
29
30const MAX_CONFIG_BYTES: u64 = 1024 * 1024;
31const MAX_AGENT_BYTES: u64 = 256 * 1024;
32const BUILT_IN_CONFIG: &str = include_str!("defaults.toml");
33
34#[derive(Clone, Debug)]
35pub struct LoadedConfiguration {
36 pub runtime: EngineConfig,
42 pub agents: BTreeMap<AgentId, AgentDocument>,
43 pub agent_presets: BTreeMap<String, BTreeMap<AgentId, AgentDocument>>,
44 pub mcp_servers: BTreeMap<String, LoadedMcpServer>,
45 pub user_mcp_servers: BTreeMap<String, crate::McpServerConfig>,
46 pub workspace_mcp_servers: BTreeMap<String, crate::McpServerConfig>,
47 pub plugins: IndexMap<String, crate::PluginConfig>,
48 pub config_paths: ConfigLayerPaths,
49 pub skills: crate::SkillRegistry,
50}
51
52#[derive(Clone, Debug, Default)]
53pub struct ConfigLayerPaths {
54 pub user: Option<PathBuf>,
55 pub workspace: Option<PathBuf>,
56}
57
58#[derive(Clone, Copy, Debug, Eq, PartialEq)]
59pub enum McpServerSource {
60 UserFile,
61 WorkspaceFile,
62 Runtime,
63}
64
65#[derive(Clone, Debug)]
66pub struct LoadedMcpServer {
67 pub source: McpServerSource,
68 pub config: crate::McpServerConfig,
69}
70
71impl LoadedConfiguration {
72 #[must_use]
73 pub fn agent_registry(&self) -> AgentRegistry {
74 AgentRegistry::from_validated(self.agents.clone())
75 }
76
77 #[must_use]
78 pub fn agent_preset_registries(&self) -> BTreeMap<String, AgentRegistry> {
79 self.agent_presets
80 .iter()
81 .map(|(name, agents)| (name.clone(), AgentRegistry::from_validated(agents.clone())))
82 .collect()
83 }
84}
85
86pub fn load(cwd: &Path) -> Result<LoadedConfiguration, ConfigError> {
88 let cwd = cwd.canonicalize().map_err(ConfigError::Io)?;
89 let user = user_root();
90 let mut loaded = load_from_roots(user.as_deref(), Some(&cwd.join(".cookie-agent")))?;
91 loaded.skills = crate::load_skills(&cwd)?;
92 Ok(loaded)
93}
94
95pub fn load_from_roots(
97 user_root: Option<&Path>,
98 workspace_root: Option<&Path>,
99) -> Result<LoadedConfiguration, ConfigError> {
100 let user = user_root
101 .map(|path| open_layer_root(path, AgentDocumentSource::User))
102 .transpose()?
103 .flatten();
104 let workspace = workspace_root
105 .map(|path| open_layer_root(path, AgentDocumentSource::Workspace))
106 .transpose()?
107 .flatten();
108 let mut runtime = EngineConfig {
109 server: ServerConfig::default(),
110 tool_output: ToolOutputConfig::default(),
111 agent_md: AgentMdConfig::default(),
112 approval: ApprovalConfig::default(),
113 model_retry: ModelRetryConfig::default(),
114 context_compaction: ContextCompactionConfig::default(),
115 session_title: SessionTitleConfig::default(),
116 delegation: DelegationConfig::default(),
117 messaging: MessagingConfig::default(),
118 pricing: Default::default(),
119 headers: BTreeMap::new(),
120 providers: BTreeMap::new(),
121 };
122 let mut agents = BTreeMap::new();
123 let mut preset_agents = BTreeMap::<String, BTreeMap<AgentId, AgentDocument>>::new();
124 let mut mcp_servers = BTreeMap::new();
125 let mut user_mcp_servers = BTreeMap::new();
126 let mut workspace_mcp_servers = BTreeMap::new();
127 let mut plugins = IndexMap::new();
128 let built_in_text =
129 BUILT_IN_CONFIG.replace("${cookie_agent_version}", env!("CARGO_PKG_VERSION"));
130 let built_in_path = Path::new("<built-in>/config.toml");
131 let mut built_in_value = built_in_text.parse::<toml::Value>().map_err(|error| {
132 ConfigError::Toml(format!(
133 "{}: {}",
134 built_in_path.display(),
135 safe_toml_error(&built_in_text, &error)
136 ))
137 })?;
138 interpolate_provider_values(&mut built_in_value, &mut Vec::new())?;
139 let built_in_layer = decode_runtime_layer(&mut built_in_value, &built_in_text, built_in_path)?;
140 apply_settings(&mut runtime, &built_in_layer);
141 for root in [user.as_ref(), workspace.as_ref()].into_iter().flatten() {
142 if let Some((mut layer, source_text)) = root.load_runtime()? {
143 apply_settings(&mut runtime, &layer);
144 if let Some(mcp) = layer.mcp.take() {
145 let source = match root.source {
146 AgentDocumentSource::User => McpServerSource::UserFile,
147 AgentDocumentSource::Workspace => McpServerSource::WorkspaceFile,
148 AgentDocumentSource::BuiltIn => McpServerSource::Runtime,
149 };
150 for (name, config) in mcp.servers {
151 match source {
152 McpServerSource::UserFile => {
153 user_mcp_servers.insert(name.clone(), config.clone());
154 }
155 McpServerSource::WorkspaceFile => {
156 workspace_mcp_servers.insert(name.clone(), config.clone());
157 }
158 McpServerSource::Runtime => unreachable!("file configuration layer"),
159 }
160 mcp_servers.insert(name, LoadedMcpServer { source, config });
161 }
162 }
163 if let Some(layer_plugins) = layer.plugins.take() {
164 plugins.extend(plugins_in_authored_order(layer_plugins, &source_text).map(
165 |(name, config)| {
166 (
167 name,
168 (
169 config,
170 root.path.join("config.toml"),
171 Arc::clone(&source_text),
172 ),
173 )
174 },
175 ));
176 }
177 for (id, mut value) in std::mem::take(&mut layer.providers) {
178 let json = SensitiveJsonValue::from_toml(value.take());
179 let provider = ProviderDefinition::deserialize(json.value()).map_err(|error| {
180 config_decode_error(
181 &root.path.join("config.toml"),
182 None,
183 &format!("provider `{id}`: {error}"),
184 )
185 })?;
186 runtime.providers.insert(id, provider);
187 }
188 }
189 let layer_agents = root.load_agents()?;
190 for (id, document) in layer_agents.shared {
191 agents.insert(id, document);
192 }
193 for (preset, documents) in layer_agents.presets {
194 preset_agents.entry(preset).or_default().extend(documents);
195 }
196 }
197 AgentRegistry::validate_ref(&agents)?;
198 let agent_presets = preset_agents
199 .into_iter()
200 .map(|(preset, documents)| {
201 let mut effective = agents.clone();
202 effective.extend(documents);
203 AgentRegistry::validate_ref(&effective)?;
204 Ok((preset, effective))
205 })
206 .collect::<Result<_, ConfigError>>()?;
207 runtime.pricing.models = runtime
208 .providers
209 .iter()
210 .flat_map(|(provider_id, provider)| {
211 let prices: Vec<_> = match provider {
212 ProviderDefinition::ModelsDev(provider) => provider
213 .model_overrides
214 .iter()
215 .filter_map(|(id, model)| model.pricing.map(|pricing| (id, pricing)))
216 .collect(),
217 ProviderDefinition::Custom(provider) => provider
218 .models
219 .iter()
220 .filter_map(|(id, model)| model.pricing.map(|pricing| (id, pricing)))
221 .collect(),
222 };
223 prices.into_iter().map(|(id, pricing)| {
224 (
225 cookie_agent_identity::ModelKey::new(provider_id.clone(), id.clone())
226 .expect("validated model IDs"),
227 pricing,
228 )
229 })
230 })
231 .collect();
232 validate_runtime(&runtime)?;
233 for (name, server) in &mcp_servers {
234 server.config.validate(name)?;
235 }
236 for (name, (plugin, path, text)) in &plugins {
237 if let Some(field) = plugin.invalid_field(name) {
238 return Err(config_decode_error(
239 path,
240 Some(text),
241 &format!("plugin `{name}`: invalid field `{field}`"),
242 ));
243 }
244 }
245 let plugins = plugins
246 .into_iter()
247 .map(|(name, (config, _, _))| (name, config))
248 .collect();
249 Ok(LoadedConfiguration {
250 runtime,
251 agents,
252 agent_presets,
253 mcp_servers,
254 user_mcp_servers,
255 workspace_mcp_servers,
256 plugins,
257 config_paths: ConfigLayerPaths {
258 user: user_root.map(|root| root.join("config.toml")),
259 workspace: workspace_root.map(|root| root.join("config.toml")),
260 },
261 skills: crate::SkillRegistry::default(),
262 })
263}
264
265fn plugins_in_authored_order(
266 mut plugins: crate::PluginsConfig,
267 source_text: &str,
268) -> impl Iterator<Item = (String, crate::PluginConfig)> {
269 let document = source_text
270 .parse::<toml_edit::DocumentMut>()
271 .expect("validated TOML reparses for plugin ordering");
272 let names: Vec<String> = document
273 .get("plugins")
274 .and_then(toml_edit::Item::as_table)
275 .map(|table| table.iter().map(|(name, _)| name.to_owned()).collect())
276 .unwrap_or_default();
277 names.into_iter().filter_map(move |name| {
278 plugins
279 .plugins
280 .shift_remove(&name)
281 .map(|config| (name, config))
282 })
283}
284
285impl LayerRoot {
286 fn load_runtime(&self) -> Result<Option<(RawRuntimeLayer, Arc<str>)>, ConfigError> {
287 let Some(bytes) = read_optional_file(&self.path, "config.toml", MAX_CONFIG_BYTES)? else {
288 return Ok(None);
289 };
290 let path = self.path.join("config.toml");
291 let text = std::str::from_utf8(&bytes)
292 .map_err(|_| ConfigError::Toml(format!("{}: content is not UTF-8", path.display())))?;
293 let value = text.parse::<toml::Value>().map_err(|error| {
294 ConfigError::Toml(format!(
295 "{}: {}",
296 path.display(),
297 safe_toml_error(text, &error)
298 ))
299 })?;
300 let mut value = SensitiveTomlValue::new(value);
301 validate_toml_value(value.value()).map_err(|_| {
302 ConfigError::Toml(format!(
303 "{}: TOML resource limit or unsupported datetime exceeded",
304 path.display()
305 ))
306 })?;
307 interpolate_provider_values(value.value_mut(), &mut Vec::new())?;
308 let layer = decode_runtime_layer(value.value_mut(), text, &path)?;
309 Ok(Some((layer, Arc::from(text))))
310 }
311
312 fn load_agents(&self) -> Result<AgentLayer, ConfigError> {
313 let Some(directory) = open_optional_directory(&self.path, "agents")? else {
314 return Ok(AgentLayer::default());
315 };
316 let names = sorted_entry_names(&directory)?;
317 let mut layer = AgentLayer::default();
318 for name in names {
319 if name.ends_with(".md") {
320 load_agent_document(&directory, &name, self.source, &mut layer.shared)?;
321 continue;
322 }
323 if regular_file_at(&directory, &name)? {
324 continue;
325 }
326 let preset_directory =
327 open_optional_directory(&directory, &name)?.ok_or(ConfigError::UnsafePath)?;
328 AgentId::new(&name).map_err(|_| ConfigError::AgentPresetName {
329 path: preset_directory.clone(),
330 name: name.clone(),
331 })?;
332 let mut documents = BTreeMap::new();
333 for document_name in sorted_entry_names(&preset_directory)? {
334 if !document_name.ends_with(".md") {
335 return Err(ConfigError::UnsafePath);
336 }
337 load_agent_document(
338 &preset_directory,
339 &document_name,
340 self.source,
341 &mut documents,
342 )?;
343 }
344 layer.presets.insert(name, documents);
345 }
346 Ok(layer)
347 }
348}
349
350#[derive(Default)]
351struct AgentLayer {
352 shared: BTreeMap<AgentId, AgentDocument>,
353 presets: BTreeMap<String, BTreeMap<AgentId, AgentDocument>>,
354}
355
356fn sorted_entry_names(directory: &Path) -> Result<Vec<String>, ConfigError> {
357 let mut names = std::fs::read_dir(directory)
358 .map_err(ConfigError::Io)?
359 .map(|entry| {
360 entry
361 .map_err(ConfigError::Io)?
362 .file_name()
363 .into_string()
364 .map_err(|_| ConfigError::UnsafePath)
365 })
366 .collect::<Result<Vec<_>, _>>()?;
367 names.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
368 Ok(names)
369}
370
371fn load_agent_document(
372 directory: &Path,
373 name: &str,
374 source: AgentDocumentSource,
375 documents: &mut BTreeMap<AgentId, AgentDocument>,
376) -> Result<(), ConfigError> {
377 if !regular_file_at(directory, name)? {
378 return Err(ConfigError::UnsafePath);
379 }
380 let stem = name.strip_suffix(".md").expect("suffix checked");
381 let id = AgentId::new(stem).map_err(|_| ConfigError::AgentFilename(name.to_owned()))?;
382 let bytes = read_required_file(directory, name, MAX_AGENT_BYTES)?;
383 let document = parse_agent(id.clone(), &bytes, source, &directory.join(name))?;
384 if documents.insert(id.clone(), document).is_some() {
385 return Err(ConfigError::DuplicateAgent(id));
386 }
387 Ok(())
388}
389
390fn decode_runtime_layer(
391 value: &mut toml::Value,
392 text: &str,
393 path: &Path,
394) -> Result<RawRuntimeLayer, ConfigError> {
395 const ALLOWED: &[&str] = &[
396 "server",
397 "tool_output",
398 "agent_md",
399 "approval",
400 "model_retry",
401 "context_compaction",
402 "session_title",
403 "delegation",
404 "messaging",
405 "headers",
406 "mcp",
407 "plugins",
408 "providers",
409 ];
410 let table = value.as_table_mut().ok_or_else(|| {
411 ConfigError::Toml(format!("{}: top level must be a table", path.display()))
412 })?;
413 if table.contains_key("schema_version") {
414 return Err(ConfigError::ConfigSchemaRemoved {
415 path: path.to_owned(),
416 line: key_line(text, "schema_version").unwrap_or(1),
417 });
418 }
419 if let Some(key) = table.keys().find(|key| !ALLOWED.contains(&key.as_str())) {
420 return Err(ConfigError::Toml(format!(
421 "{} line {}: unknown top-level field `{key}`; remove it",
422 path.display(),
423 key_line(text, key).unwrap_or(1)
424 )));
425 }
426 if table
427 .get("context_compaction")
428 .and_then(toml::Value::as_table)
429 .is_some_and(|context| {
430 context.contains_key("trigger") && context.contains_key("buffer_tokens")
431 })
432 {
433 return Err(ConfigError::Toml(format!(
434 "{}: context_compaction.trigger and context_compaction.buffer_tokens cannot both be set",
435 path.display()
436 )));
437 }
438 let providers = match table.remove("providers") {
439 None => SensitiveProviderValues::new(),
440 Some(toml::Value::Table(values)) => values
441 .into_iter()
442 .map(|(id, value)| {
443 let value = SensitiveTomlValue::new(value);
444 ProviderId::new(id).map(|id| (id, value)).map_err(|_| {
445 ConfigError::Toml(format!("{}: invalid provider ID", path.display()))
446 })
447 })
448 .collect::<Result<_, _>>()?,
449 Some(mut other) => {
450 zeroize_toml_value(&mut other);
451 return Err(ConfigError::Toml(format!(
452 "{}: `providers` must be a table",
453 path.display()
454 )));
455 }
456 };
457 let owned = std::mem::replace(value, toml::Value::Table(Default::default()));
458 let json = SensitiveJsonValue::from_toml(owned);
459 let mut layer = RawRuntimeLayer::deserialize(json.value())
460 .map_err(|error| config_decode_error(path, Some(text), &error.to_string()))?;
461 layer.providers = providers;
462 Ok(layer)
463}
464
465fn config_decode_error(path: &Path, text: Option<&str>, message: &str) -> ConfigError {
466 let field = extract_serde_field(message, "unknown field")
467 .map(|field| (field, "unknown field"))
468 .or_else(|| {
469 extract_serde_field(message, "missing field").map(|field| (field, "missing field"))
470 })
471 .or_else(|| {
472 extract_serde_field(message, "invalid field").map(|field| (field, "invalid field"))
473 });
474 let detail = field.as_ref().map_or_else(
475 || "malformed configuration content".to_owned(),
476 |(field, kind)| {
477 let line = text.and_then(|text| key_line(text, field));
478 let advice = match *kind {
479 "unknown field" => "remove it",
480 "missing field" => "add it",
481 "invalid field" => "fix it",
482 _ => unreachable!("known configuration diagnostic kind"),
483 };
484 match line {
485 Some(line) => format!("line {line}: {kind} `{field}`; {advice}"),
486 None => format!("{kind} `{field}`; {advice}"),
487 }
488 },
489 );
490 ConfigError::Toml(format!("{}: {detail}", path.display()))
491}
492
493fn extract_serde_field(message: &str, marker: &str) -> Option<String> {
494 let rest = message.split_once(marker)?.1.trim_start();
495 let quote = rest.chars().next()?;
496 if !matches!(quote, '`' | '\'' | '"') {
497 return None;
498 }
499 rest[quote.len_utf8()..]
500 .split_once(quote)
501 .map(|(field, _)| field.to_owned())
502}
503
504fn key_line(text: &str, key: &str) -> Option<usize> {
505 text.lines()
506 .position(|line| {
507 line.split_once('=')
508 .is_some_and(|(candidate, _)| candidate.trim().trim_matches('"') == key)
509 })
510 .map(|index| index + 1)
511}
512
513fn user_root() -> Option<PathBuf> {
514 paths::user_data_root().ok()
515}
516
517#[cfg(test)]
518mod agent_loader_tests {
519 use super::*;
520
521 #[test]
522 fn duplicate_agent_ids_in_one_preset_map_are_rejected() {
523 let directory = tempfile::tempdir().unwrap();
524 std::fs::write(
525 directory.path().join("worker.md"),
526 "---\ndescription: Worker\nmode: primary\nenabled: true\nmodels: [{ model: \"custom.test/model\" }]\npermissions: {}\n---\nPrompt.\n",
527 )
528 .unwrap();
529 let mut documents = BTreeMap::new();
530 load_agent_document(
531 directory.path(),
532 "worker.md",
533 AgentDocumentSource::Workspace,
534 &mut documents,
535 )
536 .unwrap();
537 assert!(matches!(
538 load_agent_document(
539 directory.path(),
540 "worker.md",
541 AgentDocumentSource::Workspace,
542 &mut documents,
543 ),
544 Err(ConfigError::DuplicateAgent(id)) if id.as_str() == "worker"
545 ));
546 }
547}